为什么用Rcpp较慢地优化R目标函数,为什么呢?


16

我目前正在研究一种贝叶斯方法,该方法每次迭代都需要多项步骤来优化多项式logit模型。我正在使用optim()进行这些优化,并用R语言编写了一个目标函数。分析显示optim()是主要瓶颈。

深入研究后,我发现了这个问题,他们建议重新编码目标函数Rcpp可以加快处理过程。我遵循了该建议,并使用编码了我的目标函数Rcpp,但结果变慢了(大约慢了两倍!)。

这是我的第一次Rcpp(或与C ++有关的任何事情),但是我找不到找到矢量化代码的方法。任何想法如何使其更快?

Tl; dr:Rcpp中函数的当前实现不如矢量化R快;如何使其更快?

一个可重现的示例

1)在R和中定义目标函数Rcpp:仅截取多项式模型的对数似然

library(Rcpp)
library(microbenchmark)

llmnl_int <- function(beta, Obs, n_cat) {
  n_Obs     <- length(Obs)
  Xint      <- matrix(c(0, beta), byrow = T, ncol = n_cat, nrow = n_Obs)
  ind       <- cbind(c(1:n_Obs), Obs)
  Xby       <- Xint[ind]
  Xint      <- exp(Xint)
  iota      <- c(rep(1, (n_cat)))
  denom     <- log(Xint %*% iota)
  return(sum(Xby - denom))
}

cppFunction('double llmnl_int_C(NumericVector beta, NumericVector Obs, int n_cat) {

    int n_Obs = Obs.size();

    NumericVector betas = (beta.size()+1);
    for (int i = 1; i < n_cat; i++) {
        betas[i] = beta[i-1];
    };

    NumericVector Xby = (n_Obs);
    NumericMatrix Xint(n_Obs, n_cat);
    NumericVector denom = (n_Obs);
    for (int i = 0; i < Xby.size(); i++) {
        Xint(i,_) = betas;
        Xby[i] = Xint(i,Obs[i]-1.0);
        Xint(i,_) = exp(Xint(i,_));
        denom[i] = log(sum(Xint(i,_)));
    };

    return sum(Xby - denom);
}')

2)比较它们的效率:

## Draw sample from a multinomial distribution
set.seed(2020)
mnl_sample <- t(rmultinom(n = 1000,size = 1,prob = c(0.3, 0.4, 0.2, 0.1)))
mnl_sample <- apply(mnl_sample,1,function(r) which(r == 1))

## Benchmarking
microbenchmark("llmml_int" = llmnl_int(beta = c(4,2,1), Obs = mnl_sample, n_cat = 4),
               "llmml_int_C" = llmnl_int_C(beta = c(4,2,1), Obs = mnl_sample, n_cat = 4),
               times = 100)
## Results
# Unit: microseconds
#         expr     min       lq     mean   median       uq     max neval
#    llmnl_int  76.809  78.6615  81.9677  79.7485  82.8495 124.295   100
#  llmnl_int_C 155.405 157.7790 161.7677 159.2200 161.5805 201.655   100

3)现在打电话给他们optim

## Benchmarking with optim
microbenchmark("llmnl_int" = optim(c(4,2,1), llmnl_int, Obs = mnl_sample, n_cat = 4, method = "BFGS", hessian = T, control = list(fnscale = -1)),
               "llmnl_int_C" = optim(c(4,2,1), llmnl_int_C, Obs = mnl_sample, n_cat = 4, method = "BFGS", hessian = T, control = list(fnscale = -1)),
               times = 100)
## Results
# Unit: milliseconds
#         expr      min       lq     mean   median       uq      max neval
#    llmnl_int 12.49163 13.26338 15.74517 14.12413 18.35461 26.58235   100
#  llmnl_int_C 25.57419 25.97413 28.05984 26.34231 30.44012 37.13442   100

我有点惊讶R中的向量化实现更快。在Rcpp中实现更有效的版本(例如,使用RcppArmadillo?)可以带来任何收益吗?使用C ++优化器在Rcpp中重新编码所有内容是更好的主意吗?

PS:第一次在Stackoverflow发布!

Answers:


9

通常,如果您能够使用向量化函数,则将发现它(几乎)与直接在Rcpp中运行代码一样快。这是因为R中的许多矢量化函数(Base R中几乎所有矢量化函数)都是用C,Cpp或Fortran编写的,因此通常很少获得收益。

