Ottimizzare la funzione dell'obiettivo R con Rcpp più lento, perché?


16

Attualmente sto lavorando a un metodo bayesiano che richiede più passaggi di ottimizzazione di un modello logit multinomiale per iterazione. Sto usando optim () per eseguire quelle ottimizzazioni, e una funzione obiettiva scritta in R. Una profilatura ha rivelato che optim () è il principale collo di bottiglia.

Dopo aver scavato, ho trovato questa domanda in cui suggeriscono che la ricodifica della funzione obiettivo Rcpppotrebbe accelerare il processo. Ho seguito il suggerimento e ho ricodificato la mia funzione oggettiva Rcpp, ma alla fine è stata più lenta (circa due volte più lenta!).

Questa è stata la mia prima volta con Rcpp(o qualsiasi cosa relativa al C ++) e non sono stato in grado di trovare un modo per vettorializzare il codice. Qualche idea su come renderlo più veloce?

Tl; dr: l'attuale implementazione della funzione in Rcpp non è veloce come R vettoriale; come renderlo più veloce?

Un esempio riproducibile :

1) Definire le funzioni oggettive in Re Rcpp: probabilità logaritmica di un modello multinomiale di intercettazione

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) Confronta la loro efficienza:

## 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) Ora chiamandoli in 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

Sono stato un po 'sorpreso che l'implementazione vettorializzata in R fosse più veloce. L'implementazione di una versione più efficiente in Rcpp (diciamo con RcppArmadillo?) Può produrre guadagni? È un'idea migliore ricodificare tutto in Rcpp usando un ottimizzatore C ++?

PS: prima pubblicazione su Stackoverflow!

Risposte:


9

In generale, se sei in grado di usare funzioni vettorializzate, lo troverai (quasi) veloce come eseguire il tuo codice direttamente in Rcpp. Questo perché molte funzioni vettorializzate in R (quasi tutte le funzioni vettorializzate in Base R) sono scritte in C, Cpp o Fortran e come tali spesso c'è poco da guadagnare.

Detto questo, ci sono miglioramenti per ottenere sia nella vostra Re Rcppcodice. L'ottimizzazione deriva dallo studio attento del codice e dalla rimozione di passaggi non necessari (assegnazione di memoria, somme, ecc.).

Cominciamo con l' Rcppottimizzazione del codice.

Nel tuo caso l'ottimizzazione principale è quella di rimuovere la matrice non necessaria e i calcoli vettoriali. Il codice è in sostanza

  1. Maiusc beta
  2. calcola il registro della somma di exp (shift beta) [log-sum-exp]
  3. usa Obs come indice per la beta spostata e somma tutte le probabilità
  4. sottrarre il log-sum-exp

Usando questa osservazione possiamo ridurre il tuo codice a 2 for-loop. Si noti che sumè semplicemente un altro for-loop (più o meno for(i = 0; i < max; i++){ sum += x }:) quindi evitare le somme può velocizzare ulteriormente il proprio codice (nella maggior parte dei casi si tratta di un'ottimizzazione non necessaria!). Inoltre, il tuo input Obsè un vettore intero e possiamo ottimizzare ulteriormente il codice utilizzando il IntegerVectortipo per evitare di trasmettere gli doubleelementi ai integervalori (credito alla risposta di 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;
}')

Si noti che ho rimosso alcune allocazioni di memoria e rimosso calcoli non necessari nel for-loop. Inoltre ho usato denomlo stesso per tutte le iterazioni e semplicemente moltiplicato per il risultato finale.

Possiamo eseguire ottimizzazioni simili nel tuo codice R, che si traduce nella seguente funzione:

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
}

Nota che la complessità della funzione è stata drasticamente ridotta rendendo più semplice la lettura da parte di altri. Solo per essere sicuri che non ho incasinato il codice da qualche parte, controlliamo che restituiscano gli stessi risultati:

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

bene è un sollievo.

Prestazione:

Userò il microbenchmark per illustrare le prestazioni. Le funzioni ottimizzate sono veloci, quindi eseguirò i 1e5tempi delle funzioni per ridurre l'effetto del Garbage Collector

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

Qui vediamo lo stesso risultato di prima. Ora le nuove funzioni sono circa 35 volte più veloci (R) e 40 volte più veloci (Cpp) rispetto alle loro prime controparti. È interessante notare che la Rfunzione ottimizzata è ancora leggermente (0,3 ms o 4%) più veloce della mia Cppfunzione ottimizzata . La mia scommessa migliore qui è che ci sia un certo overhead dal Rcpppacchetto, e se questo fosse rimosso i due sarebbero identici o R.

