この記事は「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"); }
const
は C# の静的なソースコードの状態で定数であることを表現します。上の例では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
ですので、上記のa
はC#定数です。そしてb
はconst
ではありません。(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# コンパイラのレイヤーで解決するようです。
Do you know that constant (compile-time) interpolated strings are coming to C#?👀
— Konrad Kokosa (@konradkokosa) 2020年10月21日
👉 https://t.co/zVcr0WH7jg pic.twitter.com/FGrf9eTBYZ
次期バージョンでは 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>.Add
とList<double>.Add
は JIT 後は完全に別のメソッドで、その関数ポインタの値は異なります。つまり、実行時にはあたかもList_int
とList_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 なんかが有名ですね)
速すぎる最適化に意味はない、などとよく言われますが、そもそも知らない最適化は書けません。 より速い最適化方法を知っていてこそ、それを使うか使わないかの選択肢を選べます。
たまには速すぎる最適化で最高性能を出してみませんか?
それではよいクリスマスを。