也就是说,您的代码RRcpp代码都有改进。优化来自仔细研究代码,并删除了不必要的步骤(内存分配,总和等)。

让我们从Rcpp代码优化开始。

在您的情况下,主要的优化是删除不必要的矩阵和向量计算。代码本质上

  1. Shift Beta
  2. 计算exp(shift beta)的总和的对数[log-sum-exp]
  3. 使用Obs作为移动后的beta的索引,并对所有概率求和
  4. 减去log-sum-exp

使用此观察,我们可以将您的代码减少到2个for循环。请注意,这sum仅仅是另一个for循环(或多或少for(i = 0; i < max; i++){ sum += x }:),因此避免总和可以进一步加快代码的速度(在大多数情况下,这是不必要的优化!)。另外,您的输入Obs是一个整数向量,我们可以通过使用IntegerVector类型来进一步优化代码,以避免将double元素强制转换为integer值(信贷给Ralf Stubner的答案)。

cppFunction('double llmnl_int_C_v2(NumericVector beta, IntegerVector Obs, int n_cat)
 {

    int n_Obs = Obs.size();

    NumericVector betas = (beta.size()+1);
    //1: shift beta
    for (int i = 1; i < n_cat; i++) {
        betas[i] = beta[i-1];
    };
    //2: Calculate log sum only once:
    double expBetas_log_sum = log(sum(exp(betas)));
    // pre allocate sum
    double ll_sum = 0;

    //3: Use n_Obs, to avoid calling Xby.size() every time 
    for (int i = 0; i < n_Obs; i++) {
        ll_sum += betas(Obs[i] - 1.0) ;
    };
    //4: Use that we know denom is the same for all I:
    ll_sum = ll_sum - expBetas_log_sum * n_Obs;
    return ll_sum;
}')

请注意,我删除了很多内存分配,并删除了for循环中不必要的计算。我也denom对所有迭代都使用了相同的结果,并乘以最终结果。

我们可以在您的R代码中执行类似的优化,从而产生以下功能:

llmnl_int_R_v2 <- function(beta, Obs, n_cat) {
    n_Obs <- length(Obs)
    betas <- c(0, beta)
    #note: denom = log(sum(exp(betas)))
    sum(betas[Obs]) - log(sum(exp(betas))) * n_Obs
}

请注意,该功能的复杂性已大大降低,使其他人更易于阅读。只是为了确保我没有弄乱地方的代码,让我们检查一下它们是否返回相同的结果:

set.seed(2020)
mnl_sample <- t(rmultinom(n = 1000,size = 1,prob = c(0.3, 0.4, 0.2, 0.1)))
mnl_sample <- apply(mnl_sample,1,function(r) which(r == 1))

beta = c(4,2,1)
Obs = mnl_sample 
n_cat = 4
xr <- llmnl_int(beta = beta, Obs = mnl_sample, n_cat = n_cat)
xr2 <- llmnl_int_R_v2(beta = beta, Obs = mnl_sample, n_cat = n_cat)
xc <- llmnl_int_C(beta = beta, Obs = mnl_sample, n_cat = n_cat)
xc2 <- llmnl_int_C_v2(beta = beta, Obs = mnl_sample, n_cat = n_cat)
all.equal(c(xr, xr2), c(xc, xc2))
TRUE

好吧,这是一种解脱。

性能:

我将使用微基准测试来说明性能。优化的功能很快,因此我将运行功能1e5时间以减少垃圾收集器的影响

microbenchmark("llmml_int_R" = llmnl_int(beta = beta, Obs = mnl_sample, n_cat = n_cat),
               "llmml_int_C" = llmnl_int_C(beta = beta, Obs = mnl_sample, n_cat = n_cat),
               "llmnl_int_R_v2" = llmnl_int_R_v2(beta = beta, Obs = mnl_sample, n_cat = n_cat),
               "llmml_int_C_v2" = llmnl_int_C_v2(beta = beta, Obs = mnl_sample, n_cat = n_cat),
               times = 1e5)
#Output:
#Unit: microseconds
#           expr     min      lq       mean  median      uq        max neval
#    llmml_int_R 202.701 206.801 288.219673 227.601 334.301  57368.902 1e+05
#    llmml_int_C 250.101 252.802 342.190342 272.001 399.251 112459.601 1e+05
# llmnl_int_R_v2   4.800   5.601   8.930027   6.401   9.702   5232.001 1e+05
# llmml_int_C_v2   5.100   5.801   8.834646   6.700  10.101   7154.901 1e+05

在这里,我们看到与以前相同的结果。现在,新功能比其第一批对应零件快大约35倍(R)和40倍(Cpp)。有趣的是,优化R功能比我的优化Cpp功能快一点(0.3毫秒或4%)。我最好的选择是,该Rcpp程序包中有一些开销,如果删除了该开销,则两者将相同或为R。

同样,我们可以使用Optim检查性能。

microbenchmark("llmnl_int" = optim(beta, llmnl_int, Obs = mnl_sample, 
                                   n_cat = n_cat, method = "BFGS", hessian = F, 
                                   control = list(fnscale = -1)),
               "llmnl_int_C" = optim(beta, llmnl_int_C, Obs = mnl_sample, 
                                     n_cat = n_cat, method = "BFGS", hessian = F, 
                                     control = list(fnscale = -1)),
               "llmnl_int_R_v2" = optim(beta, llmnl_int_R_v2, Obs = mnl_sample, 
                                     n_cat = n_cat, method = "BFGS", hessian = F, 
                                     control = list(fnscale = -1)),
               "llmnl_int_C_v2" = optim(beta, llmnl_int_C_v2, Obs = mnl_sample, 
                                     n_cat = n_cat, method = "BFGS", hessian = F, 
                                     control = list(fnscale = -1)),
               times = 1e3)
#Output:
#Unit: microseconds
#           expr       min        lq      mean    median         uq      max neval
#      llmnl_int 29541.301 53156.801 70304.446 76753.851  83528.101 196415.5  1000
#    llmnl_int_C 36879.501 59981.901 83134.218 92419.551 100208.451 190099.1  1000
# llmnl_int_R_v2   667.802  1253.452  1962.875  1585.101   1984.151  22718.3  1000
# llmnl_int_C_v2   704.401  1248.200  1983.247  1671.151   2033.401  11540.3  1000

结果再次是相同的。

结论:

简短的结论是,这是一个示例,其中将代码转换为Rcpp并不值得。并非总是如此,但是通常值得再次看一下您的函数,以查看代码中是否存在执行不必要的计算的区域。尤其是在使用内置矢量化函数的情况下,通常不值得花时间将代码转换为Rcpp。如果人们使用for-loops无法轻易向量化的代码来删除for循环,那么人们通常会看到很大的改进。


1
您可以将其Obs视为IntegerVector删除某些演员。
拉尔夫·斯塔伯纳

只是将其合并,然后再感谢您在回答中注意到这一点。它只是我过去了。我在我的回答@RalfStubner中将此功劳归功于您。:-)
奥利弗

