R vs Rcpp与Armadillo中矩阵rowSums()与colSums()的效率

Mik*_*ila 10 c++ performance r matrix rcpp

背景

来自R编程,我正在使用Rcpp以C/C++的形式扩展到已编译的代码.作为循环交换效果的练习(一般只是C/C++),我实现了R的等价rowSums()RcppcolSums()矩阵的函数(我知道它们存在于Rcpp糖和Armadillo中 - 这只是一个练习).

我有我的C++实现rowSums(),并colSums()沿RCPP糖arma::sum()版本在这个matsums.cpp文件中.我只是这样的简单循环:

NumericVector Cpp_colSums(const NumericMatrix& x) {
  int nr = x.nrow(), nc = x.ncol();
  NumericVector ans(nc);
  for (int j = 0; j < nc; j++) {
    double sum = 0.0;
    for (int i = 0; i < nr; i++) {
      sum += x(i, j);
    }
    ans[j] = sum;
  }
  return ans;
}

NumericVector Cpp_rowSums(const NumericMatrix& x) {
  int nr = x.nrow(), nc = x.ncol();
  NumericVector ans(nr);
  for (int j = 0; j < nc; j++) {
    for (int i = 0; i < nr; i++) {
      ans[i] += x(i, j);
    }
  }
  return ans;
}
Run Code Online (Sandbox Code Playgroud)

(R矩阵存储为列主要,因此外循环中的列应该是更有效的方法.这就是我最初测试的.)

在运行基准测试的同时,我遇到了一些我没想到的事情:行总和与总和之间存在明显的性能差异(参见下面的基准测试):

  1. 使用内置R函数,colSums()速度大约是其两倍rowSums().
  2. 使用我自己的Rcpp和糖版本,这是相反的:它的rowSums()速度大约是其两倍colSums().
  3. 最后,添加Armadillo实现,操作大致相等(col sum可能更快一些,因为我预计它们也会在R中).

所以我的主要问题是:为什么Cpp_rowSums()明显快于Cpp_colSums()

作为次要的兴趣,我也很好奇为什么R实现中的相同差异被颠倒了.(我浏览了C源代码,但无法确定显着差异.)(第三,Armadillo如何获得相同的性能?)

基准

我在10,000 x 10,000对称矩阵上测试了两个函数的所有4个实现:

Rcpp::sourceCpp("matsums.cpp")

set.seed(92136)
n <- 1e4 # build n x n test matrix
x <- matrix(rnorm(n), 1, n)
x <- crossprod(x, x) # symmetric

bench::mark(
  rowSums(x),
  colSums(x),
  Cpp_rowSums(x),
  Cpp_colSums(x),
  Sugar_rowSums(x),
  Sugar_colSums(x),
  Arma_rowSums(x),
  Arma_colSums(x)
)[, 1:7]

#> # A tibble: 8 x 7
#>   expression            min     mean   median      max `itr/sec` mem_alloc
#>   <chr>            <bch:tm> <bch:tm> <bch:tm> <bch:tm>     <dbl> <bch:byt>
#> 1 rowSums(x)        192.2ms  207.9ms  194.6ms  236.9ms      4.81    78.2KB
#> 2 colSums(x)         93.4ms   97.2ms   96.5ms  101.3ms     10.3     78.2KB
#> 3 Cpp_rowSums(x)     73.5ms   76.3ms     76ms   80.4ms     13.1     80.7KB
#> 4 Cpp_colSums(x)    126.5ms  127.6ms  126.8ms  130.3ms      7.84    80.7KB
#> 5 Sugar_rowSums(x)   73.9ms   75.6ms   74.3ms   79.4ms     13.2     80.7KB
#> 6 Sugar_colSums(x)  124.2ms  125.8ms  125.6ms  127.9ms      7.95    80.7KB
#> 7 Arma_rowSums(x)    73.2ms   74.7ms   73.9ms   79.3ms     13.4     80.7KB
#> 8 Arma_colSums(x)    62.8ms   64.4ms   63.7ms   69.6ms     15.5     80.7KB
Run Code Online (Sandbox Code Playgroud)

(同样,你可以在matsums.cpp 这里找到C++源文件.)

平台:

> sessioninfo::platform_info()
 setting  value                       
 version  R version 3.5.1 (2018-07-02)
 os       Windows >= 8 x64            
 system   x86_64, mingw32             
 ui       RStudio                     
 language (EN)                        
 collate  English_United States.1252  
 tz       Europe/Helsinki             
 date     2018-08-09  
