Rewriting R code in C++

Learning objectives:

  • Learn to improve performance by rewriting bottlenecks in C++

  • Introduction to the {Rcpp} package

Introduction

In this chapter we’ll learn how to rewrite R code in C++ to make it faster using the Rcpp package. The Rcpp package makes it simple to connect C++ to R! With C++ you can fix:

  • Loops that can’t be easily vectorised because subsequent iterations depend on previous ones.

  • Recursive functions, or problems which involve calling functions millions of times. The overhead of calling a function in C++ is much lower than in R.

  • Problems that require advanced data structures and algorithms that R doesn’t provide. Through the standard template library (STL), C++ has efficient implementations of many important data structures, from ordered maps to double-ended queue

Like how?

Getting started with C++

library(Rcpp)

Install a C++ compiler:

  • Rtools, on Windows
  • Xcode, on Mac
  • Sudo apt-get install r-base-dev or similar, on Linux.

First example

Rcpp compiling the C++ code:

cppFunction('int add(int x, int y, int z) {
  int sum = x + y + z;
  return sum;
}')
# add works like a regular R function
add
#> function (x, y, z) 
#> .Call(<pointer: 0x00007ff9d7e315f0>, x, y, z)
add(1, 2, 3)
#> [1] 6

Some things to note:

  • The syntax to create a function is different.
  • Types of inputs and outputs must be explicitly declared
  • Use = for assignment, not <-.
  • Every statement is terminated by a ;
  • C++ has it’s own name for the types we are used to:
    • scalar types are int, double, bool and String
    • vector types (for Rcpp) are IntegerVector, NumericVector, LogicalVector and CharacterVector
    • Other R types are available in C++: List, Function, DataFrame, and more.
  • Explicitly use a return statement to return a value from a function.

Example with scalar input and output

signR <- function(x) {
  if (x > 0) {
    1
  } else if (x == 0) {
    0
  } else {
    -1
  }
}

a <- -0.5
b <- 0.5
c <- 0
signR(c)
#> [1] 0

Translation:

cppFunction('int signC(int x) {
  if (x > 0) {
    return 1;
  } else if (x == 0) {
    return 0;
  } else {
    return -1;
  }
}')
  • Note that the if syntax is identical! Not everything is different!

Vector Input, Scalar output:

sumR <- function(x) {
  total <- 0
  for (i in seq_along(x)) {
    total <- total + x[i]
  }
  total
}

x<- runif(100)
sumR(x)
#> [1] 51.16287

Translation:

cppFunction('double sumC(NumericVector x) {
  int n = x.size();
  double total = 0;
  for(int i = 0; i < n; ++i) {
    total += x[i];
  }
  return total;
}')

Some observations:

  • vector indices start at 0
  • The for statement has a different syntax: for(init; check; increment)
  • Methods are called with .
  • total += x[i] is equivalent to total = total + x[i].
  • other in-place operators are -=, *=, and /=

To check for the fastest way we can use:

?bench::mark
x <- runif(1e3)
bench::mark(
  sum(x),
  sumC(x),
  sumR(x)
)
#> # A tibble: 3 × 6
#>   expression      min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 sum(x)        700ns    1.6µs   611939.        0B        0
#> 2 sumC(x)       900ns    1.7µs   469982.        0B        0
#> 3 sumR(x)      14.2µs   27.4µs    33865.        0B        0

Vector input and output

pdistR <- function(x, ys) {
  sqrt((x - ys) ^ 2)
}
cppFunction('NumericVector pdistC(double x, NumericVector ys) {
  int n = ys.size();
  NumericVector out(n);

  for(int i = 0; i < n; ++i) {
    out[i] = sqrt(pow(ys[i] - x, 2.0));
  }
  return out;
}')

Note: uses pow(), not ^, for exponentiation

y <- runif(1e6)
bench::mark(
  pdistR(0.5, y),
  pdistC(0.5, y)
)[1:6]
#> # A tibble: 2 × 6
#>   expression          min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr>     <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 pdistR(0.5, y)   4.37ms   8.76ms      119.    7.63MB     63.2
#> 2 pdistC(0.5, y)   2.26ms    5.7ms      182.    7.63MB     87.5

Source your C++ code

Source stand-alone C++ files into R using sourceCpp()

C++ files have extension .cpp

#include <Rcpp.h>
using namespace Rcpp;

And for each function that you want available within R, you need to prefix it with:

// [[Rcpp::export]]

Inside a cpp file you can include R code using special comments

/*** R
rcode here
*/

Example

This block in Rmarkdown uses {Rcpp} as a short hand for engine = “Rcpp”.

#include <Rcpp.h>
using namespace Rcpp;

// [[Rcpp::export]]
double meanC(NumericVector x) {
  int n = x.size();
  double total = 0;

  for(int i = 0; i < n; ++i) {
    total += x[i];
  }
  return total / n;
}

/*** R
x <- runif(1e5)
bench::mark(
  mean(x),
  meanC(x)
)
*/

NOTE: For some reason although the r code above runs, knit doesn’t include the output. Why?

