😽

C# メソッドの戻り値にする変数は最初に宣言する

2023/05/09に公開

はじめに

C# で戻り値にする変数を最初に宣言することで、メソッドのパフォーマンスを改善することができます。
しかし、この差はごくわずかであり、通常は無視しても問題ありません。

最初の変数の宣言を入れ替えた 2 つのメソッドを用意します。

public static int SumA(int[] array)
{
    int i = 0;
    int sum = 0;

    for (; i < array.Length; i++)
    {
        sum += array[i];
    }

    return sum;
}

public static int SumB(int[] array)
{
    int sum = 0;
    int i = 0;

    for (; i < array.Length; i++)
    {
        sum += array[i];
    }

    return sum;
}

この例だとループ変数 ifor ステートメントの外側にすることはないと思いますが、他にいい例が思いつかなかったのでご容赦ください。

SumA が戻り値を最初に宣言しないパターン、 SumB が戻り値を最初に宣言するパターンです。

これらを SharpLab を使ってアセンブリーコードを出力します。

SumA(Int32[])
    ; int i;
    L0000: xor eax, eax
    ; int sum;
    L0002: xor edx, edx
    L0004: mov r8d, [rcx+8]
    L0008: test r8d, r8d
    L000b: jle short L001c
    L000d: mov r9d, eax
    L0010: add edx, [rcx+r9*4+0x10]
    L0015: inc eax
    L0017: cmp r8d, eax
    L001a: jg short L000d
    ; ここがよくない。
    L001c: mov eax, edx
    L001e: ret

SumB(Int32[])
    ; int sum;
    L0000: xor eax, eax
    ; int i;
    L0002: xor edx, edx
    L0004: mov r8d, [rcx+8]
    L0008: test r8d, r8d
    L000b: jle short L001c
    L000d: mov r9d, edx
    L0010: add eax, [rcx+r9*4+0x10]
    L0015: inc edx
    L0017: cmp r8d, edx
    L001a: jg short L000d
    ; mov 命令がない。
    L001c: ret

SumA のループ変数 ieax レジスター に割り当てられてられているので、リターンの直前(L001c のところ)で sum (edx レジスター)eax レジスター に移動する必要があります。

対して SumBsumeax に割り当てられるため、リターンの直前で eax を移動する必要がありません。

つまり、戻り値にする変数が最初に宣言されていると、mov 命令が 1 つ削減されることがわかります。

ただし、mov 命令は非常に高速(レイテンシー 0.5)で、命令が存在はするので差がまったくないとは言えませんが、体感できるようなことはないと考えられます。(ピコ秒オーダーの世界)

コンパイラーがなぜ戻り値を rax レジスター に割り当てる最適化ができないのかご存じの方はコメントください。

ベンチマーク

BenchmarkDotNet を使用してベンチマークを行いました。

Method Length Mean Error StdDev Ratio RatioSD Code Size
SumA 0 0.4977 ns 0.0025 ns 0.0022 ns 1.00 0.00 41 B
SumB 0 0.5031 ns 0.0027 ns 0.0025 ns 1.01 0.01 39 B
SumA 1 0.4032 ns 0.0008 ns 0.0007 ns 1.00 0.00 41 B
SumB 1 0.2480 ns 0.0004 ns 0.0004 ns 0.62 0.00 39 B
SumA 2 0.4989 ns 0.0051 ns 0.0045 ns 1.00 0.00 41 B
SumB 2 0.4990 ns 0.0029 ns 0.0026 ns 1.00 0.01 39 B
SumA 3 0.7568 ns 0.0048 ns 0.0043 ns 1.00 0.00 41 B
SumB 3 0.7549 ns 0.0099 ns 0.0088 ns 1.00 0.01 39 B
SumA 4 1.0647 ns 0.0426 ns 0.0399 ns 1.00 0.00 41 B
SumB 4 1.0280 ns 0.0139 ns 0.0130 ns 0.97 0.04 39 B
SumA 5 1.3225 ns 0.0160 ns 0.0149 ns 1.00 0.00 41 B
SumB 5 1.3357 ns 0.0119 ns 0.0111 ns 1.01 0.02 39 B
SumA 6 1.8165 ns 0.0205 ns 0.0171 ns 1.00 0.00 41 B
SumB 6 1.8034 ns 0.0056 ns 0.0053 ns 0.99 0.01 39 B
SumA 7 2.1148 ns 0.0055 ns 0.0049 ns 1.00 0.00 41 B
SumB 7 2.2605 ns 0.0274 ns 0.0256 ns 1.07 0.01 39 B
SumA 8 2.4482 ns 0.0439 ns 0.0411 ns 1.00 0.00 41 B
SumB 8 2.3292 ns 0.0207 ns 0.0193 ns 0.95 0.02 39 B
SumA 9 3.0970 ns 0.0040 ns 0.0038 ns 1.00 0.00 41 B
SumB 9 2.9088 ns 0.0249 ns 0.0233 ns 0.94 0.01 39 B
SumA 10 2.8354 ns 0.0204 ns 0.0191 ns 1.00 0.00 41 B
SumB 10 2.9069 ns 0.0486 ns 0.0431 ns 1.03 0.02 39 B
SumA 20 5.9170 ns 0.1484 ns 0.1876 ns 1.00 0.00 41 B
SumB 20 5.6792 ns 0.0694 ns 0.0649 ns 0.97 0.03 39 B

なぜか配列の要素数が 1 のときだけ SumB の方がだいぶ速くなっています。複数回実行しても比率はだいたい同じです。
BenchmarkDotNet が出力するアセンブリーコードも上記のものと同様なので、なぜこれほどの時間差があるのかさっぱりわかりません。

それ以外の要素数については、要素数によって SumA が速かったり SumB が速かったりと、安定していません。
またベンチマークし直すと結果が入れ替わったりします。

mov 命令の有無以外の別の要因が影響しているのかなと思っています。
そのためこの結果からだけだと mov 命令の有無がパフォーマンスにどう影響しているか結論付けられないと思います。

条件分岐が入っているので CPU の分岐予測が常に同じ結果になっていないのではと予想しています。

ソースコード

https://github.com/k-taro56/MovInstructionBenchmark

GitHubで編集を提案

Discussion