Run Code Online (Sandbox Code Playgroud)

更新

进一步研究,我还使用R的传统C接口编写了相同的函数:源代码在这里.我编的功能R CMD SHLIB,并再次测试:行总和比山坳款项更快的同质化现象依然存在(基准).然后我又看了看与拆卸objdump,但在我看来,(我很有限ASM的理解),编译器并没有真正优化主循环体(,COLS从C代码)任何进一步的,要么?

李哲源*_*李哲源 11

首先,让我在笔记本电脑上显示时序统计信息.我使用足以进行基准测试的5000 x 5000矩阵,并且microbenchmark包用于100次评估.

Unit: milliseconds
             expr       min        lq      mean    median        uq       max
       colSums(x)  71.40671  71.64510  71.80394  71.72543  71.80773  75.07696
   Cpp_colSums(x)  71.29413  71.42409  71.65525  71.48933  71.56241  77.53056
 Sugar_colSums(x)  73.05281  73.19658  73.38979  73.25619  73.31406  76.93369
  Arma_colSums(x)  39.08791  39.34789  39.57979  39.43080  39.60657  41.70158
       rowSums(x) 177.33477 187.37805 187.57976 187.49469 187.73155 194.32120
   Cpp_rowSums(x)  54.00498  54.37984  54.70358  54.49165  54.73224  64.16104
 Sugar_rowSums(x)  54.17001  54.38420  54.73654  54.56275  54.75695  61.80466
  Arma_rowSums(x)  49.54407  49.77677  50.13739  49.90375  50.06791  58.29755
Run Code Online (Sandbox Code Playgroud)

R核心中的C代码并不总是比我们自己写的更好.这Cpp_rowSumsrowSums显示更快.我不觉得自己有能力解释为什么R的版本比应该的慢.我将重点关注:我们如何进一步优化自己colSumsrowSums击败犰狳.注意我写C,使用R的旧C接口并进行编译R CMD SHLIB.


有没有之间的任何实质性的区别colSumsrowSums

如果我们的n x n矩阵远大于CPU缓存的容量,则将数据从RAM colSums加载n x n到缓存,但rowSums加载的数量是两倍,即2 x n x n.

想想保存总和的结果向量:这个长度n向量从RAM加载到缓存中的次数是多少?因为colSums,它只加载一次,但是rowSums,它是加载n时间.每次向它添加一个矩阵列时,它都会被加载到缓存中,但由于它太大而被逐出.

对于大n:

  • colSums导致n x n + n数据从RAM加载到缓存;
  • rowSums导致n x n + n x n数据从RAM加载到缓存.

换句话说,rowSums理论上记忆效率较低,而且可能较慢.


如何提高性能colSums

由于RAM和缓存之间的数据流很容易达到最佳,因此唯一的改进是循环展开.将内循环(求和循环)展开2的深度就足够了,我们将看到2倍的提升.

循环展开工作,因为它启用CPU的指令管道.如果我们每次迭代只做一次加法,就不可能进行流水线操作; 通过两个附加功能,这种指令级并行性开始起作用.我们也可以将循环展开深度为4,但我的经验是深度2展开足以从循环展开中获得大部分好处.

如何提高性能rowSums

优化数据流是第一步.我们需要先进行缓存阻塞,以减少数据传输从2 x n x n下降到n x n.

将此n x n矩阵切换为多个行块:每个行2040 x n(最后一个块可能更小),然后rowSums按块应用普通块.对于每个块,累加器向量的长度为2040,大约是32KB CPU高速缓存可容纳的一半.对于添加到该累加器矢量的矩阵列,另一半是反转的.以这种方式,累加器向量可以保持在高速缓存中,直到处理该块中的所有矩阵列.结果,累加器向量仅被加载到高速缓存中一次,因此整体存储器性能与用于colSums.

现在我们可以进一步为rowSums每个块应用循环展开.展开外环和内环的深度为2,我们将看到一个提升.一旦外循环展开,块大小应该减少到1360,因为现在我们需要缓存中的空间来保持每个外循环迭代三个长度为1360的向量.


C代码:让我们击败犰狳

使用循环展开编写代码可能是一项令人讨厌的工作,因为我们现在需要为函数编写几个不同的版本.