x <- runif(1e5)
bench::mark(
  mean(x),
  meanC(x)
)
#> # A tibble: 2 × 6
#>   expression      min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 mean(x)     122.8µs    234µs     4084.        0B     2.02
#> 2 meanC(x)     40.4µs    114µs     9270.        0B     0

Data frames, functions, and attributes

Lists and Dataframes

Contrived example to illustrate how to access a dataframe from c++:

#include <Rcpp.h>
using namespace Rcpp;

// [[Rcpp::export]]
double mpe(List mod) {
  if (!mod.inherits("lm")) stop("Input must be a linear model");

  NumericVector resid = as<NumericVector>(mod["residuals"]);
  NumericVector fitted = as<NumericVector>(mod["fitted.values"]);

  int n = resid.size();
  double err = 0;
  for(int i = 0; i < n; ++i) {
    err += resid[i] / (fitted[i] + resid[i]);
  }
  return err / n;
}
mod <- lm(mpg ~ wt, data = mtcars)
mpe(mod)
#> [1] -0.01541615
  • Note that you must cast the values to the required type. C++ needs to know the types in advance.

Functions

#include <Rcpp.h>
using namespace Rcpp;

// [[Rcpp::export]]
RObject callWithOne(Function f) {
  return f(1);
}
callWithOne(function(x) x + 1)
#> [1] 2
  • Other values can be accessed from c++ including

    • attributes (use: .attr(). Also .names() is alias for name attribute.
    • Environment, DottedPair, Language, Symbol , etc.

Missing values

Missing values behave differently for C++ scalers

  • Scalar NA’s in Cpp : NA_LOGICAL, NA_INTEGER, NA_REAL, NA_STRING.

  • Integers (int) stores R NA’s as the smallest integer. Better to use length 1 IntegerVector

  • Doubles use IEEE 754 NaN , which behaves a bit differently for logical expressions (but ok for math expressions).

evalCpp("NA_REAL || FALSE")
#> [1] TRUE
  • Strings are a class from Rcpp, so they handle missing values fine.

  • bool can only hold two values, so be careful. Consider using vectors of length 1 or coercing to int

Vectors

  • Vectors are all type introduced by RCpp and know how to handle missing values if you use the specific type for that vector.
#include <Rcpp.h>
using namespace Rcpp;

// [[Rcpp::export]]
List missing_sampler() {
  return List::create(
    NumericVector::create(NA_REAL),
    IntegerVector::create(NA_INTEGER),
    LogicalVector::create(NA_LOGICAL),
    CharacterVector::create(NA_STRING)
  );
}
str(missing_sampler())
#> List of 4
#>  $ : num NA
#>  $ : int NA
#>  $ : logi NA
#>  $ : chr NA

Standard Template Library

STL provides powerful data structures and algorithms for C++.

Iterators

Iterators are used extensively in the STL to abstract away details of underlying data structures.

If you an iterator it, you can:

  • Get the value by ‘dereferencing’ with *it
  • Advance to the next value with ++it
  • Compare iterators (locations) with ==

Algorithms

  • The real power of iterators comes from using them with STL algorithms.

  • A good reference is [https://en.cppreference.com/w/cpp/algorithm]

  • Book provides examples using accumulate and upper_buond

  • Another Example:


#include <algorithm>
#include <Rcpp.h>

using namespace Rcpp;
 
 
// Explicit iterator version
 
// [[Rcpp::export]]
NumericVector square_C_it(NumericVector x){
  NumericVector out(x.size());
  // Each container has its own iterator type
  NumericVector::iterator in_it;
  NumericVector::iterator out_it;
  
  for(in_it = x.begin(), out_it = out.begin(); in_it != x.end();  ++in_it, ++out_it) {
    *out_it = pow(*in_it,2);
  }
  
  return out;
  
}
 
 
// Use algorithm 'transform'
  
// [[Rcpp::export]]
NumericVector square_C(NumericVector x) {
 
  NumericVector out(x.size());
 
 
  std::transform(x.begin(),x.end(), out.begin(),
            [](double v) -> double { return v*v; });
  return out;
}
square_C(c(1.0,2.0,3.0))
#> [1] 1 4 9
square_C_it(c(1.0,2.0,3.0))
#> [1] 1 4 9

Data Structures

STL provides a large set of data structures. Some of the most important:

  • std::vector - like an R vector, except knows how to grow efficiently

  • std::unordered_set - unique set of values. Ordered version std::set. Unordered is more efficient.

  • std::map - Moslty similar to R lists, provide an association between a key and a value. There is also an unordered version.

A quick example illustrating the map:

#include <Rcpp.h>
using namespace Rcpp;

// [[Rcpp::export]]
std::map<double, int> tableC(NumericVector x) {
  // Note the types are <key, value>
  std::map<double, int> counts;

  int n = x.size();
  for (int i = 0; i < n; i++) {
    counts[x[i]]++;
  }

  return counts;
}
res = tableC(c(1,1,2,1,4,5))
res
#> 1 2 4 5 
#> 3 1 1 1
  • Note that the map is converted to a named vector in this case on return

To learn more about the STL data structures see containers at cppreference

Case Studies

Case Study

Real life uses of C++ to replace slow R code.

Case study 1: Gibbs sampler

The Gibbs sampler is a method for estimating parameters expectations. It is a MCMC algorithm that has been adapted to sample from multidimensional target distributions. Gibbs sampling generates a Markov chain of samples, each of which is correlated with nearby samples.

Example blogged by Dirk Eddelbuettel, the R and C++ code is very similar but runs about 20 times faster.

“Darren Wilkinson stresses the rather pragmatic aspects of how fast and/or easy it is to write the code, rather than just the mere runtime.

R code:

gibbs_r <- function(N, thin) {
  mat <- matrix(nrow = N, ncol = 2)
  x <- y <- 0

  for (i in 1:N) {
    for (j in 1:thin) {
      x <- rgamma(1, 3, y * y + 4)
      y <- rnorm(1, 1 / (x + 1), 1 / sqrt(2 * (x + 1)))
    }
    mat[i, ] <- c(x, y)
  }
  mat
}

Actions to convert R to C++:

  • Add type declarations to all variables
  • Use ( instead of [ to index into the matrix
  • Subscript the results of rgamma and rnorm to convert from a vector into a scalar.
#include <Rcpp.h>
using namespace Rcpp;

// [[Rcpp::export]]
NumericMatrix gibbs_cpp(int N, int thin) {
  NumericMatrix mat(N, 2);
  double x = 0, y = 0;

  for(int i = 0; i < N; i++) {
    for(int j = 0; j < thin; j++) {
      x = rgamma(1, 3, 1 / (y * y + 4))[0];
      y = rnorm(1, 1 / (x + 1), 1 / sqrt(2 * (x + 1)))[0];
    }
    mat(i, 0) = x;
    mat(i, 1) = y;
  }

  return(mat);
}

Checking who’s best:

bench::mark(
  gibbs_r(100, 10),
  gibbs_cpp(100, 10),
  check = FALSE
)
#> # A tibble: 2 × 6
#>   expression              min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr>         <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 gibbs_r(100, 10)     1.31ms   3.83ms      283.  103.22KB     18.0
#> 2 gibbs_cpp(100, 10)  150.1µs  416.3µs     2456.    1.61KB     16.9

Case study 2: predict a model response from three inputs

Rcpp is smoking fast for agent based models in data frames by Gary Weissman, MD, MSHP.

Starts with this code:

vacc1a <- function(age, female, ily) {
  p <- 0.25 + 0.3 * 1 / (1 - exp(0.04 * age)) + 0.1 * ily
  p <- p * if (female) 1.25 else 0.75
  p <- max(0, p)
  p <- min(1, p)
  p
}

R code with a for loop:

vacc1 <- function(age, female, ily) {
  n <- length(age)
  out <- numeric(n)
  for (i in seq_len(n)) {
    out[i] <- vacc1a(age[i], female[i], ily[i])
  }
  out
}

Vectorized R code:

vacc2 <- function(age, female, ily) {
  p <- 0.25 + 0.3 * 1 / (1 - exp(0.04 * age)) + 0.1 * ily
  p <- p * ifelse(female, 1.25, 0.75)
  p <- pmax(0, p)
  p <- pmin(1, p)
  p
}

C++:

#include <Rcpp.h>
using namespace Rcpp;

double vacc3a(double age, bool female, bool ily){
  double p = 0.25 + 0.3 * 1 / (1 - exp(0.04 * age)) + 0.1 * ily;
  p = p * (female ? 1.25 : 0.75);
  p = std::max(p, 0.0);
  p = std::min(p, 1.0);
  return p;
}

// [[Rcpp::export]]
NumericVector vacc3(NumericVector age, LogicalVector female, 
                    LogicalVector ily) {
  int n = age.size();
  NumericVector out(n);

  for(int i = 0; i < n; ++i) {
    out[i] = vacc3a(age[i], female[i], ily[i]);
  }

  return out;
}

Sample data:

n <- 1000
age <- rnorm(n, mean = 50, sd = 10)
female <- sample(c(T, F), n, rep = TRUE)
ily <- sample(c(T, F), n, prob = c(0.8, 0.2), rep = TRUE)

stopifnot(
  all.equal(vacc1(age, female, ily), vacc2(age, female, ily)),
  all.equal(vacc1(age, female, ily), vacc3(age, female, ily))
)
Who’s faster?
bench::mark(
  vacc1 = vacc1(age, female, ily),
  vacc2 = vacc2(age, female, ily),
  vacc3 = vacc3(age, female, ily)
)
#> # A tibble: 3 × 6
#>   expression      min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 vacc1       750.5µs   1.88ms      546.    7.86KB    28.8 
#> 2 vacc2        48.4µs   72.1µs    10005.  146.68KB    16.9 
#> 3 vacc3        30.8µs   41.5µs    15519.   11.98KB     4.06

Resources

Op Success!

Congrats!