预计阅读

R 语言与 C/C++ 语言混合编程:Rcpp 入门





R 语言与 C/C++ 语言的混合编程是非常常见的。有的 R 包依赖外部的 C/C++ 文件,比如 rstan 包,而它又依赖 Rcpp、 BH、 RcppEigen 和 StanHeaders 等 R 包。这些 R 包分别封装了一些 C++ 库的头文件。

本文是书籍《现代应用统计》中概率编程框架(介绍 Stan )的补充材料。

R 与 C 语言混编

R 软件是由 C 语言、Fortran 语言和 R 语言编写的。函数 .C() / .Call() 、函数 .Fortran() 是面向 C/C++ 和 Fortran 代码现代编程接口。下面以一个简单示例介绍 R 语言原生支持的混合编程方式。首先在文件 foo.c 中实现循环输出 Hello World,代码如下:

#include <R.h>

void hello(int *n){
  int i = 1;
  for(i = 0; i < *n; ++i){
    Rprintf("Hello World %d times\n", i);
  }
  *n += 1;
}

接着,使用命令 R CMD SHLIB 编译 C 源文件 foo.c,生成动态链接库 foo.so ,同时生成的还有目标文件 foo.o

R CMD SHLIB -o code/rcpp/foo.so code/rcpp/foo.c
## using C compiler: ‘Apple clang version 15.0.0 (clang-1500.3.9.4)’
## using SDK: ‘MacOSX14.4.sdk’
## make: Nothing to be done for `all'.

加载动态链接库到 R 语言环境中。

dyn.load(x = "code/rcpp/foo.so")

检查动态链接库是否加载到 R 语言环境中,返回 TRUE 表示已经加载。

is.loaded("hello")
## [1] TRUE

在 R 语言环境中使用加载的函数 hello() ,传递参数值为整数 3。

.C("hello", as.integer(3))
## Hello World 0 times
## Hello World 1 times
## Hello World 2 times
## [[1]]
## [1] 4

至此,在 R 语言中调用 C 语言来混编的过程介绍完了。

R 与 C++ 语言混编

Rcpp

Rcpp 包可以将 R 语言和 C++ 语言无缝地衔接在一起,编译、链接、加载一气呵成,提供一种新的混合编程模式。下面的示例是根据递归公式构造斐波那契数列。

\[ \begin{aligned} F_0 &= 0 \\ F_1 &= 1 \\ F_n &= F_{n-1} + F_{n-2}, \quad n \geq 2 \end{aligned} \]

主要是在 C++ 代码环境下写一个函数实现上述递归公式,代码如下:

#include <Rcpp.h>

// [[Rcpp::export]]
int fibonacci(const int x) {
  if (x < 2) return(x);
  return (fibonacci(x - 1)) + fibonacci(x - 2);
}

接着,编译 C++ 文件 rcpp_fibonacci.cpp,如果没有报错和警告,说明一切正常。没有消息就是好消息!

Rcpp::sourceCpp(file = "code/rcpp/rcpp_fibonacci.cpp")

编译成功后,R 语言运行环境中多出一个函数 fibonacci()

fibonacci
## function (x) 
## .Call(<pointer: 0x103f6ad00>, x)

此时,可以把函数 fibonacci() 看作 R 语言的普通函数,接着,我们可以计算第 10 个斐波那契数。

fibonacci(x = 10)
## [1] 55

BH

R 语言中有很多 R 包打包了一些 C++ 库文件,安装这些 R 包后,Rcpp 包就可以使用它们,比如 BH打包了 Boost C++ 头文件。下面提供一个示例,在 Rcpp 包提供的环境里,借由 BH 包的 C++ 头文件计算两个整数的最大公约数,其中 factor 表示因子,integer 表示整数。Boost 中的子程序库 integer 提供计算函数 gcd (Greatest Common Divisor),在此基础上,自定义了函数 computeGCD,C++ 代码如下:

// [[Rcpp::depends(BH)]]