2
正如您在这个玩具示例(仅拦截mnl模型)中注意到的那样,线性预测变量(beta)在观测值上保持恒定Obs。如果我们有时变的预测变量,则根据设计矩阵的值,有必要denom对每个预测变量进行隐式计算。话虽如此,我已经在我的其余代码上实现了您的建议,并获得了一些非常不错的收获:)。感谢@ RalfStubner,@ Oliver和@thc的宝贵见解!现在进入我的下一个瓶颈!ObsX
smildiner

1
我很高兴我们能提供帮助。在更一般的情况下,在第二步的每一步中计算减法导数for-loop将给您最大的收益。同样,在更一般的情况下,我建议使用model.matrix(...)创建矩阵作为函数输入。
奥利弗

9

使用以下观察结果,可以使C ++函数更快。至少第一个也可以与R函数一起使用:

  • denom[i]每个的计算方法都相同i。因此,使用a double denom并仅执行一次此计算是有意义的。最后,我还考虑了减去这个通用术语。

  • 您的观察结果实际上是R端的一个整数向量,并且您也将它们用作C ++中的整数。使用IntegerVector与品牌开始大量铸造不必要的。

  • 您也可以在C ++中NumericVector使用编制索引IntegerVector。我不确定这是否有助于提高性能,但会使代码更短。

  • 更多与风格而非性能相关的更改。

结果:

