使用 Rcpp
正如我们所提到的那样,并行计算只有在每次迭代都是独立的情况下才可行,这样最
终结果才不会依赖运行顺序。然而,并非所有任务都像这样理想。因此,并行计算可能会
受到影响。那么怎样才能使算法快速运行,并且可以轻松地与 R 实现交互呢?答案是通
过 Rcpp 用 C++ 语言编写算法(http://www.rcpp.org/)。
C++ 代码的运行速度通常很快,这是因为它被编译为本地指令,比 R 这样的脚本语言
更接近于硬件级别。Rcpp 是一个扩展包,它使我们能够利用 R 和 C++ 的无缝整合来编写
C++代码。使用 Rcpp 可以编写 C++ 代码,并且代码中还可以调用 R 函数,利用 R 数据结
构的优势。Rcpp 使我们能够编写高性能代码,同时保留 R 强大的数据操作功能。
使用 Rcpp,先要使用正确的工具链,以确保系统可以计算本地代码。在 Windows 操
作系统下使用 Rtools,可以在 https://cran.r-project.org/bin/windows/Rtools/下载。在 Linux
和 MacOS 操作系统下,也需要正确安装的 C/C++ 工具链。
正确安装好工具链之后,我们便可以运行如下代码来安装扩展包:
install.packages("Rcpp")
然后,通过以下代码在路径 code/rcpp-demo.cpp 中创建一个 C++ 源文件:
#include <Rcpp.h>
usingnamespace Rcpp;
// [[Rcpp::export]]
NumericVector timesTwo(NumericVector x) {
return x * 2;
}
这段代码是用 C++ 语言写的,如果你不熟悉 C++ 的语法,可以快速学习一些最简单的
部分,详细内容请阅读 http://www.learncpp.com/。C++ 语言设计和支持的功能比 R 丰富且
复杂得多。不要指望在短时间内就能成为专家,但是入门之后通常就能够编写一些简单的
算法了。
通过阅读前面的代码,我们会发现它和典型的 R 代码差别很大,因为 C++ 是强类型语
言,需要指定函数参数的类型和函数返回的类型。使用 [[Rcpp::export]] 注释的函数
会被 Rcpp 捕获,当在 RStudio 中执行一个脚本文件,或者直接使用 Rcpp::sourceCpp,
这些 C++ 函数将被自动编译并移植到 R 的工作环境中。
前面的 C++ 函数接收一个数值向量,然后返回一个所有元素乘以 2 的新的数值向量。注意
到 NumericVector 类是由包含在源文件开头部分的 Rcpp.h 提供的。事实上,Rcpp.h 提供了所
有常用的 R 数据结构的 C++ 代理。现在,调用 Rcpp::sourceCpp( ) 编译并加载源文件:
Rcpp::sourceCpp("code/rcpp - demo.cpp")
该函数对源代码进行编译,并将其链接到必要的共享库中,将 R 函数显示在环境里。
其便捷之处在于,所有工作都是自动完成的,这让非专业的 C++ 开发人员编写算法更加容
易。现在,我们就有一个可以调用的 R 函数了:
timesTwo
## function (x)
## .Primitive(".Call")(<pointer: 0x7f81735528c0>, x)
可以看到,在 R 中 timeTwo 不像一个普通的函数,而是对 C++ 函数进行了一个本地
调用。此函数对单个数值输入也是可行的:
timesTwo(10)
## [1] 20
同样适用于多元素的数值向量:
timesTwo(c(1, 2, 3))
## [1] 2 4 6
现在,我们使用很简单的 C++语言结构来重新实现 algo1_for 算法。先创建一个 C++
源文件 code/rcpp-algo1.cpp 并输入以下代码:
#include <Rcpp.h>
using namespace Rcpp;
// [[Rcpp::export]]
double algo1_cpp(int n) {
double res = 0;
for (double i = 1; i < n; i++) {
res += 1 / (i * i);
}
return res;
}
需要注意到的是,algo1_cpp 中并没有使用 R 而是 C++ 的数据结构。当获取代码时,
Rcpp 会处理好所有的移植工作:
Rcpp::sourceCpp("code/rcpp-algo1.cpp")
输入单个数值时,函数可以运行:
algo1_ _cpp(10)
## [1] 1.539768
但是,输入一个数值向量,就会发生报错:
algo1_ _cpp(c(10, 15))
## Error in eval(expr, envir, enclos): expecting a single value
现在,我们可以再做一次测试。这一次,我们把 algo1_cpp 加入对比的行列中。下
面比较 R 中的 for 循环版本、for 循环的字节码编译版本、向量化版本以及 C++ 版本:
n <- 1000
microbenchmark(
algo1_ _for(n),
algo1_ _cmp(n),
algo1_ _vec(n),
algo1_ _cpp(n))
## Unit: microseconds
## expr min lq mean median uq
## algo1_for(n) 493.312 507.7220 533.41701 513.8250 531.5470
## algo1_cmp(n) 57.262 59.1375 61.44986 60.0160 61.1190
## algo1_vec(n) 10.091 10.8340 11.60346 11.3045 11.7735
## algo1_cpp(n) 5.493 6.0765 7.13512 6.6210 7.2775
## max neval cld
## 789.799 100 c
## 105.260 100 b
## 23.007 100 a
## 22.131 100 a
令人惊讶的是,C++版本甚至比向量化版本还要快。尽管向量化版本使用的是原函数,
而且已经非常快了,但是在方法分派和参数检查上仍然需要花费一些时间。而 C++ 版本是
专门为这个任务编写的,所以它会比向量化版本稍快一些。
还有一个例子是 diff_for( ) 的 C++ 实现,代码如下:
#include <Rcpp.h>
usingnamespace Rcpp;
// [[Rcpp::export]]
NumericVector diff_cpp(NumericVector x) {
NumericVector res(x.size( ) - 1);
for (int i = 0; i < x.size( ) - 1; i++) {
res[i] = x[i + 1] - x[i];
}
return res;
}
在这段 C++ 代码中,diff_cpp( ) 接收一个数值向量并返回一个数值向量。该函数
创建一个新向量,以迭代的方式计算并储存 x 中连续两个元素的差。我们执行代码文件:
Rcpp::sourceCpp("code/rcpp - diff.cpp")
很容易验证函数能否正确运行:
diff_ _cpp(c(1, 2, 3, 5))
## [1] 1 1 2
接下来,我们再测试 5 个版本函数的性能,分别是 R 中 for 循环的版本(diff_for)、
字节码编译版本(diff_cmp)、向量化版本(diff)、无需进行方法调度的向量化版本
(diff.default),以及 C++ 版本(diff_cpp):
x <- rnorm(1000)
microbenchmark(
diff_ _for(x),
diff_ _cmp(x),
diff(x),
diff.default(x),
diff_ _cpp(x))
## Unit: microseconds
## expr min lq mean median
## diff_for(x) 1055.177 1113.8875 1297.82994 1282.9675
## diff_cmp(x) 75.511 78.4210 88.46485 88.2135
## diff(x) 12.899 14.9340 20.64854 18.3975
## diff.default(x) 10.750 11.6865 13.90939 12.6400
## diff_cpp(x) 5.314 6.4260 8.62119 7.5330
## uq max neval cld
## 1400.8250 2930.690 100 c
## 90.3485 179.620 100 b
## 24.2335 65.172 100 a
## 15.3810 25.455 100 a
## 8.9570 54.455 100 a
结果显示 C++版本是最快的。
近年来,使用 Rcpp 提高性能或是直接链接到比较流行的高性能算法的库上的 R 包越
来越多。例如,RcppArmadillo 和 RcppEigen 提供了高性能的线性代数算法,RcppDE 通过
在 C++中进行差分计算演变出一种全局最优化的快速实现算法等。
如果想了解关于 Rcpp 以及相关扩展包的更多内容,可以访问官方网站:http://www.
rcpp.org/ 。同时向 大 家 推 荐 Rcpp 的 作 者 Dirk Eddelbuettel 写 的 Seamless R and C++
Integration with Rcpp 这本书,网址是:http://www.rcpp.org/book/。
1.OpenMP
我们在并行计算一节中有提到过,R 会话是单线程运行的。在 Rcpp 代码中,可以使用
多线程来提高性能。其中一个多线程技术是 OpenMP(http://openmp.org),它是由大多数现
代 C++ 编译器支持的(参见 http://openmp.org/wp/openmp-compilers/)。
在这个网站(http://gallery.rcpp.org/tags/openmp/)上,一些文章讨论并演示了 OpenMP 配
合 Rcpp 的用法。这里,我们举一个简单的例子。首先创建一个 C++ 源文件:code/rcpp-
diff-openmp.cpp,并输入以下代码:
// [[Rcpp::plugins(openmp)]]
#include <omp.h>
#include <Rcpp.h>
usingnamespace Rcpp;
// [[Rcpp::export]]
NumericVector diff_cpp_omp(NumericVector x) {
omp_set_num_threads(3);
NumericVector res(x.size( ) - 1);
#pragma omp parallel for
for (int i = 0; i < x.size( ) - 1; i++) {
res[i] = x[i + 1] - x[i];
}
return res;
}
Rcpp 会识别第一行的注释,并为编译器添加必要选项,以便 OpenMP 可被启用。使用
OpenMP,需要添加 omp.h。然后,通过调用 omp_set_num_threads(n)设置线程数,
使用 #pragma omp parallel for 来指明后面的 for 循环是并行的。如果将线程数设置
为 1,代码也能够正常运行。
我们先获取 C++代码文件:
Rcpp::sourceCpp("code/rcpp – diff - openmp.cpp")
看一下函数能否正确运行:
diff_ _cpp_ _omp(c(1, 2, 4, 8))
## [1] 1 2 4
接着,用 1000 个数的输入向量来测试函数性能:
x <- rnorm(1000)
microbenchmark(
diff_ _for(x),
diff_ _cmp(x),
diff(x),
diff.default(x),
diff_ _cpp(x),
diff_ _cpp_ _omp(x))
## Unit: microseconds
## expr min lq mean median
## diff_for(x) 1010.367 1097.9015 1275.67358 1236.7620
## diff_cmp(x) 75.729 78.6645 88.20651 88.9505
## diff(x) 12.615 16.4200 21.13281 20.5400
## diff.default(x) 10.555 12.1690 16.07964 14.8210
## diff_cpp(x) 5.640 6.4825 8.24118 7.5400
## diff_cpp_omp(x) 3.505 4.4390 26.76233 5.6625
## uq max neval cld
## 1393.5430 2839.485 100 c
## 94.3970 186.660 100 b
## 24.4260 43.893 100 a
## 18.4635 72.940 100 a
## 8.6365 50.533 100 a
## 13.9585 1430.605 100 a
遗憾的是,即使使用了多线程,diff_cpp_omp( ) 还是比其单线程的 C++ 实现要慢。
这是因为使用多线程会耗费一些时间。如果输入量很小,初始化多线程的时间可能会占整
个计算时间的很大一部分。然而,如果输入量足够大,多线程的优势就能够得到充分体现。
这里,我们使用 100000 个数的输入向量:
x <- rnorm(100000)
microbenchmark(
diff_ _for(x),
diff_ _cmp(x),
diff(x),
diff.default(x),
diff_ _cpp(x),
diff_ _cpp_ _omp(x))
## Unit: microseconds
## expr min lq mean
## diff_for(x) 112216.936 114617.4975 121631.8135
## diff_cmp(x) 7355.241 7440.7105 8800.0184
## diff(x) 863.672 897.2060 1595.9434
## diff.default(x) 844.186 877.4030 3451.6377
## diff_cpp(x) 418.207 429.3125 560.3064
## diff_cpp_omp(x) 125.572 149.9855 237.5871
## median uq max neval cld
## 115284.377 116165.3140 214787.857 100 c
## 7537.405 8439.9260 102712.582 100 b
## 1029.642 2195.5620 8020.990 100 a
## 931.306 2365.6920 99832.513 100 a
## 436.638 552.5110 2165.091 100 a
## 166.834 190.7765 1983.299 100 a
相对于对性能的提升来说,创建多线程的时间成本非常小。因此,由 OpenMP 驱动的
版本比简单的 C++ 版本还要快。
事实上,OpenMP 的功能比我们演示的还要多很多。想了解更多信息,请阅读官方文档。
若想查看更多的例子,推荐阅读 Joel Yliluoma 写的 Guide into OpenMP: Easy multithreading
programming for C++一书,网址是 http://bisqwit.iki.fi/story/howto/openmp/。
2.RcppParallel
RcppParallel 是另一种结合 Rcpp (http://rcppcore.github.io/RcppParallel/)利用了多线程
的方法,
这个扩展包涵盖了 Intel TBB
(https://www.threadingbuildingblocks.org/)和 TinyThread
(http://tinythreadpp.bitsnbites.eu/)。RcppParallel 提供了线程安全向量和矩阵封装的数
据
结构以及高级并行函数。
使用 RcppParallel 执行多线程并行计算,需要实现一个 Worker 将一个输入转换为输出。
然后,RcppParallel 负责剩下的工作,如多线程任务分配。
下面是一个简单的演示。我们创建一个包含如下代码的C++源文件code/rcpp- parallel.cpp。
注意,需要向Rcpp 声明,该文件依赖于RcppParallel,并使用C++11 来调用lambda 函数。
接下来,实现一个名为 Transformer 的 Worker,它将矩阵的每一个元素 x 转换为
1/(1 + x*×2)。然后,在 par_transform 中,创建一个 Transformer 的对象实例
并调用 parallelFor,使其自动利用多线程计算:
// [[Rcpp::plugins(cpp11)]]
// [[Rcpp::depends(RcppParallel)]]
#include <Rcpp.h>
#include <RcppParallel.h>
using namespace Rcpp;
using namespace RcppParallel;
struct Transformer : public Worker {
const RMatrix<double> input;
RMatrix<double> output;
Transformer(const NumericMatrix input, NumericMatrix output)
: input(input), output(output) {}
void operator( ) (std::size_t begin, std::size_t end) {
std::transform(input.begin( ) + begin, input.begin( ) + end,
output.begin( ) + begin, [](double x) {
return 1 / (1 + x * x);
});
}
};
// [[Rcpp::export]]
NumericMatrix par_transform (NumericMatrix x) {
NumericMatrix output(x.nrow( ) , x.ncol( ) );
Transformer transformer(x, output);
parallelFor(0, x.length( ) , transformer);
return output;
}
很容易验证该函数在输入一个小矩阵的时候可以正确运行:
mat <- matrix(1:12, nrow = 3)
mat
## [,1] [,2] [,3] [,4]
## [1,] 1 4 7 10
## [2,] 2 5 8 11
## [3,] 3 6 9 12
par_ _transform(mat)
## [,1] [,2] [,3] [,4]
## [1,] 0.5 0.05882353 0.02000000 0.009900990
## [2,] 0.2 0.03846154 0.01538462 0.008196721
## [3,] 0.1 0.02702703 0.01219512 0.006896552
all.equal(par_ _transform(mat), 1 /(1 + mat ^ 2))
## [1] TRUE
它和向量化的 R 表达式返回完全相同的结果。现在,看一下当输入矩阵很大时,该函
数的性能:
mat <- matrix(rnorm(1000 * 2000), nrow = 1000)
microbenchmark(1 /(1 + mat ^ 2), par_ _transform(mat))
## Unit: milliseconds
## expr min lq mean median
## 1/(1 + mat ^ 2) 14.50142 15.588700 19.78580 15.768088
## par_transform(mat) 7.73545 8.654449 13.88619 9.277798
## uq max neval cld
## 18.79235 127.1912 100 b
## 11.65137 110.6236 100 a
结果显示多线程版本的函数要比向量化版本的快了几乎一倍。
RcppParallel 的功能远比我们演示的要强大得多。若想了解更多介绍和实例,可以访
问 http://rcppcore.github.io/RcppParallel。