因为colSums,我们需要两个版本:

  • colSums_1x1:内部循环和外部循环都展开深度为1,即,这是一个没有循环展开的版本;
  • colSums_2x1:没有外循环展开,而内循环展开深度2.

因为rowSums我们最多可以有四个版本,rowSums_sxt其中s = 1 or 2内循环t = 1 or 2的展开深度和外循环的展开深度.

如果我们逐个编写每个版本,代码编写可能非常繁琐.经过多年或对此的沮丧,我开发了一个使用内联模板函数和宏的"自动代码/版本生成"技巧.

#include <stdlib.h>
#include <Rinternals.h>

static inline void colSums_template_sx1 (size_t s,
                                         double *A, size_t LDA,
                                         size_t nr, size_t nc,
                                         double *sum) {

  size_t nrc = nr % s, i;
  double *A_end = A + LDA * nc, a0, a1;

  for (; A < A_end; A += LDA) {
    a0 = 0.0; a1 = 0.0;  // accumulator register variables
    if (nrc > 0) a0 = A[0];  // is there a "fractional loop"?
    for (i = nrc; i < nr; i += s) {  // main loop of depth-s
      a0 += A[i];  // 1st iteration
      if (s > 1) a1 += A[i + 1];  // 2nd iteration
      }
    if (s > 1) a0 += a1;  // combine two accumulators
    *sum++ = a0;  // write-back
    }

  }

#define macro_define_colSums(s, colSums_sx1) \
SEXP colSums_sx1 (SEXP matA) { \
  double *A = REAL(matA); \
  size_t nrow_A = (size_t)nrows(matA); \
  size_t ncol_A = (size_t)ncols(matA); \
  SEXP result = PROTECT(allocVector(REALSXP, ncols(matA))); \
  double *sum = REAL(result); \
  colSums_template_sx1(s, A, nrow_A, nrow_A, ncol_A, sum); \
  UNPROTECT(1); \
  return result; \
  }

macro_define_colSums(1, colSums_1x1)
macro_define_colSums(2, colSums_2x1)
Run Code Online (Sandbox Code Playgroud)

模板函数计算(在R-语法)sum <- colSums(A[1:nr, 1:nc])对矩阵ALDA(导致A的尺寸)的行.该参数s是内循环展开的版本控制.模板函数乍一看看起来很可怕,因为它包含很多if.但是,它被宣布static inline.如果通过传入已知常量1或2来调用它s,则优化编译器能够if在编译时评估它们,消除无法访问的代码并删除"set-but-not-used"变量(寄存器变量初始化,修改但没有写回RAM).

宏用于函数声明.接受常量s和函数名称,它会生成一个具有所需循环展开版本的函数.

以下是rowSums.

static inline void rowSums_template_sxt (size_t s, size_t t,
                                         double *A, size_t LDA,
                                         size_t nr, size_t nc,
                                         double *sum) {

  size_t ncr = nc % t, nrr = nr % s, i;
  double *A_end = A + LDA * nc, *B;
  double a0, a1;

  for (i = 0; i < nr; i++) sum[i] = 0.0;  // necessary initialization

  if (ncr > 0) {  // is there a "fractional loop" for the outer loop?
    if (nrr > 0) sum[0] += A[0];  // is there a "fractional loop" for the inner loop?
    for (i = nrr; i < nr; i += s) {  // main inner loop with depth-s
      sum[i] += A[i];
      if (s > 1) sum[i + 1] += A[i + 1];
      }
    A += LDA;
    }

  for (; A < A_end; A += t * LDA) {  // main outer loop with depth-t
    if (t > 1) B = A + LDA;
    if (nrr > 0) {  // is there a "fractional loop" for the inner loop?
      a0 = A[0]; if (t > 1) a0 += A[LDA];
      sum[0] += a0;
      }
    for(i = nrr; i < nr; i += s) {  // main inner loop with depth-s
      a0 = A[i]; if (t > 1) a0 += B[i];
      sum[i] += a0;
      if (s > 1) {
        a1 = A[i + 1]; if (t > 1) a1 += B[i + 1];
        sum[i + 1] += a1;
        }
      }
    }

  }

