Tối ưu hóa chức năng mục tiêu R với Rcpp chậm hơn, tại sao?


16

Tôi hiện đang làm việc trên một phương pháp Bayes yêu cầu nhiều bước tối ưu hóa mô hình logit đa phương trên mỗi lần lặp. Tôi đang sử dụng Optim () để thực hiện các tối ưu hóa đó và một hàm mục tiêu được viết bằng R. Một hồ sơ tiết lộ rằng Optim () là nút cổ chai chính.

Sau khi đào xung quanh, tôi thấy câu hỏi này trong đó họ đề nghị rằng mã hóa lại hàm mục tiêu có Rcppthể tăng tốc quá trình. Tôi đã làm theo gợi ý và mã hóa lại hàm mục tiêu của mình Rcpp, nhưng cuối cùng nó chậm hơn (chậm hơn khoảng hai lần!).

Đây là lần đầu tiên của tôi với Rcpp(hoặc bất cứ điều gì liên quan đến C ++) và tôi không thể tìm ra cách vectơ mã. Bất kỳ ý tưởng làm thế nào để làm cho nó nhanh hơn?

Tl; dr: Việc triển khai chức năng hiện tại trong Rcpp không nhanh bằng vector R; Làm thế nào để làm cho nó nhanh hơn?

Một ví dụ tái sản xuất :

1) Xác định các hàm mục tiêu trong RRcpp: khả năng đăng nhập của một mô hình đa phương chỉ chặn

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) So sánh hiệu quả của chúng:

## 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) Bây giờ gọi họ vào 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

Tôi hơi ngạc nhiên khi việc triển khai véc tơ trong R nhanh hơn. Việc triển khai một phiên bản hiệu quả hơn trong Rcpp (giả sử với RcppArmadillo?) Có thể tạo ra bất kỳ lợi ích nào? Có phải là một ý tưởng tốt hơn để mã hóa lại mọi thứ trong Rcpp bằng trình tối ưu hóa C ++?

PS: lần đầu tiên đăng bài tại Stackoverflow!

Câu trả lời:


9

Nói chung, nếu bạn có thể sử dụng các hàm vectơ, bạn sẽ thấy nó nhanh (gần như) nhanh như chạy mã của bạn trực tiếp trong Rcpp. Điều này là do nhiều hàm vectơ trong R (hầu hết tất cả các hàm vectơ trong Base R) được viết bằng C, Cpp hoặc Fortran và do đó thường có rất ít để đạt được.

Điều đó nói rằng, có những cải tiến để đạt được cả trong RRcppmã của bạn . Tối ưu hóa đến từ việc nghiên cứu kỹ mã và loại bỏ các bước không cần thiết (gán bộ nhớ, tổng, v.v.).

Hãy bắt đầu với việc Rcpptối ưu hóa mã.

Trong trường hợp của bạn, tối ưu hóa chính là loại bỏ các phép tính vectơ và ma trận không cần thiết. Mã về bản chất là

  1. Dịch chuyển beta
  2. tính toán nhật ký của tổng exp (shift beta) [log-sum-exp]
  3. sử dụng Obs làm chỉ mục cho phiên bản beta đã thay đổi và tính tổng tất cả các xác suất
  4. trừ log-sum-exp

Sử dụng quan sát này, chúng tôi có thể giảm mã của bạn xuống còn 2 vòng. Lưu ý rằng đó sumchỉ đơn giản là một vòng lặp for khác (nhiều hơn hoặc ít hơn for(i = 0; i < max; i++){ sum += x }:) vì vậy việc tránh các khoản tiền có thể tăng tốc mã hơn nữa (trong hầu hết các tình huống, đây là tối ưu hóa không cần thiết!). Ngoài ra, đầu vào của bạn Obslà một vectơ số nguyên và chúng tôi có thể tối ưu hóa thêm mã bằng cách sử dụng IntegerVectorloại để tránh truyền các doublephần tử thành integergiá trị (Câu trả lời của Credit cho 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;
}')

Lưu ý rằng tôi đã loại bỏ khá nhiều phân bổ bộ nhớ và loại bỏ các tính toán không cần thiết trong vòng lặp for. Ngoài ra tôi đã sử dụng denomtương tự cho tất cả các lần lặp và chỉ đơn giản là nhân cho kết quả cuối cùng.

Chúng tôi có thể thực hiện tối ưu hóa tương tự trong mã R của bạn, dẫn đến chức năng dưới đây:

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
}

Lưu ý sự phức tạp của chức năng đã được giảm đáng kể làm cho người khác dễ đọc hơn. Chỉ cần chắc chắn rằng tôi đã không nhầm lẫn mã ở đâu đó, hãy kiểm tra xem chúng có trả lại kết quả tương tự không:

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

đó là một cứu trợ.

Hiệu suất:

Tôi sẽ sử dụng microbenchmark để minh họa hiệu suất. Các chức năng được tối ưu hóa rất nhanh, vì vậy tôi sẽ chạy các 1e5lần chức năng để giảm hiệu quả của trình thu gom rác

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

Ở đây chúng ta thấy kết quả tương tự như trước đây. Bây giờ các chức năng mới nhanh hơn khoảng 35 lần (R) và nhanh hơn 40 lần (Cpp) so với các bộ phận đầu tiên của chúng. Điều thú vị Rlà chức năng tối ưu hóa vẫn nhanh hơn một chút (0,3 ms hoặc 4%) so với Cppchức năng tối ưu hóa của tôi . Đặt cược tốt nhất của tôi ở đây là có một số chi phí từ Rcppgói, và nếu điều này bị loại bỏ, cả hai sẽ giống hệt nhau hoặc R.

