data.tableにfurrr::future_map()を入れるとむしろ速度が低下するっぽい
data.tableに対する処理の一部が妙に重くて困ってたのだが、その原因を調べてたらfurrr::future_map()との食い合わせが最悪だったってことが分かったのでメモ。
data.tableとは
data.frameを発展させたデータ形式で、高速かつメモリ効率の良さが売り。
この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_」を頭につけるだけで使えるので非常に便利。
何が問題だったの?
「大きめのデータの解析をするつもりで」「並列処理を入れたかった」ので、こんな感じの処理を書いていた(本来はもう少し複雑)。
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()
結果:
## 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って全体的にそういう感じになってる……?
という訳で、公式ドキュメントやら何やら調べてみたところ、下記の資料が分かりやすかった。
「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()ではスレッド数の設定が可能。
ただし、スレッド数を設定可能な最大数にするとむしろ速度が低下する(上の資料でもそんな話が出ている)。
どうしても自力で設定しないといけない場合は最大スレッド数の半分とかにしておけば問題なさそうだけど、自分みたいな詳しくない人間は関数に任せた方が安心。
もう少し詳しい話が知りたい場合はこの辺も参考になりそう。
なお自分は斜め読みしか出来てないので今度読み直す。
たぶん今回の問題は、並列化している処理の内部で、さらに並列化しようとしたことで訳の分からない状態になっていたんじゃなかろうかと思う。
foreachとかでも同じようなことをしたら(試してはいないけど)遅くなる気がするので、data.tableの時はそのまま素直に処理を書いた方が楽で良さそう。
Enjoy!!