C#で配列に対してforとforeachどっちが速いの?　～コンパイル結果を考察する～

# 謝辞

今回、このまとめを上梓するに当たり、多くの方からベンチマークデータの提供を受けました。

これらの提供がない限りこのまとめを書くことは出来なかったので、ここで心からお礼申し上げます。

# 何をしたのか

とっても簡単。

`.NET Core`上のC#で配列のループ処理に対して、for使うが速いかforeach使うが速いかどっちなの?と言う話。

御託は良いから、結果よこせって人は[こっからジャンプ](https://qiita.com/Tokeiya/items/ec591ffd29c9c7cfa76f)

# 今回考察していること

このセクションでは以下の考察を行った。

1. ベンチマークコードの提示
2. 上記コードのILの考察
3. そのILがJITでAssemblyになった結果の考察

また、[提供を受けた様々なCPUでベンチマークを実行した結果の提示を元に考察した。](https://qiita.com/Tokeiya/items/ec591ffd29c9c7cfa76f)

# ベンチマークコード

BenchmarkDotNetで以下のコードを書き、多くの方から、様々なCPUの結果を頂きました。



```csharp
using BenchmarkDotNet.Attributes;
using System.Linq;

namespace ArrayLoopSurvey
{
	[CsvMeasurementsExporter]
	[CsvExporter]
	[DisassemblyDiagnoser]
	public class IntArrayAccessBenchmark
	{
		private readonly int[] _array = Enumerable.Range(0, 900_000_000).ToArray();

		[Benchmark]
		public int IntSumFor()
		{
			int[] ary = _array;
			int accum = 0;
			for (int i = 0; i < ary.Length; i++) accum += ary[i];
			return accum;
		}


		[Benchmark]
		public int IntSumForeach()
		{
			int accum = 0;
			foreach (var i in _array) accum += i;
			return accum;
		}
	}
}

```



使ってる配列のサイズがバカみたいにでかいのは、BenchmarkDotNetでIterationを1にしたかったからだけど、正直やりすぎたと思うし、もしかしたらこれが擾乱要因担ったかも知れないと反省。



# ILだとどうなるか

`IntSumFor`メソッドをコンパイルした結果は以下の通り。

```
    .method public hidebysig 
        instance int32 IntSumFor () cil managed 
    {
        // Method begins at RVA 0x2050
        // Code size 31 (0x1f)
        .maxstack 3
        .locals init (
            [0] int32[],
            [1] int32,
            [2] int32
        )

        IL_0000: ldarg.0
        IL_0001: ldfld int32[] IntArrayAccessBenchmark::_array
        IL_0006: stloc.0
        IL_0007: ldc.i4.0
        IL_0008: stloc.1
        IL_0009: ldc.i4.0
        IL_000a: stloc.2
        // sequence point: hidden
        IL_000b: br.s IL_0017
        // loop start (head: IL_0017)
            IL_000d: ldloc.1
            IL_000e: ldloc.0
            IL_000f: ldloc.2
            IL_0010: ldelem.i4
            IL_0011: add
            IL_0012: stloc.1
            IL_0013: ldloc.2
            IL_0014: ldc.i4.1
            IL_0015: add
            IL_0016: stloc.2

            IL_0017: ldloc.2
            IL_0018: ldloc.0
            IL_0019: ldlen
            IL_001a: conv.i4
            IL_001b: blt.s IL_000d
        // end loop

        IL_001d: ldloc.1
        IL_001e: ret
    } // end of method IntArrayAccessBenchmark::IntSumFor
```



また、IntSumForeachは以下の通り。

```
   .method public hidebysig 
        instance int32 IntSumForeach () cil managed 
    {
        // Method begins at RVA 0x207c
        // Code size 33 (0x21)
        .maxstack 2
        .locals init (
            [0] int32,
            [1] int32[],
            [2] int32,
            [3] int32
        )

        IL_0000: ldc.i4.0
        IL_0001: stloc.0
        IL_0002: ldarg.0
        IL_0003: ldfld int32[] IntArrayAccessBenchmark::_array
        IL_0008: stloc.1
        IL_0009: ldc.i4.0
        IL_000a: stloc.2
        // sequence point: hidden
        IL_000b: br.s IL_0019
        // loop start (head: IL_0019)
            IL_000d: ldloc.1
            IL_000e: ldloc.2
            IL_000f: ldelem.i4
            IL_0010: stloc.3
            IL_0011: ldloc.0
            IL_0012: ldloc.3
            IL_0013: add
            IL_0014: stloc.0
            // sequence point: hidden
            IL_0015: ldloc.2
            IL_0016: ldc.i4.1
            IL_0017: add
            IL_0018: stloc.2

            IL_0019: ldloc.2
            IL_001a: ldloc.1
            IL_001b: ldlen
            IL_001c: conv.i4
            IL_001d: blt.s IL_000d
        // end loop

        IL_001f: ldloc.0
        IL_0020: ret
    } // end of method IntArrayAccessBenchmark::IntSumForeach
```



ここで注目すべきなのは、`IntSumForeach`の方が、`int32`型のローカル変数が多い。

これは、`foreach(var i in _array)`の`i`に相当していて、1度`i`に代入してからそれを`accum`に加算している形になる。

# x64ASMだとどうなるか

さて、ILを検証してきたけど、実際問題ILが直接解釈されて実行されるわけじゃなく、最終的にJITされた結果、x64アセンブリになる。

ここでは、JITされた実際の実行コードを検証してみたい。

`IntSumFor`は以下の通り。

```assembly
; ArrayLoopSurvey.IntArrayAccessBenchmark.IntSumFor()
       mov     rax,qword ptr [rcx+8]
       xor     edx,edx
       xor     ecx,ecx
       mov     r8d,dword ptr [rax+8]
       test    r8d,r8d
       jle     M00_L01
M00_L00:
       movsxd  r9,ecx
       add     edx,dword ptr [rax+r9*4+10h]
       inc     ecx
       cmp     r8d,ecx
       jg      M00_L00
M00_L01:
       mov     eax,edx
; Total bytes of code 34
```



また、`IntSumforeach`は以下の通り。

```assembly
; ArrayLoopSurvey.IntArrayAccessBenchmark.IntSumForeach()
       xor     eax,eax
       mov     rdx,qword ptr [rcx+8]
       xor     ecx,ecx
       mov     r8d,dword ptr [rdx+8]
       test    r8d,r8d
       jle     M00_L01
M00_L00:
       movsxd  r9,ecx
       mov     r9d,dword ptr [rdx+r9*4+10h]
       add     eax,r9d
       inc     ecx
       cmp     r8d,ecx
       jg      M00_L00
M00_L01:
       ret
; Total bytes of code 36
```



さて、ここで、気になるのは、先のILであった、`SumForeach`の`i`への代入が実際JITされたらどうなるのか?ってことだけど、実はしっかり残ってる。

`IntSumFor`を眺めると、`add     edx,dword ptr [rax+r9*4+10h]`で、直接配列の要素をedxレジスタに加算してる。

他方、`IntSumforeach`の場合、` mov     r9d,dword ptr [rdx+r9*4+10h]`と、r9dレジスタに1度配列の要素を代入した後、`add     eax,r9d`でeaxレジスタに加算している。



## Intermission : ecxがコピーされてる件

今回の考察には全く関係ないけど、`for`でも`foreach`でも、カウンタ相当のレジスタは`ecx`となってる。けれど、どちらの例でも、`movsxc (r8/r9),ecx`と他のレジスタへコピーされてから、コピー先の値を伴って配列の要素へのアクセスを行っている。

このようなことをなぜするのか、知人と議論したところ、1度コピーしてそれをアドレス指定に使うことで、続く、`inc ecx`と操作が独立となって、パイプラインにうまく収まるコトを企図しているのではないかという知見を得た。

従って、メモリへのアクセスはキャッシュにヒットするか否かで大きくアクセスコストが変化するので、レジスタ間のコピーコストを支払ってでも独立させておいた方が有利なのではなかろうかと考えた。

### 追記
このエントリを公開後、知人から、
ループカウンタは32bit、アドレスのインデックスアクセスは64bitなので、別管理にする必要があるのでは無いかと言う指摘を受けた。

指摘を受けた後、確かに`mov`では無く、`movsxd`になっているので[^1] 、先の推測よりこちらの理由の方が確度が高いかと考えた。


## Assemblyを素朴に眺めてわかること ～とりあえずのまとめに変えて～

さて、このように`IntSumforeach`の方が、レジスタへの代入とは言え、冗長な処理を行っている。それもこのベンチマークではその冗長な処理が900,000,000回も発生するので、`IntSumForeach`の方が遅く、`IntSumFor`の方が速いのではないかと考えた[^2]。

次回以降で、先の通り、実際に提供を受けた[ベンチマークデータを元に考察](https://qiita.com/Tokeiya/items/ec591ffd29c9c7cfa76f)していこうかと思います。




[^2]: そして、その考えはもろくも崩れ去るわけだけどw
[^1]: そりゃ、`ecx`は32bit転送先の`r~`は64bitなので、拡張転送は必須じゃある。