Kotlin の Range.forEach は遅いのか?

少し前に、「[Kotlinの隠れたコストについてのベンチマーク](http://postd.cc/kotlinshiddencosts-benchmarks/)(原文: [Kotlin's hidden costs - Benchmarks](https://sites.google.com/a/athaydes.com/renato-athaydes/posts/kotlinshiddencosts-benchmarks))」という記事が話題になりました。

その中で、Kotlin の `Range` を使った for ループと `forEach()` の比較があり、`forEach()` は「絶対に避けた方がよい(should absolutely be avoided)」と結論づけていました。この点と、それに対する[はてぶ](http://b.hatena.ne.jp/entry/postd.cc/kotlinshiddencosts-benchmarks/)などの反応が気になりました。

そこで、今回は `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 によるベンチマーク](https://willowtreeapps.com/ideas/kotlins-hidden-costs-android-benchmarks)を試した方もいるようです。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`)**

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

**for　… in ループ(`t0_range_forEachLoop`)**

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


### 結果

<img width="458" alt="スクリーンショット 2017-08-27 19.49.28.png" src="https://qiita-image-store.s3.amazonaws.com/0/122842/49a37668-cce6-acdc-ef2d-1d60d65e17ce.png">


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


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

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

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

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

**`Int?` へのキャスト (`t1_rangeForEachLoop_withBoxing`)**

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

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

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

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

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

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

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

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


### 結果

<img width="515" alt="スクリーンショット 2017-08-27 19.50.11.png" src="https://qiita-image-store.s3.amazonaws.com/0/122842/7e1a0559-5a92-082c-72b5-6e86128ffebf.png">


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

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

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


Java の for ループ
----------------

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

### 結果

<img width="449" alt="スクリーンショット 2017-08-27 19.51.00.png" src="https://qiita-image-store.s3.amazonaws.com/0/122842/01c945a7-55be-8e37-3556-6e5db7406c39.png">


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


インライン展開
----------------

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

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

### 結果

<img width="449" alt="スクリーンショット 2017-08-27 19.51.26.png" src="https://qiita-image-store.s3.amazonaws.com/0/122842/4b21ae46-9780-ed85-4eb9-325b1e4ce6a7.png">


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


まとめ
----------------

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

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

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

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

https://github.com/wm3/kotlin-hidden-costs-benchmark

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

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">この表いいな。HDDが遅いことがよくわかる(大陸またぐのと一桁しか違わない)。<a href="https://t.co/w5W0bLTyge">https://t.co/w5W0bLTyge</a> <a href="https://t.co/e2TccGVp7q">pic.twitter.com/e2TccGVp7q</a></p>&mdash; TT@北海道 (@edy555) <a href="https://twitter.com/edy555/status/896136253925580800">2017年8月11日</a></blockquote>
<script async src="//platform.twitter.com/widgets.js" charset="utf-8"></script>

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