Rでfor文は使うべきでないか? (R Advent Calendar 2011)
この記事はR Advent Calendar 2011 (http://atnd.org/events/22039)への参加記事です。
Rではよく「for文は使うべきでない」と言われます。forではなく、ベクトル単位での処理として記述したり、lapplyやapplyなどのベクトル・行列に対するmap系関数を使うべきとされています。しかし実際には、必ずしもいつもfor文を避ければ良いというものでも無いようです。今回はそのあたり、for文がどのくらい遅いのか(または遅くないのか)を調べてみたいと思います。
lapply関数
通常、for文と対応関係にあるのはlapply関数です。lapplyは
lapply(v,func)
という形で用いられ、その機能は
- 第一引数のベクトルvの各要素にfuncを適用する
- 各要素への適用結果をリストにまとめて返す
というものです。
for文で書ける処理はlapplyでも書くことができ、以下のようになります。
for(i in 1:n){ func(i) #なんらかの処理 } lapply(1:n,func)
以下ではforとlapplyの速度が、使用ケースによってどのように異なるかを見ていきます。
もったいぶってもあれですので、ネタバレすると次のような構成で話をします。
- 何もしないforループとlapplyの関係
- forループとは異なり、lapplyは「何もしない」場合でも関数呼び出しが必要であり、その分forよりも大分遅くなります。
- forにも同等の関数呼び出しが含まれる場合は、lapplyの方がわずかに速くなります。
- リストを返すループ
- lapplyではループ処理を行った結果をリストに格納して返します。結果を捨てるようなforループと比較するとlapplyは遅くなります。
- 一方、for文で同様のリスト生成を行おうとするとlapplyに比べ圧倒的に低速となります。
- ベクトル演算とforループ
- R組み込みのベクトル関数の処理をforループで実現しようとすると圧倒的に低速となります。これは、lapplyの場合と同じく、処理がどこまでC言語実装に含まれているかの問題です。
結論として、「可能な限りC言語実装の関数を使うべき」であり、「無駄な処理の含まれるC言語実装関数は無理に使うべきでない」となります。
テスト関数
テストのため以下のような関数を用意しています。テスト対象の関数funcとテストケースcasesを渡すことで処理時間を測定します。
tester <- function(func,cases){ results <- c() results.names <- c("user","system","elapsed") for(case in cases){ result <- system.time(func(case)) results <- c(results,result[1:3]) } results <- matrix(results,ncol=3,byrow=T) colnames(results) <- results.names return(results) }
何もしないforループとlapply
まずは何もしないforとlapplyについて比較してみます。
cases <- list(1:10^7,1:(2*10^7),1:(3*10^7)) test.lfor.null <- function(v){ for(i in v){} } empty.func <- function(x){} test.lapply.empty <- function(v){ lapply(v,empty.func) }
テスト結果は次のようになります。
> test.lfor.null.result user system elapsed [1,] 0.095 0 0.095 [2,] 0.188 0 0.189 [3,] 0.284 0 0.284 > test.lapply.empty.result user system elapsed [1,] 0.371 0.004 0.375 [2,] 0.740 0.006 0.746 [3,] 1.118 0.003 1.121
for文の方が4倍程度速いという結果になりました。これはlapplyでは処理を関数に包む必要があり、必ず関数呼び出しのコストがかかるためです。実際、for文の方にも関数呼び出しのコストを乗せると
test.lfor.empty <- function(v){ for(i in v){ empty.func(i) } } > test.lfor.empty.result user system elapsed [1,] 0.500 0.002 0.502 [2,] 0.984 0.007 0.992 [3,] 1.470 0.007 1.478
となり、lapplyの方が1.3倍程度速くなります。
従って
- 関数に包む必要のない処理についてのループでは、for文を使う方が良い
と言えそうです。
何かするforループとlapply
次に、ループ内で何らかの処理をする場合の速度を比較してみます。
ここでは、単純に足し算を行うだけの処理とします。
cases <- list(1:10^7,1:(2*10^7),1:(3*10^7)) plus1.func <- function(x){1+1} test.lfor.plus1 <- function(v){ for(i in v){ plus1.func(i) } } test.lapply.plus1 <- function(v){ lapply(v,plus1.func) } > test.lfor.plus1.result user system elapsed [1,] 8.806 0.416 9.224 [2,] 15.874 0.017 15.898 [3,] 24.569 0.251 24.829 > test.lapply.plus1.result user system elapsed [1,] 13.626 0.204 13.835 [2,] 30.235 0.519 30.765 [3,] 40.767 3.523 55.877
今度はlapplyの方がかなり遅くなりました。これはlapplyの「結果をリストとして返す」機能のせいだと考えられます。forでは足し算の結果をその都度捨てていますが、lapplyでは逐一リストに追加し、結果として返す操作が加わっています。
for文を使って明示的にリストを生成してみるとどうなるかというと
cases.small <- list(1:10^4,1:(2*10^4),1:(3*10^4)) test.lfor.plus1.list <- function(v){ result <- list() for(i in v){ result <- c(result,1+1) } return(result) } > test.lfor.plus1.list.result user system elapsed [1,] 2.678 0.090 2.774 [2,] 11.673 0.837 12.558 [3,] 25.148 1.882 27.042
これまで使っていたcasesでは時間がかかりすぎるため、小さいケースで実行しました。ループ数が3桁小さいにも関わらず、lapplyの場合の半分程度の時間がかかっています。すなわち、lapplyで行ったリスト生成と、Rで明示的に記述したリスト生成とでは数百倍程度の速度差があるということが分かりました。
lapplyはRの組み込み関数でありその実装はC言語です。リストの生成操作もC内部で行われており、この部分がpure Rでのリストを返す関数との決定的な速度差を生んでいると考えられます。
- リストを生成するような処理は可能な限りlapplyで行うべき
と言えます。
ベクトル演算とforループ
最後に、より基本的なテクニックとしてベクトル演算とforループの比較をしてみます。
vs <- list(1:10^7,1:(2*10^7),1:(3*10^7)) test.vfor <- function(v){ for(i in v){ i + 1 } } test.vector <- function(v){ v + 1 } > test.vfor.result user system elapsed [1,] 4.391 0.050 4.441 [2,] 8.698 0.030 8.727 [3,] 12.863 0.015 12.879 > test.vector.result user system elapsed [1,] 0.032 0.000 0.032 [2,] 0.079 0.043 0.122 [3,] 0.117 0.065 0.182
これは(当然ながら)組み込みのベクトル演算を利用した方が圧倒的に高速です。lapplyの場合と同じように、ループ処理がC言語実装の内部に含まれているためです。
結論
以上の比較から、「Rではfor文を使うべきではない」という説については
- ループ内の処理を関数に包む必要がない場合はfor文の方が良い
- リスト生成の必要がないループについてはfor文の方が良い
- リスト生成、ベクトル演算など、組み込みのループ処理を利用できる場合はそれを用いた方が良い
と言えるだろうと考えられます。結局いかにCで実装された関数を利用できるかという問題ですが、C実装の関数も完璧ではなく、場合によっては不要な処理が含まれていたり、データ構造を上手く処理できない場合などもあります。基本的な特性を押さえつつ、ケースバイケースで使い分ける必要があると言えそうです。
多次元の場合
(Rで多次元ループを書く機会はそれほど多くない気もしますが)多次元の場合も基本的には同じで、いかに組み込みのC実装関数を使うかがポイントとなります。詳しくは下記の参考文献(Chapter 14)を参照して頂きたいのですが、特に注意すべき点として
- applyはC実装ではなくR上での実装である
という点があります。行列型に整形したデータについてapplyを使えば自動的に速くなるというわけではなく、いかにベクトル演算を使うかという点に注意してコーディングする必要があります。
追記
for文でlistを生成するところについて、yatsutaさんからコメントを頂きました。forの方ではループコストの差異に加えlistの領域を確保するコストが上乗せされている、というものです。これを考慮するには、forで逐次的にリストを大きくしていくのではなく、予め必要なサイズを確保しておいた上で要素のセットにforループを使う必要があります。
実行してみると下記のようになります。
vs <- list(1:10^7,1:(2*10^7),1:(3*10^7)) test.lfor.plus1.list2 <- function(v){ result <- vector("list",length(v)) for(i in v){ result[[i]] <- 1+1 } } > test.lfor.plus1.list2.result user system elapsed [1,] 37.215 0.287 37.503 [2,] 61.647 1.017 63.459 [3,] 90.967 3.785 102.298
元のサイズのテストケースで実行することができ、速度はlapplyの場合の1/2程度となりました。逐次的にlistを拡大していったケースのコストは、ほぼ全てがlistのための領域確保コストであったことになります。
領域確保コストを除いてもforの方が2倍程度遅いということになりましたが、この原因は「ループそのもの」と「代入」による速度差を合わせたものとなります。これらを個別に見るのは、ループまたは代入のみをC実装で行うような関数が(おそらく)Rには無いため、中々難しいかと思います。とはいえ、両方合わせてこのくらいの差があると把握しておけば実用上は十分そうです。
参考文献
プログラミング的な側面からRを解説した資料はあまり多くありませんが、最近出た以下の本は、おそらく本としては最もこの手の話題について詳しいのではないかと思います。Rコードの書き方と速度の関係についても1章割かれています。
The Art of R Programming: A Tour of Statistical Software Design
- 作者: Norman Matloff
- 出版社/メーカー: No Starch Press
- 発売日: 2011/10/11
- メディア: ペーパーバック
- 購入: 2人 クリック: 62回
- この商品を含むブログを見る