cppally

cppally is a high-performance, header-only library providing a rich C++20 API for R.

It offers type-safe classes, C++20 concepts, template registration, and R-based scalars to make code readable, fast, and maintainable.

Multiple threads and Single-Instruction-Multiple-Data (SIMD) vectorisation are used where possible. The number of threads used across cppally can be set with cppally::set_threads() and queried with cppally::get_threads().

For info on using cppally see Getting started with cppally

Acknowledgements

I first want to thank the authors and contributors of the fantastic cpp11 R package, without which I would not have been inspired to write this package. I’d also like to thank the authors and contributors of Rcpp for developing this ecosystem that has laid much of the groundwork for C++ and R integration.

cppally features

Installation

Install the CRAN release

install.packages("cppally")

or the development version

# pak::pak("NicChr/cppally")
install.packages("cppally", repos = c('https://nicchr.r-universe.dev', 'https://cloud.r-project.org'))

Registering C++ templates to R

Example: Templated sum


template <RMathType T>
[[cppally::register]]
r_dbl cpp_sum(r_vector<T> x){
  r_size_t n = x.length();
  r_dbl out(0);

  for (r_size_t i = 0; i < n; ++i){
    out += x.get(i);
  }
  return out;
}
  

You can use cpp_source() to register this C++ function to R