Allo stesso modo possiamo verificare le prestazioni utilizzando 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

Ancora una volta il risultato è lo stesso.

Conclusione:

In conclusione, vale la pena notare che questo è un esempio, in cui la conversione del codice in Rcpp non vale davvero la pena. Questo non è sempre il caso, ma spesso vale la pena dare una seconda occhiata alla tua funzione, per vedere se ci sono aree del tuo codice, dove vengono eseguiti calcoli non necessari. Soprattutto nelle situazioni in cui si usano funzioni vettoriali incorporate, spesso non vale la pena convertire il codice in Rcpp. Più spesso si possono vedere grandi miglioramenti se si usa for-loopscon codice che non può essere facilmente vettorizzato per rimuovere il for-loop.


1
Puoi considerare Obscome IntegerVectorrimuovere alcuni cast.
Ralf Stubner,

Lo stavo solo incorporando prima di ringraziarti per aver notato questo nella tua risposta. È semplicemente passato da me. Ti ho dato credito per questo nella mia risposta @RalfStubner. :-)
Oliver

2
Come hai notato in questo esempio di giocattolo (modello mnl solo intercettazione) i predittori lineari ( beta) rimangono costanti sulle osservazioni Obs. Se avessimo predittori variabili nel tempo, sarebbe necessario un calcolo implicito denomper ciascuno Obs, basato sul valore di una matrice di progettazione X. Detto questo, sto già implementando i tuoi suggerimenti sul resto del mio codice con alcuni guadagni davvero interessanti :). Grazie @RalfStubner, @Oliver e @thc per le tue risposte molto perspicaci! Passiamo ora al mio prossimo collo di bottiglia!
smildiner,

1
Sono felice che potremmo aiutare. Nel caso più generale si calcola la sottrazione del denom ad ogni passo del secondo for-loopche ti darà il massimo guadagno. Inoltre, nel caso più generale, suggerirei di utilizzare model.matrix(...)per creare la matrice per l'input nelle funzioni.
Oliver,

9

La tua funzione C ++ può essere resa più veloce usando le seguenti osservazioni. Almeno il primo potrebbe anche essere usato con la tua funzione R:

  • Il modo in cui calcoli denom[i]è lo stesso per tutti i. Ha quindi senso usare a double denome fare questo calcolo una sola volta. Alla fine, desidero anche sottrarre questo termine comune.

  • Le tue osservazioni sono in realtà un vettore intero sul lato R e le stai usando anche come numeri interi in C ++. L'uso di un IntegerVectorper cominciare rende superflui i casting.

  • Puoi indicizzare a NumericVectorusando anche IntegerVectorin C ++. Non sono sicuro che ciò aiuti le prestazioni, ma rende il codice un po 'più breve.

  • Alcuni cambiamenti che sono più legati allo stile che alle prestazioni.

Risultato:

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

Per me questa funzione è circa dieci volte più veloce della tua funzione R.


Grazie per la risposta Ralph, non ho individuato il tipo di input. L'ho incorporato anche nella mia risposta, dandoti il ​​merito. :-)
Oliver

7

Posso pensare a quattro potenziali ottimizzazioni sulle risposte di Ralf e Olivers.

(Dovresti accettare le loro risposte, ma volevo solo aggiungere i miei 2 centesimi).

1) Utilizzare // [[Rcpp::export(rng = false)]]come intestazione di commento la funzione in un file C ++ separato. Questo porta ad una velocità dell'80% circa sulla mia macchina. (Questo è il suggerimento più importante tra i 4).

2) Preferisci cmathquando possibile. (In questo caso, non sembra fare la differenza).

3) Evitare l'allocazione quando possibile, ad es. Non passare betaa un nuovo vettore.

4) Obiettivo di stiramento: utilizzare i SEXPparametri anziché i vettori Rcpp. (Lasciato come esercizio al lettore). I vettori Rcpp sono involucri molto sottili, ma sono ancora involucri e c'è un piccolo overhead.

Questi suggerimenti non sarebbero importanti, se non per il fatto che stai chiamando la funzione in modo stretto optim. Quindi qualsiasi sovraccarico è molto importante.

Bench:

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 è la risposta di Oliver con rng=false. v4 è con Suggerimenti n. 2 e n. 3 inclusi.

La funzione:

#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;
}
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.