配列のアクセス方法による速さの違い

# はじめに
C#でマネージドオブジェクトとして配列にアクセスするときと、アンマネージドオブジェクトとして配列にポインタでアクセスするときの速さを比べました。
ここでは書き込みしか計測していないので、読み込みの場合は違った結果になるかもしれません。

# 結論
算術複合代入演算子でポインタを更新してアクセスする方法が最速でした。
また、インデクサー演算子を使った普通の配列へのアクセスが、ポインター要素アクセス演算子を使ったポインタによる配列へのアクセスよりも速くなることがあります。

# 環境
Windows 10 Home, AMD Ryzen 5 3500 6-Core Processor(3.59 GHz), 16GB Memory, Visual Studio 2022, .NET 6.0

# 計測コード

```c#
private static readonly int Count = 1000; // 関数の実行を繰り返す回数
private static readonly int Size = 100000; // 配列の大きさ

private static int[] VSA { get; } = new int[Size]; // マネージド配列
private static IntPtr VSPH = Marshal.AllocHGlobal(sizeof(int) * Size); // アンマネージド配列
private static int* VSP { get; } = (int*)VSPH.ToPointer(); // アンマネージド配列のポインタ

// 普通に配列にアクセスする方法
private static void Array()
{
    int[] a = VSA;
    for (int i = 0; i < Size; i++)
    {
        a[i] = i;
    }
}

// ポインター要素アクセス演算子でアクセスする方法
private static void Pointer1()
{
    int* p = VSP;
    for (int i = 0; i < Size; i++)
    {
        p[i] = i;
    }
}

// ポインタで、算術演算子で目的のアドレスに移動する方法
private static void Pointer2()
{
    int* p = VSP;
    for (int i = 0; i < Size; i++)
    {
        *(p + i) = i;
    }
}

// ポインタをインクリメント演算子で更新する方法
private static void Pointer3()
{
    int* p = VSP;
    for (int i = 0; i < Size; i++)
    {
        *p = i;
        p++;
    }
}

// ポインタを算術複合代入演算子で更新する方法
private static void Pointer4()
{
    int* p = VSP;
    for (int i = 0; i < Size; i++)
    {
        *p = i;
        p += 1;
    }
}

// エントリポイント
public static void Entry()
{
    Console.WriteLine($"Array:   \t{Program.Measure(Count, Array)}");
    Console.WriteLine($"Pointer1:\t{Program.Measure(Count, Pointer1)}");
    Console.WriteLine($"Pointer2:\t{Program.Measure(Count, Pointer2)}");
    Console.WriteLine($"Pointer3:\t{Program.Measure(Count, Pointer3)}");
    Console.WriteLine($"Pointer4:\t{Program.Measure(Count, Pointer4)}");

    Marshal.FreeHGlobal(VSPH);
}

// 関数の処理にかかる時間を計測する
public static TimeSpan Measure(int count, Action action)
{
    DateTime time = DateTime.Now;
    for (int i = 0; i < count; i++)
    {
        action();
    }
    return DateTime.Now - time;
}
```

# 結果
CountとSizeを以下のように変えて計測しました。単位はミリ秒です。

## Count = 10, Size = 10000000

| 方法 | １回目 | ２回目 | ３回目 | 平均 |
|:---------|---------:|---------:|---------:|---------:|
| Array    | 313.9766 | 327.8773 | 307.7491 | 316.5343 |
| Pointer1 | 366.2786 | 432.1988 | 385.5325 | 394.6699 |
| Pointer2 | 317.5178 | 321.1072 | 334.8939 | 324.5063 |
| Pointer3 | 282.9098 | 269.9905 | 258.7676 | 270.5559 |
| Pointer4 | 243.4862 | 245.5820 | 243.0119 | 244.0267 |

## Count = 1000, Size = 100000

| 方法 | １回目 | ２回目 | ３回目 | 平均 |
|:---------|---------:|---------:|---------:|---------:|
| Array    | 327.2435 | 292.3494 | 276.8912 | 298.8280 |
| Pointer1 | 321.7174 | 427.6671 | 351.0425 | 366.8090 |
| Pointer2 | 330.6075 | 327.0324 | 329.7217 | 329.1205 |
| Pointer3 | 248.2612 | 266.3699 | 265.2462 | 259.9591 |
| Pointer4 | 240.4266 | 242.9126 | 242.5092 | 241.9494 |

## Count = 100000, Size = 1000

| 方法 | １回目 | ２回目 | ３回目 | 平均 |
|:---------|---------:|---------:|---------:|---------:|
| Array    | 314.5075 | 279.0266 | 275.8633 | 289.7991 |
| Pointer1 | 371.4749 | 414.7517 | 335.8634 | 374.0300 |
| Pointer2 | 334.1162 | 308.4300 | 320.1084 | 320.8848 |
| Pointer3 | 267.7154 | 313.6996 | 289.6043 | 290.3397 |
| Pointer4 | 243.0017 | 245.0434 | 249.3512 | 245.7987 |

## Count = 10000000, Size = 10

| 方法 | １回目 | ２回目 | ３回目 | 平均 |
|:---------|---------:|---------:|---------:|---------:|
| Array    | 355.4642 | 279.7824 | 279.7824 | 305.0096 |
| Pointer1 | 356.0908 | 385.8575 | 385.8575 | 375.9352 |
| Pointer2 | 348.3576 | 319.1117 | 319.1117 | 328.8603 |
| Pointer3 | 247.0751 | 265.7220 | 265.7220 | 259.5063 |
| Pointer4 | 245.3749 | 245.7401 | 245.7401 | 245.6183 |

Pointer4は安定して圧倒的に速いです。

# おわりに

私としてはこの結果は意外です。
Pointer3, 4はループ毎にポインタを更新しており、アドレスに整数値を加算するという処理に加えてポインタを書き換えるという処理を行うと思うのでPointer1, 2よりも遅いと思っていました。
Pointer1, 3はそれぞれPointer2, 4のシンタックスシュガーで、内部的には同じ処理だと思ってたので速さが違うとは思いませんでした。
ポインタを使うよりも普通に配列を使った方が速いことがあるのも驚きです。

何事もやってみないとわかりませんね。