Rcpp 속도가 느린 R 목적 함수를 최적화하는 이유는 무엇입니까?


16

현재 반복마다 다항식 로짓 모델을 여러 단계로 최적화 해야하는 베이지안 방법을 연구 중입니다. 이러한 최적화를 수행하기 위해 optim ()을 사용하고 있으며 R로 작성된 목적 함수입니다. 프로파일 링에서 optim ()이 주요 병목 현상 인 것으로 나타났습니다.

주위를 파고 들자, 이 질문 을 통해 목적 함수를 코딩 Rcpp하면 프로세스 속도를 높일 수 있다고 제안했습니다 . 나는 제안을 따랐고 객관적인 함수를로 코딩 Rcpp했지만 결국 더 느려졌습니다 (약 2 배 느립니다!).

이것은 Rcpp(또는 C ++과 관련된 것) 처음으로 코드를 벡터화하는 방법을 찾을 수 없었습니다. 더 빨리 만드는 방법에 대한 아이디어가 있습니까?

T1; 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에 처음 게시!

답변:


9

일반적으로 벡터화 된 함수를 사용할 수 있다면 Rcpp에서 직접 코드를 실행하는 것만큼이나 빠릅니다. 이는 R의 많은 벡터화 된 함수 (Base R의 거의 모든 벡터화 된 함수)가 C, Cpp 또는 Fortran으로 작성되므로 얻을 것이 거의 없기 때문입니다.

R, Rcpp코드 와 코드를 모두 향상시킬 수 있습니다. 최적화는 코드를주의 깊게 연구하고 불필요한 단계 (메모리 할당, 합계 등)를 제거함으로써 이루어집니다.

Rcpp코드 최적화 부터 시작하겠습니다 .

귀하의 경우 주요 최적화는 불필요한 행렬 및 벡터 계산을 제거하는 것입니다. 코드는 본질적으로

  1. 시프트 베타
  2. exp (shift beta)의 합의 로그를 계산합니다. [log-sum-exp]
  3. 시프트 된 베타의 인덱스로 Obs를 사용하고 모든 확률에 대한 합계
  4. log-sum-exp를 빼다

이 관찰을 사용하여 코드를 2 개의 for-loop로 줄일 수 있습니다. 참고 sum단순히 서로에 대한 루프 (더 많거나 적은 : for(i = 0; i < max; i++){ sum += x }) 합을 피하는 것이 더 것들 코드의 속도를 높일 수 있도록 (대부분의 상황에서이 불필요한 최적화입니다!). 또한 입력 Obs은 정수 벡터이며 요소를 값으로 IntegerVector캐스팅하지 않도록 유형을 사용하여 코드를 추가로 최적화 할 수 있습니다 (Credit to Ralf Stubner의 답변).doubleinteger

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

글쎄요, 그것은 구호입니다.

공연:

성능을 설명하기 위해 microbenchmark를 사용합니다. 최적화 된 기능은 빠르므로 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기능은 여전히 최적화 된 기능 보다 매우 약간 (0.3ms 또는 4 %) 빠릅니다 Cpp. 가장 좋은 방법은 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-loopsfor-loop를 제거하기 위해 쉽게 벡터화 할 수없는 코드와 함께 사용하면 더 큰 개선 효과를 볼 수 있습니다.


1
캐스트를 제거 하는 Obs것으로 취급 할 수 있습니다 IntegerVector.
Ralf Stubner

당신의 대답에 이것을 알아 줘서 감사하기 전에 그것을 통합하고있었습니다. 그것은 단순히 나를 지나쳤다. 내 답변 @RalfStubner에서 이에 대한 크레딧을주었습니다. :-)
Oliver

2
이 장난감 예제 (인터셉트 전용 mnl 모델)에서 알 수 있듯이 선형 예측 변수 ( beta)는 관측치에 대해 일정하게 유지됩니다 Obs. 만약 시변 예측 변수가 있다면 , 설계 행렬의 가치에 기초하여 denom각각 에 대한 암시 적 계산 Obs이 필요할 것 X입니다. 즉, 나는 이미 코드의 나머지 부분에 대한 제안을 실제로 좋은 이익으로 구현하고 있습니다. :). 통찰력있는 답장을 보내 주신 @RalfStubner, @Oliver 및 @thc에게 감사합니다! 이제 다음 병목 현상으로 넘어갑니다!
smildiner

1
도와 드리겠습니다. 보다 일반적인 경우 두 번째 단계의 각 단계에서 뺄셈을 계산 for-loop하면 가장 큰 이득을 얻을 수 있습니다. 또한 더 일반적인 경우에는 model.matrix(...)함수 입력을 위해 행렬을 만드는 데 사용 하는 것이 좋습니다 .
올리버

9

다음 관찰을 통해 C ++ 함수를 더 빠르게 만들 수 있습니다. 적어도 첫 번째는 R 함수와 함께 사용할 수도 있습니다.

  • 계산 방법 denom[i]은 모두 동일합니다 i. 따라서 a를 사용 double denom하고이 계산을 한 번만 수행 하는 것이 좋습니다 . 또한이 공통 용어를 결국 빼는 것도 제외합니다.

  • 관측치는 실제로 R 측의 정수 벡터이며 C ++에서도 정수로 사용합니다. 를 사용하면 IntegerVector차종에 불필요한 캐스팅을 많이 시작합니다.

  • C ++에서도를 NumericVector사용하여 a 를 색인 할 수 있습니다 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 기능보다 약 10 배 빠릅니다.


귀하의 답변에 감사드립니다 Ralph는 입력 유형을 찾지 못했습니다. 나는 이것을 당신의 신용을 줄뿐만 아니라 내 대답에 통합했습니다. :-)
Oliver

7

Ralf와 Olivers의 대답에 대한 네 가지 잠재적 최적화를 생각할 수 있습니다 .

(당신은 그들의 대답을 받아 들여야하지만, 나는 단지 2 센트를 더하고 싶었습니다).

1) // [[Rcpp::export(rng = false)]]별도의 C ++ 파일에서 함수의 주석 헤더로 사용하십시오 . 내 컴퓨터의 속도가 ~ 80 % 증가합니다. (이것은 4 가지 중에서 가장 중요한 제안입니다).

2) cmath가능하면 선호 하십시오. (이 경우에는 차이가없는 것 같습니다).

3) 가능할 때마다 할당을 피하십시오 (예 : beta새 벡터로 이동하지 마십시오) .

4) 스트레치 목표 : SEXPRcpp 벡터 대신 매개 변수를 사용하십시오 . (독자에게 연습으로 왼쪽). 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;
}
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.