ネコのために鐘は鳴る

寺院に住み着くパソコ〇好き

(C#) 3種類の定数と JIT コンパイル時最適化

この記事は「C# Advent Calendar 2020」の24日目の記事です。

3種類の定数と分岐削除

C# には3種類の定数があります。とはいえ、実際に C#ソースコードとして目に見えるのは1種類だけでconstとして書くものです。それぞれ順に見ていきましょう。

C#定数 (const)

最も一般的な定数はconstキーワードで定義するものです。

void Hoge()
{
    const int a = 5;
    if(a == 5)
        Console.WriteLine("a is 5");
    else
        Console.WriteLine("a is not 5");
}

constC# の静的なソースコードの状態で定数であることを表現します。上の例ではconstは変数として定義されますが実際にはコンパイルすると変数はなくなり、aリテラル5が直接埋め込まれます。静的に5であることをコンパイラが認識できるため、上記のifの分岐はなくなり、Console.WriteLine("a is 5");だけが残ります。

IL 定数

次に IL 定数です。const キーワードをつけることはできませんが、コンパイル時に定数であることが確定しているものです。

string Foo()
{
    const string a = nameof(Foo);
    string b = $"{a}{a}";  // not 'const' but IL const
    return b;
}

一番わかりやすい IL 定数は string interpolation ($文字列) です。まずnameof演算子は右辺値constですので、上記のaC#定数です。そしてbconstではありません。(constにするとコンパイルエラー)

しかし、bコンパイル時に"FooFoo"であることが確定しています。したがって、コンパイル後の IL を見ると、直接定数で"FooFoo"が埋め込まれていることが確認できます。コンパイル後の IL は以下の通りです。

// IL
.method private hidebysig instance string Foo () cil managed 
{
    .maxstack 8
    IL_0000: ldstr "FooFoo"
    IL_0005: ret
}

みごとに string interpolation が消えて"FooFoo"が IL 内で定数となっているのがわかります。

では、C# 定数の時と同じように分岐が消えるのかどうか確認してみましょう。以下のコードを見てください。

int Bar()
{
    return $"{nameof(Bar)}{nameof(Bar)}" == "BarBar" ? 1 : 0;
}

このメソッドは必ず1を返すことは見て分かるかと思います。 ではこれをコンパイルした IL を見てましょう。

// IL
.method private hidebysig instance int32 Bar () cil managed
{
    .maxstack 8
    IL_0000: ldstr "BarBar"
    IL_0005: ldstr "BarBar"
    IL_000a: call bool [System.Private.CoreLib]System.String::op_Equality(string, string)
    IL_000f: brtrue.s IL_0013    // if 分岐

    IL_0011: ldc.i4.0
    IL_0012: ret

    IL_0013: ldc.i4.1
    IL_0014: ret
    }

あれ……?分岐が消えていませんね。上記の IL_000f のbrtrue.s命令が C#ifに相当します。一応 JIT コンパイル結果の x86-64 アセンブリまで見ておきましょう。

アセンブリを見てスラスラ理解できる宇宙人の方は良いですが、人間には厳しいので隣に高級言語的な疑似コードをつけておきました。

; x86-64 asm (on 64 bits)
Bar()
    L0000: sub rsp, 0x28
    L0004: mov rcx, 0x287a79f8938
    L000e: mov rdx, [rcx]
    L0011: mov rcx, rdx
    L0014: call System.String.Equals(System.String, System.String)
    L0019: test eax, eax            ; if("BarBar" == "BarBar")
    L001b: jne short L0024          ;     goto L0024 ┐
    L001d: xor eax, eax             ;                |
    L001f: add rsp, 0x28            ;                |
    L0023: ret                      ; return 0       |
    L0024: mov eax, 1               ;             <--┘
    L0029: add rsp, 0x28            ;
    L002d: ret                      ; return 1;

やっぱり分岐が消えていませんね。理想的にはただ1を返すだけのアセンブリになっていてほしいのですが、そうなっていません。これはおそらく単にC#コンパイラ、あるいは JIT コンパイラの最適化性能が不十分なだけです。どちらのレイヤーでこの最適化を解決するかは議論の余地がありますが、次期C#バージョン (少なくとも C# 10 以降) では C# コンパイラのレイヤーで解決するようです。

次期バージョンでは const のみを含む string interpolation はconstにできるようです。つまり、上記のBar()メソッドを JIT コンパイルすると

; x86-64 asm (on 64 bits)
Bar()
    L0000: mov eax, 1
    L0005: ret

となり、完全にリテラルの1を返すメソッドに最適化されます。

余談

現状の C# では string interpolation を使わずに+演算子const stringを繋ぐと、完全な最適化がされて1だけを返すメソッドになります。

int Bar()
{
    return nameof(Bar) + nameof(Bar) == "BarBar" ? 1 : 0;
}

つまり、これは string interpolation の最適化がただ甘いというだけの話です。

JIT 定数

JIT 定数は C# 定数や IL 定数よりもさらに制約が緩いもので、静的には定数として扱われません。実行時に JIT コンパイラが解釈し、実行環境の機械語になって初めて定数扱いされます。実際に見て見ましょう。

int Get3PointerSize()
{
    return IntPtr.Size * 3;
}
// IL
.method private hidebysig instance int32 Get3PointerSize () cil managed 
{
    .maxstack 8
    IL_0000: call int32 [System.Private.CoreLib]System.IntPtr::get_Size()
    IL_0005: ldc.i4.3        // 3
    IL_0006: mul             // IntPtr.Size * 3
    IL_0007: ret             // return
}
; x86-64 asm (on 64 bits)
Get3PointerSize()
    L0000: mov eax, 0x18
    L0005: ret                    ; return 24

上記のGet3PointerSize()メソッドはポインタ3つ分のバイトサイズを返すメソッドです。 IntPtr.Sizeは 64 bit 環境では8、32 bit 環境では4です。 つまり、64 bit 環境では24を、32 bit 環境では12を返します。

しかし、これは実行時に初めてその実行環境の値が決まるため、静的にはその値はわかりません。 したがって、IL 上では値を取得して乗算が行われているのがわかります。JIT コンパイルが行われて初めて定数が確定し、乗算を省略してリテラル値を返すメソッドになります。

では、JIT 定数についても分岐省略の最適化を見てみます。

void DumpRuntimeBits()
{
    if(IntPtr.Size == 4)
        Console.WriteLine("This is 32 bits runtime");
    else if(IntPtr.Size == 8)
        Console.WriteLine("This is 64 bits runtime");
    else
        Console.WriteLine("Unknown runtime");
}
; x86-64 asm (on 64 bits)
DumpRuntimeBits()
    L0000: mov rcx, 0x287a79f8970
    L000a: mov rcx, [rcx]        ; "This is 64 bits runtime"
    L000d: jmp System.Console.WriteLine(System.String)

JIT 後のアセンブリでは分岐が綺麗に消えていますね。 これは 64 bit 環境の JIT 結果なので、32 bit 環境 JIT の場合はif(IntPtr.Size == 4)の処理だけが残ります。

型と JIT と最適化

C# の実行時最適化を制御してこそ、JIT コンパイルを採用している C# の真の力を発揮できます。 JIT 定数としてIntPtr.Size以外に代表的な物はtypeof演算子です。 JIT 定数に焦点を当ててジェネリクスと組み合わせて使う方法について見ていきます。

 

ジェネリクス<T>は IL の状態では型引数<T>が抽象的なままメタな状態です。これが、実行時に実際に使われた型に合わせて機械語が生成されます。

例えば、List<T>の場合、List<int>.AddList<double>.AddJIT 後は完全に別のメソッドで、その関数ポインタの値は異なります。つまり、実行時にはあたかもList_intList_doubleという別の型が存在しているかのように解釈されます。

これと JIT 定数である typeofと組み合わせてみます。

public class Sample<T>
{
    public void DumpType()
    {
        if(typeof(T) == typeof(int))
            Console.WriteLine("int");
        else if(typeof(T) == typeof(double))
            Console.WriteLine("double");
        else
            Console.WriteLine("other types");
    }
}

// ------------------------
var intSample = new Sample<int>();
intSample.DumpType();        // >> int

JIT 後のアセンブリは省略しますが、この分岐は実行時には消え、一致する部分の処理だけが残ります。 したがって、ジェネリクスクラスあるいはジェネリクスメソッドの中で、特定の型に対してだけ処理を行う場合、typeofで if 文を大量に列挙するのが最適解です。

 

このように、JIT の仕組みと JIT 時最適化読みで施せる C# のパフォーマンスハックはいろいろとあります。 特に、ジェネリクスJIT は相性がいいため、いろいろとできます。また機会があれば記事を書こうかなと思います。

(例えば、分岐削除という観点ではないですが、ジェネリクスJIT の組み合わせとしては Static Type Caching なんかが有名ですね)

速すぎる最適化に意味はない、などとよく言われますが、そもそも知らない最適化は書けません。 より速い最適化方法を知っていてこそ、それを使うか使わないかの選択肢を選べます。

たまには速すぎる最適化で最高性能を出してみませんか?

それではよいクリスマスを。