double llmnl_int_C(NumericVector beta, IntegerVector Obs, int n_cat) {

    int n_Obs = Obs.size();

    NumericVector betas(beta.size()+1);
    for (int i = 1; i < n_cat; ++i) {
        betas[i] = beta[i-1];
    };

    double denom = log(sum(exp(betas)));
    NumericVector Xby = betas[Obs - 1];

    return sum(Xby) - n_Obs * denom;
}

对我来说,此功能大约比您的R函数快十倍。


感谢您的回答Ralph,没有发现输入类型。我已经将此纳入我的答案中,并给了您荣誉。:-)
奥利弗

7

我可以想到针对Ralf和Olivers答案的四个潜在优化。

(您应该接受他们的回答,但我只想加上2美分)。

1)// [[Rcpp::export(rng = false)]]在单独的C ++文件中用作函数的注释标头。这导致我的机器上的速度提高了约80%。(这是4个中最重要的建议)。

2)cmath尽可能选择。(在这种情况下,似乎没有什么不同)。

3)尽可能避免分配,例如不要移入beta新向量。

4)伸展目标:使用SEXP参数而不是Rcpp向量。(左为读者练习)。Rcpp向量是非常薄的包装器,但是它们仍然是包装器,并且开销很小。

这些建议并不重要,即使不是因为您在中的紧密循环中调用该函数的事实也是如此optim。因此,任何开销都是非常重要的。

板凳:

microbenchmark("llmnl_int_R_v1" = optim(beta, llmnl_int, Obs = mnl_sample, 
                                      n_cat = n_cat, method = "BFGS", hessian = F, 
                                      control = list(fnscale = -1)),
             "llmnl_int_R_v2" = optim(beta, llmnl_int_R_v2, Obs = mnl_sample, 
                                      n_cat = n_cat, method = "BFGS", hessian = F, 
                                      control = list(fnscale = -1)),
             "llmnl_int_C_v2" = optim(beta, llmnl_int_C_v2, Obs = mnl_sample, 
                                      n_cat = n_cat, method = "BFGS", hessian = F, 
                                      control = list(fnscale = -1)),
             "llmnl_int_C_v3" = optim(beta, llmnl_int_C_v3, Obs = mnl_sample, 
                                      n_cat = n_cat, method = "BFGS", hessian = F, 
                                      control = list(fnscale = -1)),
             "llmnl_int_C_v4" = optim(beta, llmnl_int_C_v4, Obs = mnl_sample, 
                                      n_cat = n_cat, method = "BFGS", hessian = F, 
                                      control = list(fnscale = -1)),
             times = 1000)


Unit: microseconds
expr      min         lq       mean     median         uq        max neval cld
llmnl_int_R_v1 9480.780 10662.3530 14126.6399 11359.8460 18505.6280 146823.430  1000   c
llmnl_int_R_v2  697.276   735.7735  1015.8217   768.5735   810.6235  11095.924  1000  b 
llmnl_int_C_v2  997.828  1021.4720  1106.0968  1031.7905  1078.2835  11222.803  1000  b 
llmnl_int_C_v3  284.519   295.7825   328.5890   304.0325   328.2015   9647.417  1000 a  
llmnl_int_C_v4  245.650   256.9760   283.9071   266.3985   299.2090   1156.448  1000 a 

v3是Oliver的回答rng=false。v4包含建议2和3。

功能:

#include <Rcpp.h>
#include <cmath>
using namespace Rcpp;

// [[Rcpp::export(rng = false)]]
double llmnl_int_C_v4(NumericVector beta, IntegerVector Obs, int n_cat) {

  int n_Obs = Obs.size();
  //2: Calculate log sum only once:
  // double expBetas_log_sum = log(sum(exp(betas)));
  double expBetas_log_sum = 1.0; // std::exp(0)
  for (int i = 1; i < n_cat; i++) {
    expBetas_log_sum += std::exp(beta[i-1]);
  };
  expBetas_log_sum = std::log(expBetas_log_sum);

  double ll_sum = 0;
  //3: Use n_Obs, to avoid calling Xby.size() every time 
  for (int i = 0; i < n_Obs; i++) {
    if(Obs[i] == 1L) continue;
    ll_sum += beta[Obs[i]-2L];
  };
  //4: Use that we know denom is the same for all I:
  ll_sum = ll_sum - expBetas_log_sum * n_Obs;
  return ll_sum;
}
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.