Оптимізація цільової функції R з Rcpp повільніше, чому?


16

Зараз я працюю над методом Байєса, який вимагає декількох кроків оптимізації багаточленної моделі logit за ітерацію. Я використовую Optim () для виконання цих оптимізацій, і об'єктивна функція, написана в Р. Профілювання показало, що 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?) Може призвести до будь-яких вигод? Чи краще ідея перекодувати все в Rcpp за допомогою оптимізатора C ++?

PS: перший раз розміщення в Stackoverflow!

Відповіді:


9

Загалом, якщо ви зможете використовувати векторизовані функції, ви виявите, що це (майже) так швидко, як запуск коду безпосередньо в Rcpp. Це пояснюється тим, що багато векторизованих функцій в R (майже всі векторизовані функції в базі R) записані на C, Cpp або Fortran, і як такої часто можна отримати мало.

Однак, у вашому Rта Rcppкодовому відношенні є покращення . Оптимізація відбувається завдяки уважному вивченню коду та видаленню зайвих кроків (призначення пам'яті, сум тощо).

Почнемо з Rcppоптимізації коду.

У вашому випадку основна оптимізація полягає у видаленні непотрібної матриці та векторних обчислень. Код по суті

  1. Бета-версія Shift
  2. обчислити журнал суми exp (shift beta) [log-sum-exp]
  3. використовувати Obs в якості індексу для зрушеної бета-версії та підсумовувати всі ймовірності
  4. підрегулювати log-sum-exp

Використовуючи це спостереження, ми можемо зменшити ваш код до 2-х циклів. Зауважте, що sumце просто інший цикл (більш-менш for(i = 0; i < max; i++){ sum += x }:), тому уникнення сум може пришвидшити те, що код надалі (в більшості ситуацій це зайва оптимізація!). Крім того, ваш вхід Obsє цілим вектором, і ми можемо додатково оптимізувати код, використовуючи IntegerVectorтип, щоб уникнути закидання doubleелементів до integerзначень (Кредит відповіді Ральфа Стубнера).

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функція все ще дуже незначна (0,3 мс або 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-loopsкодом, який не можна легко векторизувати, щоб вилучити цикл for.


1
Ви можете ставитися Obsяк до IntegerVectorзняття деяких каст.
Ральф Стубнер

Просто включив це перед тим, як подякувати за те, що помітили це у своїй відповіді. Це просто пройшло повз мене. Я вдячний вам за це у своїй відповіді @RalfStubner. :-)
Олівер

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для початку робить багато кастингу непотрібним.

  • Ви можете проіндексувати NumericVectorвикористання, використовуючи також і IntegerVectorC ++. Я не впевнений, чи сприяє це продуктивність, але це робить код трохи коротшим.

  • Ще кілька змін, які більше стосуються стилю, ніж продуктивності.

Результат:

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 функція.


Дякуємо за вашу відповідь, Ральф, не помітив тип введення. Я включив це у свою відповідь, а також даю вам заслугу. :-)
Олівер

7

Я можу придумати чотири потенційні оптимізації щодо відповідей Ральфа та Оліверса.

(Ви повинні прийняти їх відповіді, але я просто хотів додати свої 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 - відповідь Олівера 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;
}
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.