Rcppを遅くしてR目的関数を最適化すると、なぜですか?


16

私は現在、反復ごとに多項ロジットモデルの最適化の複数のステップを必要とするベイズ法に取り組んでいます。これらの最適化を実行するためにoptim()を使用し、Rで記述された目的関数を使用しています。プロファイリングにより、optim()が主なボトルネックであることが明らかになりました。

調べてみると、目的関数を再コーディングするとプロセスが高速化する可能性があることを示唆するこの質問が見つかりましたRcpp。私は提案に従い、目的関数をRcppでに遅くなりました(約2倍遅い!)。

これは初めて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での初めての投稿!

回答:


9

一般に、ベクトル化された関数を使用できる場合、Rcppでコードを直接実行するのと(ほぼ)高速であることがわかります。これは、Rの多くのベクトル化された関数(Base Rのほとんどすべてのベクトル化された関数)がC、Cpp、またはFortranで記述されているため、多くの場合、得ることがほとんどないためです。

とはいえ、RRcppコードの両方で改善すべき点があります。最適化は、コードを注意深く検討し、不要なステップ(メモリの割り当て、合計など)を削除することから始まります。

Rcppコードの最適化から始めましょう。

あなたの場合、主な最適化は、不要な行列とベクトルの計算を削除することです。コードは本質的に

  1. シフトベータ
  2. exp(shift beta)の合計の対数を計算する[log-sum-exp]
  3. シフトされたベータのインデックスとしてObsを使用し、すべての確率を合計します
  4. log-sum-expを引く

この観察結果を使用して、コードを2つのforループに減らすことができます。これsumは単に別のforループ(多かれ少なかれfor(i = 0; i < max; i++){ sum += x }:)であるため、合計を回避すると、コードをさらに高速化できます(ほとんどの場合、これは不要な最適化です!)。さらに、入力Obsは整数ベクトルであり、IntegerVector型を使用してdouble要素をinteger値にキャストしないようにすることでコードをさらに最適化できます(Crlf to 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機能があり、まだ非常にわずか(0.3ミリ秒または4%)より速く私の最適化よりもCpp機能。ここでの私の最善の策は、Rcppパッケージからのオーバーヘッドがあり、これを削除した場合、2つは同一または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

もう一度結果は同じです。

結論:

短い結論として、これは1つの例であり、コードをRcppに変換することは本当に問題に値しないことは注目に値します。これは常に当てはまるわけではありませんが、多くの場合、不要な計算が実行されるコードの領域があるかどうかを確認するために、関数をもう一度確認する価値があります。特に組み込みのベクトル化された関数を使用する状況では、コードをRcppに変換する価値はありません。多くの場合for-loops、forループを削除するために簡単にベクトル化できないコードを使用すると、大きな改善が見られます。


1
一部のキャストの削除Obsとして扱うことができIntegerVectorます。
Ralf Stubner

回答でこれに気づいていただきありがとうございます。それは単に私を通り過ぎました。私の回答である@RalfStubnerでこれを評価しました。:-)
オリバー

2
このおもちゃの例(切片のみのmnlモデル)で気付いたように、線形予測子(beta)は観測値にわたって一定のままObsです。時間変動する予測子がある場合、計画行列の値に基づいて、denomそれぞれの暗黙的な計算がObs必要になりますX。そうは言っても、私はすでにあなたの提案を私のコードの残りの部分に実装しています。@ RalfStubner、@ Oliver、@ thcの非常に洞察に満ちた返信をありがとう!次のボトルネックに移りましょう!
smildiner

1
お役に立ててうれしいです。より一般的なケースでは、秒の各ステップでdenomを減算して計算for-loopすると、最大のゲインが得られます。また、より一般的なケースmodel.matrix(...)では、関数の入力用のマトリックスを作成するためにを使用することをお勧めします。
Oliver

9

C ++関数は、次の観察を使用してより高速にすることができます。少なくとも最初のものは、R関数でも使用できます。

  • 計算方法denom[i]はすべてので同じですi。したがって、aを使用しdouble denomてこの計算を1回だけ行うことは理にかなっています。最後に、この一般的な用語を差し引くことも考慮します。

  • 観測は実際には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関数よりもおよそ10倍高速です。


回答ありがとうございます。ラルフは入力タイプを見つけませんでした。私はこれを私の答えに組み込んだだけでなく、あなたに信用を与えました。:-)
オリバー

7

ラルフとオリバーの回答に対して、4つの潜在的な最適化を考えることができます。

(あなたは彼らの答えを受け入れるべきですが、私は私の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;
}
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.