在R、Rcpp和Armadillo中,matrix rowSums()与colSums()的效率比较。

17

背景

我来自R编程,正在学习扩展到编译代码,使用Rcpp。作为一个关于循环交换效果(以及C / C ++)的实践练习,我使用Rcpp为矩阵实现了与R的rowSums()colSums()函数相当的等价物(我知道这些存在于Rcpp sugar和Armadillo中 - 这只是一个练习)。

问题

我有我的rowSums()colSums()的C ++实现,以及Rcpp sugararma :: 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;
}

(R矩阵是以列为主存储的,所以在外部循环中使用列会更高效。这就是我最初测试的内容。)

在对它们进行基准测试时,我遇到了一些意想不到的事情:行总和和列总和之间存在明显的性能差异(见下面的基准测试):

  1. 使用内置的R函数,colSums()rowSums()快大约两倍。
  2. 使用我的Rcpp和sugar版本,情况相反: rowSums()colSums()快大约两倍。
  3. 最后,添加Armadillo实现,操作大致相等(列总和可能会更快,因为我也希望在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

(再次提醒,您可以在此处找到C++源文件matsums.cpp这里。)

平台:

> 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  

更新

进一步调查后,我还使用了 R 传统的 C 接口编写了相同的函数:源代码在这里。我使用 R CMD SHLIB 将其编译成可执行文件,并进行了测试:行求和比列求和更快(基准测试)。然后,我还使用 objdump 查看了反汇编代码,但我认为(由于我极其有限的汇编语言知识),编译器没有从 C 代码中进一步优化主循环体()?
3个回答

17

首先,让我展示我的笔记本电脑的时序统计数据。我使用一个 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

R核心中的C代码并不总是比我们自己编写的更好。这表现在Cpp_rowSumsrowSums更快。我不觉得自己有能力解释为什么R的版本比应该更慢。我将专注于:如何进一步优化我们自己的colSumsrowSums以击败Armadillo。请注意,我编写C代码,使用R的旧C接口,并使用R CMD SHLIB进行编译。

colSumsrowSums之间是否存在实质性差异?

如果我们有一个比CPU缓存容量大得多的n x n矩阵,colSums会从RAM加载n x n数据到缓存中,但rowSums会加载两倍的数据,即2 x n x n

想一想保存总和的向量:这个长度为n的向量从RAM加载到缓存中的次数是多少?对于colSums,它只被加载一次,但对于rowSums,它被加载了n次。每次将矩阵列添加到其中时,它都会被加载到缓存中,但然后被驱逐,因为它太大了。

对于大的n:

  • colSums会从RAM加载n x n + n数据到缓存中;
  • rowSums会从RAM加载n x n + n x n数据到缓存中。

换句话说,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代码:让Armadillo垮掉

使用循环展开编写代码可能是一项糟糕的工作,因为现在我们需要为函数编写多个不同版本。

对于colSums,我们需要两个版本:

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

对于rowSums,我们最多可以有四个版本,rowSums_sxt,其中s = 1或2是内部循环的展开深度,t = 1或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)

该模板函数计算矩阵A(具有LDA行)的列和,表达式为sum <- colSums(A[1:nr, 1:nc])。参数s用于控制内部循环展开版本。该模板函数一开始看起来很糟糕,因为它包含了许多if语句。但是,它被声明为static inline。如果将已知常量1或2传递给s调用它,则优化编译器能够在编译时评估这些if,消除不可到达的代码并丢弃“设置但未使用”的变量(初始化、修改但未写回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)

请注意,模板函数现在接受s和t,并且由宏定义的函数应用了行分块。
尽管我在代码中留下了一些注释,但代码可能仍然不容易理解,但我无法花更多时间进行详细说明。
要使用它们,请将它们复制并粘贴到名为“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
  }

现在让我们进行一项基准测试,再次使用一个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))

在我的笔记本电脑上,我看到:

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

注意循环展开如何加速colSumsrowSums。并且在完全优化的情况下("col2"和"row4"),我们击败了Armadillo(请参见本答案开头的计时表)。
在这种情况下,行分块策略并没有明显的好处。让我们尝试一个有数百万行的矩阵。
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))

我理解

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

在这种情况下,我们观察到了缓存阻塞所带来的收益。

最后的想法

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

  • 为什么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关于使用matrixStats中的rowSums2colSums2

仍然使用上面的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

我认为rowSums2colSums2在性能上并没有优势。


我想为使用Windows 10上的R 4.x.x的用户(像我一样)留下评论。为了使上述代码可运行,我们必须设置环境变量(请参阅https://support.shotgunsoftware.com/hc/en-us/articles/114094235653-Setting-global-environment-variables-on-Windows)。将“C:\ rtools40 \ usr \ bin; C:\ rtools40 \ mingw64 \ x86_64-w64-mingw32; C:\ Program Files \ R \ R-4.0.2 \ bin \ x64”添加到系统变量的路径中。然后,打开命令窗口并转到您的文件夹。键入“R CMD SHLIB -c matSums.c”,然后输入。我们将在文件夹中得到“matSums.dll”文件。 - user7283235
接下来,在“matSums.R”中,将所有的“dyn.load("matSums.so")”更改为“dyn.load("matSums.dll")”。最后,执行“source("matSums.r")”。完成! - user7283235
1
非常好的写作。一个注意点是,在支持它的系统中(例如那些具有x87 FPUs的系统),R将使用长双精度累加器和相应的x87指令。所以这并不完全是一比一的比较。如果我理解正确,你的“row1”实际上没有展开,而这已经带来了大部分的改进。我已经单独确认,切换到双精度累加器可以获得类似的结果。可能CPU已经通过OOO内部展开了。我发现将4列展开为long double累加器对于rowSums来说是最佳选择。 - BrodieG

2
"为什么Cpp_rowSums()比Cpp_colSums()更快?" - 当获取“行主要”时,CPU的预取器可以预测您正在做什么,并在您需要之前从主存储器中获取下一批数据到CPU缓存中。这加速了您对数据的访问。
当您访问“列主要”时,预取器很难预测您接下来需要什么,因此它不会像效率那样将东西提前装入缓存内存(如果有的话)- 这会使您变慢。
CPU喜欢线性访问数据。如果您不按照它们所喜欢的方式做,就会付出缓存未命中和主存储器访问延迟的代价。

而且/或者你想要的下一个项目已经被缓存了,因为它与上一个项目在同一缓存行中。 - Tim Randall
1
矩阵是按列存储的;当获取行主元时,您所称呼的线性数据访问是什么? - F. Privé
2
谢谢!正如其他人所提到的,R矩阵(以及Armadillo)是按列主存储的,这就是为什么我在外部循环中对列和行求和都要通过列进行。那么数据应该尽可能地线性访问,是吗? - Mikko Marttila

1

有一个非常好的R包叫做Rfast(这里),它提供了行/列求和、均值等C++实现。我刚试用了一下,它比base包中对应的默认函数要快得多。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接