#define macro_define_rowSums(s, t, rowSums_sxt) \
SEXP rowSums_sxt (SEXP matA, SEXP chunk_size) { \
  double *A = REAL(matA); \
  size_t nrow_A = (size_t)nrows(matA); \
  size_t ncol_A = (size_t)ncols(matA); \
  SEXP result = PROTECT(allocVector(REALSXP, nrows(matA))); \
  double *sum = REAL(result); \
  size_t block_size = (size_t)asInteger(chunk_size); \
  size_t i, block_size_i; \
  if (block_size > nrow_A) block_size = nrow_A; \
  for (i = 0; i < nrow_A; i += block_size_i) { \
    block_size_i = nrow_A - i; if (block_size_i > block_size) block_size_i = block_size; \
    rowSums_template_sxt(s, t, A, nrow_A, block_size_i, ncol_A, sum); \
    A += block_size_i; sum += block_size_i; \
    } \
  UNPROTECT(1); \
  return result; \
  }

macro_define_rowSums(1, 1, rowSums_1x1)
macro_define_rowSums(1, 2, rowSums_1x2)
macro_define_rowSums(2, 1, rowSums_2x1)
macro_define_rowSums(2, 2, rowSums_2x2)
Run Code Online (Sandbox Code Playgroud)

请注意,模板函数现在接受st,并且宏定义的函数已应用行分块.

即使我在代码中留下了一些注释,但代码可能仍然不容易理解,但我不能花更多时间来详细解释.

要使用它们,请将它们复制并粘贴到名为"matSums.c"的C文件中并使用它进行编译R CMD SHLIB -c matSums.c.

对于R侧,在"matSums.R"中定义以下功能.

colSums_zheyuan <- function (A, s) {
  dyn.load("matSums.so")
  if (s == 1) result <- .Call("colSums_1x1", A)
  if (s == 2) result <- .Call("colSums_2x1", A)
  dyn.unload("matSums.so")
  result
  }

rowSums_zheyuan <- function (A, chunk.size, s, t) {
  dyn.load("matSums.so")
  if (s == 1 && t == 1) result <- .Call("rowSums_1x1", A, as.integer(chunk.size))
  if (s == 2 && t == 1) result <- .Call("rowSums_2x1", A, as.integer(chunk.size))
  if (s == 1 && t == 2) result <- .Call("rowSums_1x2", A, as.integer(chunk.size))
  if (s == 2 && t == 2) result <- .Call("rowSums_2x2", A, as.integer(chunk.size))
  dyn.unload("matSums.so")
  result
  }
Run Code Online (Sandbox Code Playgroud)

现在让我们有一个基准,再次使用5000 x 5000矩阵.

A <- matrix(0, 5000, 5000)

library(microbenchmark)
source("matSums.R")

microbenchmark("col0" = colSums(A),
               "col1" = colSums_zheyuan(A, 1),
               "col2" = colSums_zheyuan(A, 2),
               "row0" = rowSums(A),
               "row1" = rowSums_zheyuan(A, nrow(A), 1, 1),
               "row2" = rowSums_zheyuan(A, 2040, 1, 1),
               "row3" = rowSums_zheyuan(A, 1360, 1, 2),
               "row4" = rowSums_zheyuan(A, 1360, 2, 2))
Run Code Online (Sandbox Code Playgroud)

我的笔记本电脑上有:

Unit: milliseconds
 expr       min        lq      mean    median        uq       max neval
 col0  65.33908  71.67229  71.87273  71.80829  71.89444 111.84177   100
 col1  67.16655  71.84840  72.01871  71.94065  72.05975  77.84291   100
 col2  35.05374  38.98260  39.33618  39.09121  39.17615  53.52847   100
 row0 159.48096 187.44225 185.53748 187.53091 187.67592 202.84827   100
 row1  49.65853  54.78769  54.78313  54.92278  55.08600  60.27789   100
 row2  49.42403  54.56469  55.00518  54.74746  55.06866  60.31065   100
 row3  37.43314  41.57365  41.58784  41.68814  41.81774  47.12690   100
 row4  34.73295  37.20092  38.51019  37.30809  37.44097  99.28327   100
Run Code Online (Sandbox Code Playgroud)

请注意循环展开如何加速colSumsrowSums.通过全面优化("col2"和"row4"),我们击败了犰狳(请参阅本答案开头的时间表).

在这种情况下,行分块策略并没有明显产生效益.让我们尝试一个包含数百万行的矩阵.