Tương tự như vậy, chúng ta có thể kiểm tra hiệu suất bằng 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

Một lần nữa kết quả là như nhau.

Phần kết luận:

Như một kết luận ngắn, đáng lưu ý rằng đây là một ví dụ, trong đó việc chuyển đổi mã của bạn sang Rcpp không thực sự đáng để gặp rắc rối. Điều này không phải lúc nào cũng đúng, nhưng thường thì đáng để xem qua chức năng của bạn, để xem liệu có khu vực nào trong mã của bạn không, nơi thực hiện các phép tính không cần thiết. Đặc biệt trong các tình huống mà người ta sử dụng các hàm vector hóa buildin, thường không có giá trị thời gian để chuyển đổi mã sang Rcpp. Thông thường, người ta có thể thấy những cải tiến tuyệt vời nếu một người sử dụng for-loopsvới mã không thể dễ dàng được vector hóa để loại bỏ vòng lặp for.


1
Bạn có thể coi Obsnhư là một IntegerVectorloại bỏ một số phôi.
Ralf Stubner

Chỉ là kết hợp nó trước khi cảm ơn bạn đã nhận thấy điều này trong câu trả lời của bạn. Nó chỉ đơn giản là thông qua tôi. Tôi đã cho bạn tín dụng cho điều này trong câu trả lời của tôi @RalfStubner. :-)
Oliver

2
Như bạn đã thấy trên ví dụ đồ chơi này (mô hình mnl chỉ chặn), các yếu tố dự đoán tuyến tính ( beta) không đổi trong các quan sát Obs. Nếu chúng ta có thời gian dự đoán khác nhau, việc tính toán ngầm denomcho từng yếu tố Obssẽ trở nên cần thiết, dựa trên giá trị của ma trận thiết kế X. Điều đó đang được nói, tôi đã thực hiện các đề xuất của bạn về phần còn lại của mã của tôi với một số lợi ích thực sự tốt đẹp :). Cảm ơn bạn @RalfStubner, @Oliver và @thc vì những câu trả lời sâu sắc của bạn! Bây giờ chuyển sang nút cổ chai tiếp theo của tôi!
smildiner

1
Tôi rất vui vì chúng tôi có thể giúp đỡ. Trong trường hợp tổng quát hơn, việc tính toán mệnh giá trừ ở mỗi bước của giây for-loopsẽ mang lại cho bạn mức tăng lớn nhất. Ngoài ra, trong trường hợp tổng quát hơn, tôi khuyên bạn nên sử dụng model.matrix(...)để tạo ma trận cho đầu vào trong các chức năng của mình.
Oliver

9

Chức năng C ++ của bạn có thể được thực hiện nhanh hơn bằng cách sử dụng các quan sát sau đây. Ít nhất đầu tiên cũng có thể được sử dụng với chức năng R của bạn:

  • Cách bạn tính toán denom[i]là như nhau cho mọi i. Do đó, có ý nghĩa khi sử dụng a double denomvà thực hiện phép tính này chỉ một lần. Tôi cũng yếu tố trừ đi thuật ngữ phổ biến này cuối cùng.

  • Các quan sát của bạn thực sự là một vectơ số nguyên ở phía R và bạn cũng đang sử dụng chúng làm số nguyên trong C ++. Sử dụng một IntegerVectorđể bắt đầu với rất nhiều đúc không cần thiết.

  • Bạn cũng có thể lập chỉ mục NumericVectorbằng cách sử dụng một IntegerVectorC ++. Tôi không chắc chắn nếu điều này giúp hiệu suất, nhưng nó làm cho mã ngắn hơn một chút.

  • Một số thay đổi liên quan nhiều đến phong cách hơn là hiệu suất.

Kết quả:

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;
}

Đối với tôi chức năng này nhanh hơn khoảng mười lần so với chức năng R của bạn.


Cảm ơn câu trả lời của bạn Ralph, đã không phát hiện ra loại đầu vào. Tôi đã kết hợp điều này vào câu trả lời của mình cũng như cung cấp cho bạn khoản tín dụng. :-)
Oliver

7

Tôi có thể nghĩ về bốn tối ưu hóa tiềm năng đối với câu trả lời của Ralf và Olivers.

(Bạn nên chấp nhận câu trả lời của họ, nhưng tôi chỉ muốn thêm 2 xu của mình).

1) Sử dụng // [[Rcpp::export(rng = false)]]làm tiêu đề nhận xét cho chức năng trong tệp C ++ riêng biệt. Điều này dẫn đến tăng tốc ~ 80% trên máy của tôi. (Đây là gợi ý quan trọng nhất trong số 4).

2) Thích cmathkhi có thể. (Trong trường hợp này, nó dường như không tạo ra sự khác biệt).

3) Tránh phân bổ bất cứ khi nào có thể, ví dụ: không chuyển betasang một vectơ mới.

4) Kéo dài mục tiêu: sử dụng SEXPcác tham số thay vì các vectơ Rcpp. (Còn lại như một bài tập cho người đọc). Các vectơ Rcpp là các hàm bao rất mỏng, nhưng chúng vẫn là các hàm bao và có một chi phí nhỏ.

Những đề xuất này sẽ không quan trọng, nếu không phải vì thực tế là bạn đang gọi hàm trong một vòng lặp chặt chẽ optim. Vì vậy, bất kỳ chi phí là rất quan trọng.

Băng ghế:

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 là câu trả lời của Oliver với rng=false. v4 có kèm theo Gợi ý số 2 và số 3.

Chức năng:

#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;
}
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.