30
11

More than 5 years have passed since last update.

Kotlin の Range.forEach は遅いのか?

Last updated at Posted at 2017-08-27

少し前に、「Kotlinの隠れたコストについてのベンチマーク(原文: Kotlin's hidden costs - Benchmarks)」という記事が話題になりました。

その中で、Kotlin の Range を使った for ループと forEach() の比較があり、forEach() は「絶対に避けた方がよい(should absolutely be avoided)」と結論づけていました。この点と、それに対するはてぶなどの反応が気になりました。

そこで、今回は Range に対する for ループに対象を絞って調べてみました。

記事を見直してみる

元記事にはベンチマーク結果が書かれており、数値は以下のようになっています。

Benchmark                                                                             Mode   Samples         Mean   Mean error    Units
c.a.k.part3.KotlinBenchmarkPart3.kotlinRangeForEachFunction                          thrpt       200   108382.188      561.632   ops/ms
c.a.k.part3.KotlinBenchmarkPart3.kotlinRangeForEachLoop                              thrpt       200   331558.172      494.281   ops/ms
c.a.k.part3.KotlinBenchmarkPart3.kotlinRangeForEachLoopWithStep1                     thrpt       200   331250.339      545.200   ops/ms

確かに forEach を使用したベンチマーク (kotlinRangeForEachFunction) は他と比べ 3 倍かかっています。しかしこれだけで本当に「絶対に避けた方が良い」と言えるほどの差なのでしょうか。

結果を良くみてみましょう。単位は全て ops/ms とあります。これを元に「一回の処理に秒かかったか」を計算すると以下のようになります。

名称 内容 スコア
kotlinRangeForEachFunction forEach()呼び出し 9.227(ns/op)
kotlinRangeForEachLoop for … in ループ 3.016(ns/op)
kotlinRangeForEachLoopWithStep1 for … in … by ループ 3.019(ns/op)

ここで行なっている処理は 1..10 に対する処理です。つまりこの「3倍の差」というのは 10 回のループに 9 ナノ秒かかるか 3 ナノ秒かかるかの違いです。

ちなみに元のベンチマークでは Core i7 の MacBook Pro を使っているようですが、Android 7.1.2 上の Pixel によるベンチマークを試した方もいるようです。for ループが 170 ナノ秒程度なのに対し、forEach() を使った場合は約3倍の 500 ナノ秒程度かかるようです。

果たしてこの差はどれくらいの影響を与えるのでしょうか。

他の処理と比較してみる

ナノ秒レベルの数字を出されてもいまいち実感がないかもしれません。そこで少し処理を変えながら比較してみました。

なお計測対象のマシンは以下の通りです。

  • 機種 … Nexus5x
  • CPU … Qualcomm Snapdragon 808 1.8GHz (2コア)+ 1.4GHz (4コア)
  • メモリ … 2GB
  • OS … Android の 8.0.0

Kotlin の Range ループ

元のベンチマークでも行われている Range の for ループと forEach() 関数を使った処理を呼び出しました。

forEach() 呼び出し(t0_range_forEachFunction)

fun rangeForEachMethod(blackHole: BlackHole) {
    (1..10).forEach {
        blackHole.consume(it)
    }
}

for … in ループ(t0_range_forEachLoop)

fun rangeForEachLoop(blackHole: BlackHole) {
    for (it in 1..10) {
        blackHole.consume(it)
    }
}

結果

スクリーンショット 2017-08-27 19.49.28.png

やはり forEach() 呼び出しを使うと遅く、 6〜7 倍の時間がかかっています。しかし先ほどの Android ベンチマークと比べて 100 ナノ秒ほど速くなっています(これはプログラムを修正したためで、詳細は後述します)

ループの中で他の処理を行う

現実的には空の処理を行う事は無いでしょうから、他の処理を含めてみました。

1要素の Int 配列の生成 (t1_rangeForEachLoop_withArrayCreation)

fun rangeForEachLoopWithArrayCreation(blackHole: BlackHole) {
    for (it in 1..10) {
        blackHole.consumeArray(intArrayOf(it))
    }
}

Int? へのキャスト (t1_rangeForEachLoop_withBoxing)

fun rangeForEachLoopWithBoxing(blackHole: BlackHole) {
    for (it in 1..10) {
        blackHole.consume(it as Int?)
    }
}

Optional に変換すると、遅いと言われる Auto Boxing が行われます

大きな数字の Int? へのキャスト(t1_rangeForEachLoop_withBoxingLargeNum)

fun rangeForEachLoopWithBoxingLargeNum(blackHole: BlackHole) {
    for (it in 1001..1010) {
        blackHole.consume(it as Int?)
    }
}

Java では 127 までの int の Auto Boxing は最適化されています。それより大きな数は毎回 Integer が生成されるようになり、さらに遅くなります。

配列へのランダムなアクセス (t1_rangeForEachLoop_withRandomAccess)

fun rangeForEachLoopWithRandomAccess(blackHole: BlackHole, ra: RandomAccessor) {
    for (it in 1..10) {
        blackHole.consume(ra.next(it))
    }
}

ra.next() 呼び出しを行うと、配列の要素へのアクセスが1回だけ行われます。何番目にアクセスするかはランダムです。配列は100万要素と大きめになっていて、これによって CPU のキャッシュアクセスができないようになっています。

結果

スクリーンショット 2017-08-27 19.50.11.png

注目すべきはランダムアクセスにかかる時間でしょう。元の for ループよりも約 35 倍もの時間がかかっています。Auto Boxing や配列の生成と比べても何倍も時間がかかっています。

これは、メモリが CPU のキャッシュに乗ってない事が原因と言えるでしょう。一般にキャッシュに乗らない場合に 100 倍遅くなる事は珍しい事ではありません。それでも遅延時間は 1 回のアクセスにつき 200 ナノ秒程度である事は注目に値する所です。

Auto Boxing や配列の生成がある場合も forEach() と同等の時間がかかるようですので、forEach() の最適化の前にオブジェクトを生成していないかを確認した方が良いでしょう。

Java の for ループ

元のベンチマークには Java のループのベンチマークはありません。参考までに Java のループを追加し、他の処理(forEach()関数と Range に対する for ループ)と比較追加してみました。

結果

スクリーンショット 2017-08-27 19.51.00.png

多少小さくなっていますが、10回のループで数十ナノ秒程度の差であり、誤差の範囲です。

インライン展開

以上の処理には、ループ自体のコストの時間の他に、blackHole.consume(…) という呼び出しの時間が含まれています。この関数は、過剰な最適化を防ぐためのダミーの処理が書かれています(この処理が無いと空のループなどはループ自体が削除されてしまって計測できなくなります)。

このダミーの処理がどれくらいかかっているかを見るために、10回のループを展開して計測してみました。また、1回/2回/4回のループも追加しています。

結果

スクリーンショット 2017-08-27 19.51.26.png

かかった時間は for ループと大きく変わりません。つまり、今まで行ったベンチマークのうち、50ナノ秒程度は blackHole.consume(…) 呼び出しにかかっていると思われます。特に for ループを使った処理の大半はこの処理で占められている事が想定されます。

まとめ

例のベンチマークの記事が出て「forEach() は絶対に避けるべき」と書かれているのを見て「そんなわけあるか!」とつっこみたくなったのは僕だけではないでしょう。

Kotlin の Range を使った for ループは極めて速く、ボトルネックとなる事はまずないと言えるのではないでしょうか。また Kotlin の forEach() ループは相対的に遅いと言えますが、「ループ内の処理が極めてシンプル」でかつ「大量に呼び出される」場合を除き、その影響が顕在化する事は無いと思われます。

またこの種のマイクロベンチマークはもちろん有効ではありますが、鵜呑みにするのも危険でしょう。今回の例だと、計測対象とは関係のない blackHole.consume(…) という処理がベンチマーク結果に大きな影響を与えていました。(なお元の Android 向けのベンチマークでは blackHole が不正確な結果をもたらしているように思われたので、大きく修正しています。今回のベンチマーク結果が多少速くなっているのはそのためです)

ちなみに、今回のベンチマークにしようしたプログラムは以下に公開しています。

最後に、多少の演算処理や関数呼び出しよりもメモリアクセスの方がはるかに時間がかかると言えると思います。ちなみに少し前に参考になるツイートや資料が話題になりましたので、リンクを貼っておきます。

ソースと思われる資料(英語)
https://gist.github.com/jboner/2841832

30
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
11