#include <Rcpp.h>
#include <boost/integer/common_factor.hpp>
#include <boost/integer/common_factor_ct.hpp>
#include <boost/integer/common_factor_rt.hpp>

// [[Rcpp::export]]
int computeGCD(int a, int b) {
    return boost::integer::gcd(a, b);
}

编译 C++ 源码,如果没有报错、警告等信息,说明一切正常。

Rcpp::sourceCpp(file = "code/rcpp/bh_gcd.cpp")

编译成功后,运行环境中将出现一个函数 computeGCD() ,下面查看该函数内容。

computeGCD
## function (a, b) 
## .Call(<pointer: 0x103f7ed00>, a, b)

.Call 告诉我们这是一个这样的函数,可以将 R 语言环境中的对象(数据、变量、函数)传递给编译好的可执行的 C/C++ 程序,而且这个程序已经加载到 R 语言环境中了。下面调用该程序计算 12 和 15 的最大公约数。

computeGCD(a = 12, b = 15)
## [1] 3

Boost C++ 还有很多函数,比如 lcm(Least Common Multiple),下面计算两个整数的最小公倍数。

// [[Rcpp::depends(BH)]]

#include <Rcpp.h>
#include <boost/integer/common_factor.hpp>
#include <boost/integer/common_factor_ct.hpp>
#include <boost/integer/common_factor_rt.hpp>

// [[Rcpp::export]]
int computeLCM(int a, int b) {
    return boost::integer::lcm(a, b);
}

类似地,编译代码,然后计算 12 和 15 的最小公倍数。

# 编译代码
Rcpp::sourceCpp(file = "code/rcpp/bh_lcm.cpp")
# 计算 12 和 15 的最小公倍数
computeLCM(a = 12, b = 15)
## [1] 60

RcppEigen

RcppEigen 包是一个高性能的计算矩阵特征值的 R 包,封装了 Eigen C++ 库,使用方式和前面非常类似,只要知道 Eigen C++ 库封装的函数调用方式,就可以通过 Rcpp 包来编译。下面计算一个矩阵的特征值。

#include <RcppEigen.h>

// [[Rcpp::depends(RcppEigen)]]

using Eigen::Map;                       // 'maps' rather than copies
using Eigen::MatrixXd;                  // variable size matrix, double precision
using Eigen::VectorXd;                  // variable size vector, double precision
using Eigen::SelfAdjointEigenSolver;    // one of the eigenvalue solvers

// [[Rcpp::export]]
VectorXd getEigenValues(Map<MatrixXd> M) {
    SelfAdjointEigenSolver<MatrixXd> es(M);
    return es.eigenvalues();
}

编译代码,获得在 R 语言环境中可直接使用的函数 getEigenValues()

# 编译代码
Rcpp::sourceCpp(file = "code/rcpp/rcpp_eigen.cpp")

首先,构造一个矩阵 X

X <- toeplitz(x = c(1, 2, 3))
X
##      [,1] [,2] [,3]
## [1,]    1    2    3
## [2,]    2    1    2
## [3,]    3    2    1

然后,函数 getEigenValues() 计算特征值。

# 计算特征值
getEigenValues(X)
## [1] -2.0000 -0.7016  5.7016

作为对比,下面使用 R 语言内置的函数 eigen() 计算矩阵 X 的特征值。

eigen(X)
## eigen() decomposition
## $values
## [1]  5.7016 -0.7016 -2.0000
## 
## $vectors
##         [,1]    [,2]       [,3]
## [1,] -0.6059  0.3645  7.071e-01
## [2,] -0.5155 -0.8569 -5.551e-16
## [3,] -0.6059  0.3645 -7.071e-01

可以观察到,两种方式计算的结果是一样的。

StanHeaders

暴露用户定义的函数,目的是调试用户定义的函数,检查自己编写的 Stan 函数是否正确,还可以借此进一步优化代码,理解 Stan 内置的函数。有时候,为了熟悉 Stan 内置函数的作用,或者为了排查 Stan 代码中的错误都需要单独拎出来其中的函数。