A <- matrix(0, 1e+7, 20)
microbenchmark("row1" = rowSums_zheyuan(A, nrow(A), 1, 1),
               "row2" = rowSums_zheyuan(A, 2040, 1, 1),
               "row3" = rowSums_zheyuan(A, 1360, 1, 2),
               "row4" = rowSums_zheyuan(A, 1360, 2, 2))
Run Code Online (Sandbox Code Playgroud)

我明白了

Unit: milliseconds
 expr      min       lq     mean   median       uq      max neval
 row1 604.7202 607.0256 617.1687 607.8580 609.1728 720.1790   100
 row2 514.7488 515.9874 528.9795 516.5193 521.4870 636.0051   100
 row3 412.1884 413.8688 421.0790 414.8640 419.0537 525.7852   100
 row4 377.7918 379.1052 390.4230 379.9344 386.4379 476.9614   100
Run Code Online (Sandbox Code Playgroud)

在这种情况下,我们观察缓存阻塞的收益.


最后的想法

基本上,这个答案已经解决了所有问题,除了以下内容:

  • 为什么R的rowSums效率低于应有的效率.
  • 为什么没有任何优化,rowSums("row1")比colSums("col1")快.

同样,我无法解释第一个,实际上我并不关心,因为我们可以轻松编写比R的内置版本更快的版本.

第二个绝对值得追求.我在我们的讨论室中复制我的评论以备记录.

这个问题归结为:"为什么添加单个向量比逐个添加两个向量慢?"

我不时看到类似的现象.我第一次遇到这种奇怪的行为是几年前我编码自己的矩阵 - 矩阵乘法.我发现DAXPY比DDOT快.

DAXPY这样做:y += a * x,在哪里xy是向量并且a是标量; DDOT这样做:a += x * y.

鉴于DDOT是减速操作,我希望它比DAXPY快.但不,DAXPY更快.

但是,只要我在矩阵乘法的三重循环嵌套中展开循环,DDOT就比DAXPY快得多.

你的问题恰好发生了类似的事情.减少操作:a = x[1] + x[2] + ... + x[n]比元素添加慢:y[i] += x[i].但是一旦完成循环展开,后者的优势就会丧失.

我不确定以下解释是否属实,因为我没有证据.

约简操作具有依赖链,因此计算严格连续; 另一方面,元素操作没有依赖链,因此CPU可以用它做得更好.

一旦我们展开循环,每次迭代都会有更多的算术,CPU可以在两种情况下进行流水线操作.然后可以观察到还原操作的真正优点.


回复Jaap使用rowSums2colSums2来自matrixStats

仍然使用上面的5000 x 5000示例.

A <- matrix(0, 5000, 5000)

library(microbenchmark)
source("matSums.R")
library(matrixStats)  ## NEW

microbenchmark("col0" = base::colSums(A),
               "col*" = matrixStats::colSums2(A),  ## NEW
               "col1" = colSums_zheyuan(A, 1),
               "col2" = colSums_zheyuan(A, 2),
               "row0" = base::rowSums(A),
               "row*" = matrixStats::rowSums2(A),  ## NEW
               "row1" = rowSums_zheyuan(A, nrow(A), 1, 1),
               "row2" = rowSums_zheyuan(A, 2040, 1, 1),
               "row3" = rowSums_zheyuan(A, 1360, 1, 2),
               "row4" = rowSums_zheyuan(A, 1360, 2, 2))

Unit: milliseconds
 expr       min        lq      mean    median        uq       max neval
 col0  71.53841  71.72628  72.13527  71.81793  71.90575  78.39645   100
 col*  75.60527  75.87255  76.30752  75.98990  76.18090  87.07599   100
 col1  71.67098  71.86180  72.06846  71.93872  72.03739  77.87816   100
 col2  38.88565  39.03980  39.57232  39.08045  39.16790  51.39561   100
 row0 187.44744 187.58121 188.98930 187.67168 187.86314 206.37662   100
 row* 158.08639 158.26528 159.01561 158.34864 158.62187 174.05457   100
 row1  54.62389  54.81724  54.97211  54.92394  55.04690  56.33462   100
 row2  54.15409  54.44208  54.78769  54.59162  54.76073  60.92176   100
 row3  41.43393  41.63886  42.57511  41.73538  41.81844 111.94846   100
 row4  37.07175  37.25258  37.45033  37.34456  37.47387  43.14157   100
Run Code Online (Sandbox Code Playgroud)

我不明白的性能优势rowSums2colSums2.