戯言日記

Rの話だと思ったら唐突にサバゲーが混じってくる何か。

data.tableにfurrr::future_map()を入れるとむしろ速度が低下するっぽい

data.tableに対する処理の一部が妙に重くて困ってたのだが、その原因を調べてたらfurrr::future_map()との食い合わせが最悪だったってことが分かったのでメモ。


data.tableとは

data.frameを発展させたデータ形式で、高速かつメモリ効率の良さが売り。

cran.r-project.org

このdata.table形式で解析をしている場合、こういう書き方がよく出てくる(例としてirisで書いてみる)。

#花弁サイズの単位を cm → mm に変更
data.table(iris)[, lapply(.SD, function(x) x*10), ,
                 .SDcols=Petal.Width:Petal.Length]


この時のlapply()について、無名関数1を使う場合はmap()に置き換えている人も多い。

data.table(iris)[, lapply(.SD, ~.x*10), ,
                 .SDcols=Petal.Width:Petal.Length]


furrr::future_map()とは

tidyverseユーザーにはお馴染みのpurrr::map()系の関数について、置き換えるだけでお手軽に並列処理を実装できる関数。
様々なmap()系の関数に対応していて、しかも「future_」を頭につけるだけで使えるので非常に便利。

cran.r-project.org


何が問題だったの?

「大きめのデータの解析をするつもりで」「並列処理を入れたかった」ので、こんな感じの処理を書いていた(本来はもう少し複雑)。

data.table(iris)[, future_map(.SD, ~.*10), ,
                 .SDcols=Petal.Width:Petal.Length]


が、どうにも処理が重い。というか時間がかなりかかる。

処理に条件分岐とかを入れていたので、その辺がおかしいのだろうと勘違いしてずっと調べていたのだが、future_map()で書いていたところをmap()と書き間違えた時にいきなり爆速になって気が付いた


とりあえずベンチマークを取る

上で書いた処理3種類について、microbenchmarkパッケージで処理速度を比較してみる。

library(microbenchmark)

#処理は上で書いていたものをそのまま使用
microbenchmark(
  "lapply" = data.table(iris)[, lapply(.SD, function(x) x*10), ,
                              .SDcols=Petal.Width:Petal.Length],
  "map" = data.table(iris)[, map(.SD, ~.*10), ,
                           .SDcols=Petal.Width:Petal.Length],
  "future_map" = data.table(iris)[, future_map(.SD, ~.*10), ,
                                  .SDcols=Petal.Width:Petal.Length],
  times = 100) -> res
res %>% autoplot()
res %>% print()


結果:

f:id:doubtpad:20210514005224p:plain

## Unit: microseconds
##        expr    min       lq      mean   median       uq     max neval
##      lapply  750.3   848.80  1528.067  1110.35  1500.30 13169.6   100
##         map  792.3   879.75  1210.073   992.10  1264.75  4440.0   100
##  future_map 9952.7 12710.25 15790.534 14612.25 17863.30 56270.4   100

lapply()とmap()は同程度(若干map()の方が有利)なので好みで選んで良さそうだが、future_map()を使うと中央値で約15倍、平均でも10倍くらい遅い。
これだけ遅ければ、処理のボトルネックになるに決まってる。

原因

今さら思い出してみると「fread()は最初から並列化されてる」とかって話を見かけた記憶があったので、もしかしてdata.tableって全体的にそういう感じになってる……?

という訳で、公式ドキュメントやら何やら調べてみたところ、下記の資料が分かりやすかった。

www.john-ros.com

「16.4.2 Parallel Data Munging with data.table」から引用。

We now recall it to emphasize that various operations in data.table are done in parallel, using OpenMP. 意訳:もっかい言うけど2、data.tableはOpenMPを使って並列処理してるよ。

やはり内部で並列化していた。

というか読んでて気が付いたのだが、パッケージの関数にgetDTthreads()とsetDTthreads()という、そのものずばりな関数が入っていた。
やはりパッケージの関数は全部確認するべきだわ(遠い目)。

ちなみに、getDTthreads()を使うと現状のスレッド数の確認、setDTthreads()ではスレッド数の設定が可能。
ただし、スレッド数を設定可能な最大数にするとむしろ速度が低下する(上の資料でもそんな話が出ている)。
どうしても自力で設定しないといけない場合は最大スレッド数の半分とかにしておけば問題なさそうだけど、自分みたいな詳しくない人間は関数に任せた方が安心。

もう少し詳しい話が知りたい場合はこの辺も参考になりそう。
なお自分は斜め読みしか出来てないので今度読み直す。

github.com


たぶん今回の問題は、並列化している処理の内部で、さらに並列化しようとしたことで訳の分からない状態になっていたんじゃなかろうかと思う。
foreachとかでも同じようなことをしたら(試してはいないけど)遅くなる気がするので、data.tableの時はそのまま素直に処理を書いた方が楽で良さそう。

Enjoy!!


  1. 「~abs(fft(.x))2」みたいに書くアレ。Rを使い始めて日が浅いと、使い方を調べるのに名前が分からなくて意外と手間取ると思うの自分だけだろうか。

  2. なお、ここより前の本文中でOpenMPに関する記述はない(16.3.2でOpenMPI関連の話をしているので、それを指しているのかも)。