Stan 框架的头文件都打包在 StanHeaders 包里。包含大量的内置函数,可以从如下三个地方了解。

  1. StanHeaders 包的帮助文档Using the Stan Math C++ Library,代码层面,有哪些函数及如何使用。

  2. Stan 官网的函数帮助文档,各个函数的定义,函数参数、返回值和数学表达式。只有文档,没有示例。适合根据函数名搜索。

  3. 只知需要什么,但不知函数是什么,采用谷歌搜索。

library(StanHeaders)

StanHeaders 就两个函数,一个导出的可直接用, stanFunction() 用来调用 Stan 框架中的 Math 库的函数。一个未导出的也可通过命名空间用 CxxFlags() ,编译 C++ 文件时带上一些编译的 Flags(开启一些选项)。

StanHeaders:::CxxFlags()
## -I'/Library/Frameworks/R.framework/Versions/4.3-x86_64/Resources/library/RcppParallel/include' -D_REENTRANT -DSTAN_THREADS

可以看到这是引用 RcppParallel 包的头文件,实际上, RStan 包还依赖 RcppParallel 包。

以了解函数 lkj_corr_cholesky_rng 为例,这是一个 LKJ 分布,Cholesky 分解相关矩阵 \(R\) 模拟 \(L_u\),调用 Stan Math 库中的 lkj_corr_cholesky_rng 函数,编译 C++ 代码,将加载至 R 环境中。

stanFunction("lkj_corr_cholesky_rng", K = 2L, eta = 2L)
##        [,1]   [,2]
## [1,] 1.0000 0.0000
## [2,] 0.3524 0.9359

K = 2L 表示 2 阶相关矩阵,eta = 2L 表示相关性的强弱。这个函数是用来生成随机数的,结果是一个二阶的下三角矩阵(矩阵对角线以上的元素都是 0),设置随机数种子,则可使结果复现。

lkj_corr_cholesky_rng(K = 2L, eta = 2L, random_seed = 20232023)
##         [,1]   [,2]
## [1,] 1.00000 0.0000
## [2,] 0.05875 0.9983

根据 \(R = L_u L_u^{\top}\),可以计算相关矩阵。

m <- lkj_corr_cholesky_rng(K = 2L, eta = 2L, random_seed = 20232023)
m %*% t(m)
##         [,1]    [,2]
## [1,] 1.00000 0.05875
## [2,] 0.05875 1.00000

运行环境

sessionInfo()
## R version 4.3.3 (2024-02-29)
## Platform: x86_64-apple-darwin20 (64-bit)
## Running under: macOS Sonoma 14.4.1
## 
## Matrix products: default
## BLAS:   /Library/Frameworks/R.framework/Versions/4.3-x86_64/Resources/lib/libRblas.0.dylib 
## LAPACK: /Library/Frameworks/R.framework/Versions/4.3-x86_64/Resources/lib/libRlapack.dylib;  LAPACK version 3.11.0
## 
## locale:
## [1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
## 
## time zone: Asia/Shanghai
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
## [1] StanHeaders_2.32.6
## 
## loaded via a namespace (and not attached):
##  [1] digest_0.6.35       R6_2.5.1            bookdown_0.38      
##  [4] fastmap_1.1.1       xfun_0.43           blogdown_1.19      
##  [7] RcppEigen_0.3.4.0.0 BH_1.84.0-0         cachem_1.0.8       
## [10] knitr_1.46          htmltools_0.5.8.1   RcppParallel_5.1.7 
## [13] rmarkdown_2.26      lifecycle_1.0.4     cli_3.6.2          
## [16] sass_0.4.9          withr_3.0.0         jquerylib_0.1.4    
## [19] compiler_4.3.3      rstudioapi_0.16.0   tools_4.3.3        
## [22] evaluate_0.23       bslib_0.7.0         Rcpp_1.0.12        
## [25] yaml_2.3.8          jsonlite_1.8.8      rlang_1.1.3