cpp_source(code = '
#include <cppally.hpp>
using namespace cppally;

template <RMathType T>
[[cppally::register]]
r_dbl cpp_sum(r_vector<T> x){
  r_size_t n = x.length();
  r_dbl out(0);

  for (r_size_t i = 0; i < n; ++i){
    out += x.get(i);
  }
  return out;
}
')
cpp_sum(1:5)
#> [1] 15

NA values are handled like in R. In this case NA is returned if the vector contains one or more NA values.

cpp_sum(c(1, NA, 3))
#> [1] NA

Since it is a template, it can accept various classes (that satisfy RMathType).

cpp_sum(c(T, F)) # Logicals
#> [1] 1
cpp_sum(c(1L, 2L, 3L)) # Integers
#> [1] 6
cpp_sum(c(1.5, 2.5, 3.5)) # Doubles
#> [1] 7.5

Character vector elements (r_str) don’t satisfy RMathType, so r_vector<r_str> is rejected.

cpp_sum("A STRING")
#> Error:
#> ! Argument 1 of type character does not satisfy the template constraints

Design choices

Templates

cppally uses templates a lot for generic programming. While this can be a flexible way to write generic functions, it can slow down compile times and increase binary sizes.

There are two limitations when it comes to registering C++ templates to R.

The first is a C++ limitation, though it only applies when that template is shared across more than 1 cpp file (translation unit).

The second is an R/C++ interoperability limitation. It means we can’t call e.g. foo<T>(x) directly from R. Instead we rely on automatic deduction, which the majority of templates use. Automatic deduction here looks like foo(x) where T is deduced from x by a cppally template dispatch layer between R and C++.

Scalar R types and custom methods

cppally offers scalar C++ types that mirror their R counterpart in the sense that they are NA aware and match other R semantics specific to that class.

Scalar classes that cppally offers

Scalar class R-counterpart
r_lgl logical(1)
r_int integer(1)
r_dbl double(1)
r_str character(1)
r_cplx complex(1)
r_int64 bit64::integer64(1)
r_date as.Date(double(1))
r_psxct as.POSIXct(double(1))
r_raw raw(1)

Scalar arithmetic

Many operators and methods have been written to ensure safe, correct, and efficient behaviour. This includes binary arithmetic operators such as: +, -, /, *, and many more.

For example, in standard C++, an integer divided by another integer int / int always results in an integer int. With cppally, division always results in an R double r_dbl, so r_int / r_int returns an R double, matching R semantics.

Another example is the modulo operator %. With standard C++ integers, this returns the remainder after integer division (truncated towards zero), but R uses Knuth floored division, which cppally also implements to match. (int) -5 % 2 returns -1 (truncated modulo), whereas (r_int) -5 % 2 returns 1 (floored modulo).

Integer overflow

Integer overflow with cppally integers is never undefined behaviour. NA will always be returned where there is integer overflow. Let’s look at how cppally’s + operator is currently defined to see this in detail.

// First few lines of cppally::operator+

template <MathType T, MathType U>
requires (RMathType<T> || RMathType<U>)
  inline constexpr auto operator+(T lhs, U rhs) noexcept {
    
    using common_t = common_math_t<T, U>;
    
    if constexpr (RIntegerType<common_t>){
      using I  = unwrap_t<common_t>;
      using UI = std::make_unsigned_t<I>;
      
      I a = static_cast<I>(unwrap(lhs));
      I b = static_cast<I>(unwrap(rhs));
      
      // Wraparound sum via unsigned: defined behaviour
      I s = static_cast<I>(static_cast<UI>(a) + static_cast<UI>(b));
      
      // Overflowed iff a and b share a sign that s does not
      // a ^ s: Sign bit of the XOR is 1 when a and s have different signs, otherwise 0
      // b ^ s: Same logic as a ^ s
      // (a ^ s) & (b ^ s): bitwise AND, the sign bit of the result is 1 only when both a ^ s and b ^ s have their sign bit set
      // Overflow can only happen when we are adding two numbers of the same sign
      // and because this is C++20, two's complement applies, which means we can check whether the top bit is set via < 0
      bool bad = (((a ^ s) & (b ^ s)) < 0) | internal::any_arithmetic_na(lhs, rhs);
      return bad ? common_t::na() : common_t(s);

The steps are as follows:

  1. Find the common type between the two via cppally::common_math_t
  2. Work with the unwrapped C++ primitives
  3. Cast lhs and rhs to the common integer type
  4. Cast lhs and rhs to their unsigned type, add together, and cast back to the common signed type
  5. If a and b have identical sign but s does not, then there is integer overflow
  6. If there was overflow or if either lhs or rhs is NA, return NA, otherwise return the added result

Since we are using same-width[^1] integers and branchless operations, integer addition with cppally is fast and easily SIMD vectorisable.

Relational operators

Relational operators like ==, <, etc, are all NA-aware as well, and return r_lgl instead of bool.

For example r_int(5) == r_dbl(5) returns r_true, whereas na<r_int>() == na<r_int>() returns r_na.

Automatic protection

Like the excellent cpp11 package, cppally also handles automatic protection for R objects. For more info see Automatic Protection.

ALTREP

For performance reasons, ALTREP materialisation is eager by default, which means that ALTREP vectors are materialised on construction.

To preserve ALTREP compact representations, one can enable the package-wide CPPALLY_PRESERVE_ALTREP flag. This can be done through cppally::use_preserve_altrep_flag() or cppally::cpp_source(..., preserve_altrep = TRUE). You can also manually add the -DCPPALLY_PRESERVE_ALTREP flag to Makevars.

Copy-on-modify

Copy-on-modify is an opt-in feature which can be enabled via cppally::use_copy_on_modify() or by setting the CPPALLY_COPY_ON_MODIFY Makevars flag directly.

When enabled, all modifications are first checked to ensure that the object being modified isn’t referenced or owned by another object. If it is referenced, a copy is taken first before modifying.

This safety check is inherently single-threaded which means that enabling copy-on-modify disables almost all parallelisation, degrading cppally performance. Enable if you want to prevent accidental modification of shared objects.

By default, copy-on-modify is disabled and hence all element setting is done in-place. It is up to the user to ensure that the vector they are modifying is safe to modify. Vectors you create with r_vector<T>(n) are freshly constructed, and hence safe to modify at that point. You can reliably check that it is safe to modify a vector by first calling r_vector::is_exclusive(). If the vector is exclusive, it means it is not referenced by another object and therefore is safe to modify in-place. If it is not exclusive, you can call r_vector::copy() to return a copied (fresh) vector.

Using both cppally and the R C API

Using the R C API alongside cppally is strongly discouraged for the following reasons.

If R throws an error via Rf_error() a ‘longjmp’ will occur, meaning C++ destructors won’t run and memory that should have been released will not be released.

Furthermore, due to the way cppally caches vector names, using R C API functions like Rf_setAttrib() will set the vector’s names without informing cppally, leading to synchronisation issues. cppally needs to keep the names cache in sync with the R names attribute and the only way it can do that is by detecting changes to the names via cppally::r_vector::set_names() or cppally::attr::set_attr() (or cppally::set_old_names()).

Attributes

Attribute manipulation can be done using functions in the attr namespace via cppally::attr::

Views

A view in standard C++ can mean different things. With std::string_view, it means we don’t own the string data, we are just viewing its contents. r_str_view is similar, but the data it’s referring to is the underlying SEXP. Views in cppally mean “I don’t protect the SEXP”. Therefore, r_str_view is a view into the CHARSXP, without taking protection ownership of it. r_str on the other hand does protect the CHARSXP, via r_sexp.

Why use views?

We offer views for performance reasons. Protecting an object is a considerable overhead (about 5-10 ns per protect). We can avoid that overhead by using views. This is safe as long as we are sure that the view object is protected by another object.

Here are some common use-cases where one might use views:

How to use views

r_str_view is a dedicated view type for r_str. As mentioned above it can be used as function arguments for string inputs. This has the benefit of flexibly accepting r_str and r_str_view, without the compile cost of a template.

It is okay as an input, but generally not okay as an output.

Good:

r_str foo(r_str_view x){
  return r_str(x);
}

Bad:

r_str_view foo(r_str_view x){
  r_str my_str("hello");
  return my_str;
}

If in doubt, always use r_str. For more info on views see Automatic Protection

View a vector element

If you have a vector, you can use r_vector::view(). This will get the element without re-protecting it. Just make sure that the element view does not outlive its parent vector.

r_vector<r_sexp> my_list( {r_sexp(), r_sexp()} );

// Unprotected but implicitly protected by my_list
r_sexp element0 = my_list.view(0);
r_sexp element1 = my_list.view(1);

// element0 or element1 must not outlive my_list

A copy of an element view is still a view, and not protected.

r_sexp my_element = r_sexp(element0); // my_element is not protected

If in doubt, use get()

// Both element0 and element1 are protected themselves
r_sexp element0 = my_list.get(0);
r_sexp element1 = my_list.get(1);

r_sexp my_element = element0; // Copy here is very cheap (0.2-0.5 ns/copy)

Lossy coercion

Any coercion that results in complete information loss is an error (partial is allowed, e.g. double -> int).

For example, string -> integer conversion may not be possible without complete information loss

as<r_int>(r_str("a"))
#> Error:
#> ! Implicit NA coercion detected from r_str to r_int, please ensure data can be coerced without complete loss of information

This is in contrast to R which returns an NA with a warning

as.integer("a")
#> Warning: NAs introduced by coercion
#> [1] NA

The benefit of cppally’s approach is that when registering C++ functions to R, inputs can be supplied flexibly without unexpected behaviour.

Let’s say you have a function foo that expects an r_int but you give it an r_dbl without realising - this will implicitly coerce to r_int without throwing an error.


[[cppally::register]]
r_int foo(r_int x){
  return x;
}
foo(1.2345)
#> [1] 1

The double 1.2345 was implicitly converted to 1, an example of partial lossy coercion. cppally allows this.

What cppally doesn’t allow is total lossy coercion, which can result in ambiguity. Take the following example of counting the occurrence of a value in a vector.


[[cppally::register]]
int count_val(r_vector<r_dbl> x, r_dbl value){
  return x.count(value);
}
x <- c(rep(0, 20), rep(1, 30), rep(NA, 40))

count_val(x, 0)
#> [1] 20
count_val(x, 1)
#> [1] 30
count_val(x, NA)
#> [1] 40

# value can also implicitly coerce a string to a double

count_val(x, "0")
#> [1] 20
count_val(x, "1")
#> [1] 30

So far so good. But what happens if we implicitly coerced to NA and then counted occurrences?

count_val(x, as.double("1. 0")) # Wrong
#> Warning in count_val(x, as.double("1. 0")): NAs introduced by coercion
#> [1] 40

as.double("1. 0") was coerced to NA, count_val() then counted the number of NA values and returned 40, even though we didn’t ask for a count of NA, so the result should have been 0.

This is the main issue with allowing total lossy coercion to NA - count_val() can’t distinguish between a true NA and an NA that has been produced from a lossy coercion.

With cppally this ambiguity is impossible.

count_val(x, "1. 0")
#> Error:
#> ! Implicit NA coercion detected from r_str to r_dbl, please ensure data can be coerced without complete loss of information

Vector indexing

All indexing is 0-based including subsetting vectors.

subset(make_vector<r_dbl>(10, 20, 30), make_vector<r_int>(1)); // index 1 = second element = 20

64-bit integers

On the C++ side, 64-bit integers are fully supported, including vectors. To return 64-bit integers to R we need the bit64 package to be loaded. cppally delegates the handling of 64-bit integer vectors in R to bit64 by marking them with the “integer64” class.

library(bit64)
#> Warning: package 'bit64' was built under R version 4.6.1

[[cppally::register]]
r_int64 as_int64(r_int x){
    return as<r_int64>(x);
}
as_int64(.Machine$integer.max) + 1L
#> integer64
#> [1] 2147483648

Please note that aliases of standard C++ signed 64-bit integer types like int64_t, R_xlen_t and cppally’s r_size_t will convert to 64-bit integer vectors when returned to R.

as<r_size_t>(r_int(0))
#> integer64
#> [1] 0
as<R_xlen_t>(r_int(0))
#> integer64
#> [1] 0
as<int64_t>(r_int(0))
#> integer64
#> [1] 0

Using R’s NULL

The cppally version of R’s R_NilValue is r_null which is of type r_sexp. In an attempt to avoid the use of additional meta-programming tactics to deal with r_null, we allow vectors the special ability to hold r_null. This makes programming with attributes easier, since attributes are sometimes vectors and sometimes NULL.

To check if a vector is NULL, use r_vector::is_null().

r_vector<r_int>(r_null)
#> NULL
r_vector<r_int>(r_null).is_null()
#> [1] TRUE

Large cpp files on Windows

Because cppally is a template-heavy library, binary sizes can sometimes get large. Once a .o file gets too big, Windows will throw a compiler warning or error (the default limit is 65,279 addressable sections). To increase this limit to 4,294,967,296, add the following flag:

PKG_CXXFLAGS = -Wa,-mbig-obj

If you are submitting an R package to CRAN, this will likely trigger a CRAN note, so it is worth flagging this in your cran-comments.md file.

Enabling OpenMP

To benefit from OpenMP multi-threaded execution and SIMD vectorisation, run cppally::use_openmp() which will set the appropriate Makevars flags. Note that this is automatically set by cppally::use_cppally().

Threads can be controlled with set_threads() and get_threads().

C++20 and RStudio

At the moment C++20 is not fully supported via RStudio, so I would recommend using VSCode with the C/C++ for Visual Studio Code extension.

To get VSCode intellisense to work correctly, you will likely need to set some parameters in c_cpp_properties.json.

My json file looks like this:

{
  "configurations": [
    {
      "name": "Win32",
      "includePath": [
        "${workspaceFolder}/**",
        "${workspaceFolder}/src",
        "${workspaceFolder}/inst/include",
        "C:/Program Files/R/R-4.*/include",
        "${env:LOCALAPPDATA}/R/win-library/4.*/cpp11/include",
        "${env:LOCALAPPDATA}/R/win-library/4.*/Rcpp/include",
        "${env:LOCALAPPDATA}/R/win-library/4.*/cppally/include"
      ],
      "defines": [
        "_DEBUG",
        "UNICODE",
        "_UNICODE",
        "STRICT_R_HEADERS"
      ],
      "compilerPath": "C:\\rtools45\\x86_64-w64-mingw32.static.posix\\bin\\g++.exe",
      "cppStandard": "gnu++20",
      "intelliSenseMode": "gcc-x64"
    }
  ],
  "version": 4
}

As your R installation path may differ, you can find the exact path with

normalizePath(Sys.getenv("R_HOME"), winslash = "/")

Your R libraries can be found with

.libPaths()

The compiler bundled with RTools is likely found here

cxx <- system2(file.path(R.home("bin"), "R"), 
               c("CMD", "config", "CXX20"), stdout = TRUE)
cxx_bin <- trimws(strsplit(cxx, " ")[[1]][1])
Sys.which(cxx_bin)
#>                                          g++ 
#> "C:\\rtools45\\X86_64~1.POS\\bin\\G__~1.EXE"

Once you have both paths, set compilerPath and the R include path in c_cpp_properties.json accordingly.

[^1]: We could also check 32-bit integers for overflow by first casting to 64-bit, but this is not as efficient as using 32-bit operations.