ジェネリックメソッド
.NET のジェネリックメソッドは、1つ以上の型パラメーターを持つメソッドだ。型安全性を維持しながら、さまざまなデータ型を扱える。型ごとにソースコードを複製せず、コードを特殊化したい場面で特に威力を発揮する。
2つの値を加算する次のジェネリックメソッドを考えてみよう。
T Add<T>(T a, T b) where T : IAdditionOperators<T, T, T>
{
return a + b;
}
このメソッドは int や float のほか、IAdditionOperators<T, T, T> を実装する任意の型で呼び出せる。
この柔軟性は強力なだけでなく、効率もよい。値型でインスタンス化した場合、JIT は通常、その型に特殊化したコードを生成する。そのため、ボックス化や実行時の型チェックを挟まず、基になる演算を直接使える。int と float に対して生成されるコードも、次のように小さい。
Add[int](int,int):int:this (FullOpts):
lea eax, [rsi+rdx]
ret
Add[float](float,float):float:this (FullOpts):
vaddss xmm0, xmm0, xmm1
ret
見てのとおり、int のインスタンス化は整数加算に、float のインスタンス化はスカラー浮動小数点加算になる。どちらのメソッドにも、ジェネリックなディスパッチは残っていない。
具体的な型でこのメソッドを呼び出す側も JIT の最適化対象なら、さらに呼び出しをインライン化できる。両方のインスタンス化を定数で呼び出してみよう。
void Test()
{
Console.WriteLine(Add(4, 5));
Console.WriteLine(Add(4.0f, 5.0f));
}
JIT は2つの呼び出しをインライン化し、さらに両方の加算を定数畳み込みできる。最終的なコードに残るのは、Console.WriteLine への2回の呼び出しだけになっている。
Test():this (FullOpts):
push rax
mov edi, 9
call [System.Console:WriteLine(int)]
vmovss xmm0, dword ptr [reloc @RWD00]
add rsp, 8
tail.jmp [System.Console:WriteLine(float)]
RWD00 dd 41100000h ; 9
Add への呼び出しがどちらもインライン化され、結果が直接計算されて Console.WriteLine に渡されている。つまり、正確なジェネリックメソッドが分かれば、抽象化は生成コードから消える。
仮想メソッド
.NET の仮想メソッドは、ポリモーフィックな振る舞いを実現する。まず、基底クラスで仮想メソッドを宣言する。
class Base
{
public virtual void Display()
{
Console.WriteLine("Base Display");
}
}
次に、このメソッドをオーバーライドする派生クラスを作る。
class Derived : Base
{
public override void Display()
{
Console.WriteLine("Derived Display");
}
}
Derived のインスタンスに対して Display を呼び出すと、Derived でオーバーライドしたメソッドが実行される。
void Test()
{
Base obj = new Derived();
obj.Display(); // Output: Derived Display
}
通常、仮想呼び出しではオブジェクトの実際の実行時型に基づいて実装を選ぶ必要がある。このディスパッチには、直接呼び出しよりもコストがかかる。
しかし、脱仮想化(devirtualization)という最適化によって、このオーバーヘッドを取り除ける。先ほどの例では、obj が新しく生成された Derived を参照していると JIT が判断できるため、仮想呼び出しのターゲットを Derived.Display に置き換えられる。ターゲットが分かれば、その呼び出しもインライン化できる。インライン化後はオブジェクトの割り当ても不要になるため、これも消える。
Program:Test():this (FullOpts):
push rax
mov rdi, 0x7E62ECE02D58 ; 'Derived Display'
add rsp, 8
tail.jmp [System.Console:WriteLine(System.String)]
JIT は obj の実際の型が Derived だと見抜き、仮想呼び出しを Derived.Display への直接呼び出しに置き換え、さらにインライン化する。最終的なコードは "Derived Display" を渡して Console.WriteLine を直接呼び出す。オブジェクトの割り当ても仮想呼び出しも、最適化後には残らない。
脱仮想化の意義は、ディスパッチのオーバーヘッドを取り除くことだけではない。呼び出し先が直接分かれば、インライナーがそのメソッド本体を参照できるようになり、呼び出し元でさらに大きな最適化の機会が生まれる。
JIT が仮想メソッド呼び出しを脱仮想化して性能を改善できる場面は数多くあり、その範囲は .NET の新しいバージョンが出るたびに広がっている。
ジェネリック仮想メソッド
ここからは、ジェネリック仮想メソッド(GVM)について考えてみよう。
メソッドはジェネリックにも仮想にもできる。それなら、両方を組み合わせるとどうなるだろうか。ジェネリック仮想メソッドは型パラメーターを持つ一方で、実装はレシーバーの実行時型に応じて選ばれる。
たとえば、インターフェイスにジェネリックメソッドを宣言できる。
interface ICalculator
{
T Calc<T>(T a, T b) where T : IAdditionOperators<T, T, T>;
}
そして、クラス側でその実装を提供する。
class AddCalculator : ICalculator
{
public T Calc<T>(T a, T b) where T : IAdditionOperators<T, T, T>
{
return a + b;
}
}
先ほどの非仮想ジェネリックメソッドと似たコードが生成されると思うかもしれない。インターフェイスを通して呼び出すとどうなるか見てみよう。レシーバーが AddCalculator であることは明らかで、2つのメソッド型引数も具体的に決まっている。
void Test()
{
ICalculator calc = new AddCalculator();
Console.WriteLine(calc.Calc(4, 5));
Console.WriteLine(calc.Calc(4.0f, 5.0f));
}
.NET 10 の生成コードを見ると、どうやら予想とは違う結果になっている。
Program:Test():this (FullOpts):
push rbp
push rbx
push rax
lea rbp, [rsp+0x10]
mov rdi, 0x7A7265C4B8D0 ; AddCalculator
call CORINFO_HELP_NEWSFAST
mov rbx, rax
mov rdi, rbx
mov rsi, 0x7A7265C45A80 ; ICalculator
mov rdx, 0x7A7265D6A6C8 ; token handle
call [CORINFO_HELP_VIRTUAL_FUNC_PTR]
mov rdi, rbx
mov esi, 4
mov edx, 5
call rax
mov edi, eax
call [System.Console:WriteLine(int)]
mov rdi, rbx
mov rsi, 0x7A7265C45A80 ; ICalculator
mov rdx, 0x7A7265D6A8A0 ; token handle
call [CORINFO_HELP_VIRTUAL_FUNC_PTR]
vmovss xmm1, dword ptr [reloc @RWD00]
vmovss xmm0, dword ptr [reloc @RWD04]
mov rdi, rbx
call rax
nop
add rsp, 8
pop rbx
pop rbp
tail.jmp [System.Console:WriteLine(float)]
RWD00 dd 40A00000h ; 5
RWD04 dd 40800000h ; 4
なんということだろう。2つの数を足すだけなのに、かなり大きなコードになっている。いったい何が起きたのか。
まず、CORINFO_HELP_NEWSFAST が AddCalculator オブジェクトを割り当てる。2回の呼び出しで必要になるため、JIT は得られたオブジェクト参照を rbx に保持する。
生成コードは呼び出しのたびに、レシーバー、ICalculator インターフェイス、要求するジェネリックメソッドのインスタンス化を識別するトークンの3つを CORINFO_HELP_VIRTUAL_FUNC_PTR に渡す。このヘルパーが正しい実装を解決し、そのアドレスを rax に返す。
続いて、プログラムは call rax でそのアドレスを呼び出す。これは間接呼び出しだ。Calc<int> と Calc<float> は別々の GVM ターゲットなので、2つ目のインスタンス化でも同じ解決処理が繰り返される。
ヘルパー呼び出しと間接呼び出しは、目に見えるコストにすぎない。さらに大きいのは、JIT が Calc<T> の直接の呼び出し先を得られず、どちらの実装もインライン化できないことだ。その結果、先ほどのジェネリックメソッドの例を 9 まで縮めた定数畳み込みも行えない。
それなら AddCalculator をクラスから構造体に変えればよいのでは、と思うかもしれない。ジェネリックコードでは、構造体のインターフェイスメンバーをボックス化せずに呼び出せることが多い。構造体ならディスパッチも軽くなるだろうか。
struct AddCalculator : ICalculator
{
public T Calc<T>(T a, T b) where T : IAdditionOperators<T, T, T>
{
return a + b;
}
}
.NET 10 で生成されるコードは次のようになる。
Test():this (FullOpts):
push rbp
push rbx
push rax
lea rbp, [rsp+0x10]
mov rdi, 0x77030962B8B8 ; AddCalculator
call CORINFO_HELP_NEWSFAST
mov rbx, rax
mov byte ptr [rbx+0x08], 0
mov rdi, rbx
mov rsi, 0x770309625A80 ; ICalculator
mov rdx, 0x77030974A6C8 ; token handle
call [CORINFO_HELP_VIRTUAL_FUNC_PTR]
mov rdi, rbx
mov esi, 4
mov edx, 5
call rax
mov edi, eax
call [System.Console:WriteLine(int)]
mov rdi, rbx
mov rsi, 0x770309625A80 ; ICalculator
mov rdx, 0x77030974A8A0 ; token handle
call [CORINFO_HELP_VIRTUAL_FUNC_PTR]
vmovss xmm1, dword ptr [reloc @RWD00]
vmovss xmm0, dword ptr [reloc @RWD04]
mov rdi, rbx
call rax
nop
add rsp, 8
pop rbx
pop rbp
tail.jmp [System.Console:WriteLine(float)]
RWD00 dd 40A00000h ; 5
RWD04 dd 40800000h ; 4
さらに悪くなってしまった。生成コードはクラス版とほぼ同じだが、こちらではボックス化まで加わる。ローカル変数の型は依然としてインターフェイス型の ICalculator なので、構造体を代入すると値がボックス化される。CORINFO_HELP_NEWSFAST がボックスを割り当て、その直後のストアがペイロードを初期化する。その後の2つの GVM 呼び出しは、先ほどと同じくヘルパーによる解決と間接ディスパッチを経由する。
構造体へのインターフェイス呼び出しが、常にボックス化されるわけではない。ジェネリック型パラメーターを介した制約付き呼び出しなら、値型を保ったままボックス化を避けられる。この例では、値を ICalculator に変換した時点で、どちらの呼び出しよりも前にその機会を失っている。
通常の仮想呼び出しは、主にレシーバーの型と仮想スロットで決まる。GVM 呼び出しには、メソッドのインスタンス化という別の軸が加わる。同じレシーバーとインターフェイスメソッドを使っていても、Calc<int> と Calc<float> の解決結果は別々のエントリーポイントになり得る。
.NET ランタイム自体は、この組み合わせを実行時に解決する方法をすでに知っている。上の生成コードで CORINFO_HELP_VIRTUAL_FUNC_PTR が行っているのがそれだ。JIT 最適化で難しいのは、特定のレシーバー、インターフェイスメソッド、ジェネリックインスタンス化の組み合わせから、コンパイル中に唯一のターゲットを確定することだ。
JIT がそのターゲットを確定できれば、処理全体を次のように縮められる。
- 実行時の GVM ルックアップが不要になる。
- 間接呼び出しが直接呼び出しになる。
- 直接呼び出しをインライン化でき、メソッド本体に対してさらに最適化を行える。
しかし .NET 10 では最初の段階が行われず、その後の最適化もすべて阻まれていた。JIT はレシーバーが AddCalculator だと分かっていても、その情報から正確な GVM ターゲットを解決できなかった。生成コードにはヘルパーによるルックアップと間接呼び出しが必要なままで、インライナーがメソッド本体を見る機会もなかった。
ジェネリック仮想メソッド呼び出しの脱仮想化
問題が分かったところで、.NET 11 でこれを解決していこう。
GVM 呼び出しには、いくつかの異なる形がある。
- レシーバーがクラスか構造体か。
- インスタンス化に使う型が値型か参照型か。
- 正確なジェネリックコンテキストがコンパイル時に分かるか、実行時ルックアップが必要か。
一番最初の形は分かりやすい。構造体がボックス化され、インターフェイスを通して呼び出されると、ディスパッチ先がアンボックス化スタブ(unboxing stub)と呼ばれる特別なコードになることがある。このスタブは、ボックス化されたオブジェクトの this ポインターを構造体のペイロードに向くよう調整し、実際のメソッド本体へ制御を移す。JIT が呼び出しを脱仮想化できれば、アンボックス化されたエントリーポイントを直接ターゲットにでき、ボックス自体も取り除ける可能性がある。
2つ目の形があるのは、ジェネリックメソッドを値型でも参照型でもインスタンス化できるためだ。JIT は通常、値型に対してはコードを特殊化する一方、参照型のインスタンス化では同じネイティブコードを共有する。
これは共有ジェネリックコードと呼ばれる。型パラメーターが参照型の場合、JIT は通常、メソッド本体を1つだけ生成し、すべての参照型インスタンス化で共有する。
共有インスタンス化は、内部的には System.__Canon という特殊な型を使って表現される。ネイティブコードが共有されているため、呼び出し先では正確なインスタンス化を識別する隠しジェネリックコンテキスト引数が必要になることがある。メソッド本体はこのコンテキストを使って、型固有の処理を行える。
たとえば、次の共有ジェネリックメソッド呼び出しを考えてみよう。
void Test()
{
Console.WriteLine(GetType<string>());
Console.WriteLine(GetType<object>());
}
[MethodImpl(MethodImplOptions.NoInlining)]
Type GetType<T>()
{
return typeof(T);
}
すべての参照型に対して生成される GetType<T> は1つだけで、JIT は正確なインスタンス化ごとに隠しジェネリックコンテキスト引数を渡す。生成コードは次のようになる。
Test():this (FullOpts):
push rbp
push rbx
push rax
lea rbp, [rsp+0x10]
mov rbx, rdi
mov rdi, rbx
mov rsi, 0x7F694233AAC8 ; Program:GetType[System.String]():System.Type:this
call [Program:GetType[System.__Canon]():System.Type:this]
mov rdi, rax
call [System.Console:WriteLine(System.Object)]
mov rdi, rbx
mov rsi, 0x7F694233AB50 ; Program:GetType[System.Object]():System.Type:this
call [Program:GetType[System.__Canon]():System.Type:this]
mov rdi, rax
add rsp, 8
pop rbx
pop rbp
tail.jmp [System.Console:WriteLine(System.Object)]
GetType[System.__Canon]():System.Type:this (FullOpts):
push rax
mov qword ptr [rsp], rsi
mov rdi, qword ptr [rsi+0x18]
mov rdi, qword ptr [rdi]
call CORINFO_HELP_TYPEHANDLE_TO_RUNTIMETYPE
nop
add rsp, 8
ret
見てのとおり、呼び出し元は rsi に隠し引数を渡し、GetType<T> の正確なインスタンス化を識別している。この例で渡されるのはメソッドコンテキストで、共有メソッド本体はそこから実際の型引数を取得する。
つまり、GVM 呼び出しの脱仮想化は、単に呼び出し先を変えるだけで済むとは限らない。GVM 呼び出しを直接呼び出しに変えたあと、ターゲットが共有ジェネリックコードを使う場合は、JIT が正しい隠しコンテキスト引数も渡さなければならない。
続いて3つ目の形に移ろう。上の例では呼び出し元のコンパイル時に型引数が分かっているため、JIT は GetType<string> と GetType<object> への呼び出しを直接生成できる。しかし、正確なコンテキストが実行時にしか分からない場合、必要なターゲットやコンテキストを動的に検索しなければならない。内部的には、これを実行時ルックアップ(runtime lookup)と呼ぶ。
.NET 11 での改善
ジェネリック仮想メソッドの脱仮想化
私はこの問題に1年近く取り組んできたが、ここまでに大きく前進できた。これに関する作業項目については主にこちらの Issue で追跡されている:JIT: Devirtualization for generic virtual methods · Issue #112596 · dotnet/runtime。
前文で挙げた3つの形を思い出してみてくださいー .NET 11 では、最初の2つを改善した。
JIT は、レシーバーがクラスでも構造体でも、またメソッドが値型でも参照型でもインスタンス化されていても、GVM 呼び出しを脱仮想化できるようになった。実行時ルックアップが必要になる3つ目の軸はまだ作業中で、.NET 12 での対応を予定している。
同じ例が .NET 11 でどのようなコードになるか見てみよう。
TestClass():this (FullOpts):
push rax
mov edi, 9
call [System.Console:WriteLine(int)]
vmovss xmm0, dword ptr [reloc @RWD00]
add rsp, 8
tail.jmp [System.Console:WriteLine(float)]
RWD00 dd 41100000h ; 9
TestStruct():this (FullOpts):
push rax
mov edi, 9
call [System.Console:WriteLine(int)]
vmovss xmm0, dword ptr [reloc @RWD00]
add rsp, 8
tail.jmp [System.Console:WriteLine(float)]
RWD00 dd 41100000h ; 9
TestClass と TestStruct は、それぞれクラスと構造体に対して Calc を呼び出している。見てのとおり、生成コードは最適な形になった。GVM 呼び出しは脱仮想化され、構造体のボックス化も取り除かれている。JIT は Calc をインライン化して両方の加算を定数畳み込みできるため、9 が Console.WriteLine に直接渡される。
メソッドを参照型でインスタンス化した場合にも機能する。string は IAdditionOperators<T, T, T> の制約を満たさないので、例を変えよう。
入力された文字列を目的の型として解析する、次のジェネリック仮想メソッドを考える。
void Test()
{
ICanParse p1 = new ClassParser();
Console.WriteLine(p1.Parse<string>("test"));
ICanParse p2 = new StructParser();
Console.WriteLine(p2.Parse<string>("test"));
}
interface ICanParse
{
T Parse<T>(string s) where T : IParsable<T>;
}
class ClassParser : ICanParse
{
public T Parse<T>(string s) where T : IParsable<T>
{
return T.Parse(s, null);
}
}
struct StructParser : ICanParse
{
public T Parse<T>(string s) where T : IParsable<T>
{
return T.Parse(s, null);
}
}
.NET 10 では、Test に対して次のコードが生成されていた。
Test():this (FullOpts):
push rbp
push r15
push r14
push rbx
push rax
lea rbp, [rsp+0x20]
mov rdi, 0x79D16F05B8D0 ; ClassParser
call CORINFO_HELP_NEWSFAST
mov rbx, rax
mov rdi, rbx
mov r15, 0x79D16F05B800 ; ICanParse
mov rsi, r15
mov rdx, 0x79D16F160A10 ; token handle
call [CORINFO_HELP_VIRTUAL_FUNC_PTR]
mov rdi, rbx
mov rbx, 0x79D16C202680 ; 'test'
mov rsi, rbx
call rax
mov rdi, rax
call [System.Console:WriteLine(System.String)]
mov rdi, 0x79D16F05B9B8 ; StructParser
call CORINFO_HELP_NEWSFAST
mov r14, rax
mov byte ptr [r14+0x08], 0
mov rdi, r14
mov rsi, r15
mov rdx, 0x79D16F160A10 ; token handle
call [CORINFO_HELP_VIRTUAL_FUNC_PTR]
mov rdi, r14
mov rsi, rbx
call rax
mov rdi, rax
add rsp, 8
pop rbx
pop r14
pop r15
pop rbp
tail.jmp [System.Console:WriteLine(System.String)]
見てのとおり、実行時の解決と間接呼び出しが残っており、StructParser はインターフェイス呼び出しの前にボックス化されている。
.NET 11 では、Test の生成コードは次のようになる。
Test():this (FullOpts):
push rbx
mov rbx, 0x70FBB5202A70 ; 'test'
mov rdi, rbx
call [System.Console:WriteLine(System.String)]
mov rdi, rbx
pop rbx
tail.jmp [System.Console:WriteLine(System.String)]
生成コードは最適な形になり、どちらの呼び出しも文字列リテラルを Console.WriteLine に直接渡している。
ジェネリックコンテキストにおけるデフォルトインターフェイスメソッドの脱仮想化
この改善は、ジェネリックインターフェイスで宣言されたデフォルトインターフェイスメソッド(DIM)にも適用される。次のコードを見てみよう。
void Test()
{
IGenericA<string> a = new AImpl();
a.B("GVM");
a.C("Generic DIM");
}
interface IA
{
void A(object o);
void B<T>(T o);
}
interface IGenericA<T> : IA
{
void C(T o) => B(o);
}
class AImpl : IGenericA<string>
{
public void A(object o)
{
Console.WriteLine(o);
}
public void B<T>(T o)
{
Console.WriteLine(o);
}
}
.NET 10 では、次のコードが生成されていた。
Test():this (FullOpts):
push rbx
sub rsp, 32
mov rcx, 0x7FFB383AF518
call CORINFO_HELP_NEWSFAST
mov rbx, rax
mov rcx, rbx
mov rdx, 0x7FFB383AF348
mov r8, 0x7FFB383AF828
call CORINFO_HELP_VIRTUAL_FUNC_PTR
mov rcx, rbx
mov rdx, 0xA025C36200
call rax
mov rcx, rbx
mov r11, 0x7FFB380E0070
mov rdx, 0xA025C36220
call [r11]IGenericA`1[System.__Canon]:C(System.__Canon):this
nop
add rsp, 32
pop rbx
ret
.NET 11 では、次のようになる。
Test():this (FullOpts):
sub rsp, 40
mov rcx, 0xA0261EE6F0 ; 'GVM'
call [System.Console:WriteLine(System.Object)]
mov rcx, 0xA0261EE710 ; 'Generic DIM'
call [System.Console:WriteLine(System.Object)]
nop
add rsp, 40
ret
すべての間接処理が消え、生成コードは最適な形になった。
小さいながら手堅い改善、where T : new()
ジェネリックコードで where T : new() を使ったことがある人は、どれくらいいるだろうか。
new() 制約を持つジェネリックメソッド内の new T() は、Activator.CreateInstance<T>() の呼び出しとしてコンパイルされる。そこで、Activator.CreateInstance<T>() の結果が正確に T 型であると JIT が認識できるようにする変更も .NET 11 にコントリビュートした。これにより、JIT は返されたオブジェクトに対する後続の呼び出しを脱仮想化できる。
.NET 11 での最終結果
メソッドの呼び出しのパフォーマンスと割り当てコストを .NET 10 と .NET 11 で比較するベンチマークを作成した。コードと結果は GitHub Gist で公開している。
.NET 10 と .NET 11 の生成コードの差分は次のとおりだ。
G_M000_IG01: ;; offset=0x0000
- push rdi
- push rsi
- push rbp
push rbx
- sub rsp, 40
+ sub rsp, 32
-G_M000_IG02: ;; offset=0x0008
- mov rcx, 0x7FFCFE6A32B8
- call CORINFO_HELP_NEWSFAST
- mov rbx, rax
- mov byte ptr [rbx+0x08], 0
- mov rcx, 0x7FFCFE67EBE0
- call CORINFO_HELP_NEWSFAST
- mov rsi, rax
- mov rcx, rbx
- mov rdi, 0x7FFCFE6A2AC8
- mov rdx, rdi
- mov r8, 0x7FFCFE6A35C8
- call CORINFO_HELP_VIRTUAL_FUNC_PTR
- mov rcx, rbx
- mov rdx, rsi
- call rax
- mov rdx, rax
- mov rcx, rbx
- mov r11, 0x7FFCFE3B0100
- call [r11]Test.IFooConsumer`1[System.__Canon]:Consume(System.__Canon):this
- mov rsi, 0x7FFCFE6A3690
- mov rcx, rsi
- call CORINFO_HELP_NEWSFAST
- mov rbp, rax
- mov rcx, rbx
- mov rdx, rdi
- mov r8, 0x7FFCFE6A38F8
- call CORINFO_HELP_VIRTUAL_FUNC_PTR
- mov rcx, rbx
- mov rdx, rbp
- call rax
- mov rdx, rax
- mov rcx, rbx
- mov r11, 0x7FFCFE3B0108
- call [r11]Test.IFooConsumer`1[System.__Canon]:Consume(System.__Canon):this
- mov rcx, rsi
- call CORINFO_HELP_NEWSFAST
- mov rdx, rax
- mov rcx, rbx
- mov r11, 0x7FFCFE3B0110
- call [r11]Test.IFooConsumer`1[System.__Canon]:Consume(System.__Canon):this
- mov rcx, rsi
- call CORINFO_HELP_NEWSFAST
- mov rbp, rax
- mov rcx, rbx
- mov rdx, rdi
- mov r8, 0x7FFCFE6A38F8
- call CORINFO_HELP_VIRTUAL_FUNC_PTR
- mov rcx, rbx
- mov rdx, rbp
- call rax
- mov rdx, rax
- mov rcx, rbx
- mov r11, 0x7FFCFE3B0118
- call [r11]Test.IFooConsumer`1[System.__Canon]:Consume(System.__Canon):this
- mov rcx, rsi
- call CORINFO_HELP_NEWSFAST
- mov rdx, rax
- mov rcx, rbx
- mov r11, 0x7FFCFE3B0128
- call [r11]Test.IFooConsumer`1[System.__Canon]:Consume(System.__Canon):this
- mov rcx, rsi
- call CORINFO_HELP_NEWSFAST
- mov rdx, rax
- mov rcx, rbx
- mov r11, 0x7FFCFE3B0138
-
-G_M000_IG03: ;; offset=0x0138
- call [r11]Test.IFooConsumer`1[System.__Canon]:Consume(System.__Canon):this
+G_M000_IG02: ;; offset=0x0005
+ mov rcx, 0x7FFC55594270
+ call [System.Activator:CreateInstance[System.__Canon]():System.__Canon]
+ cmp byte ptr [rax], al
+ mov rcx, 0x7FFC555943B0
+ mov rdx, 0x224E7636488
+ call [Test.Program:Use[System.__Canon](System.__Canon)]
+ mov rcx, 0x7FFC555943B0
+ mov rdx, 0x224E7636488
+ call [Test.Program:Use[System.__Canon](System.__Canon)]
+ mov rcx, 0x7FFC55594530
+ call [System.Activator:CreateInstance[System.__Canon]():System.__Canon]
+ cmp byte ptr [rax], al
+ mov rcx, 0x7FFC555943B0
+ mov rbx, 0x224E76364D0
+ mov rdx, rbx
+ call [Test.Program:Use[System.__Canon](System.__Canon)]
+ mov rcx, 0x7FFC555943B0
+ mov rdx, rbx
+ call [Test.Program:Use[System.__Canon](System.__Canon)]
+ mov rcx, 0x7FFC555943B0
+ mov rdx, rbx
+ call [Test.Program:Use[System.__Canon](System.__Canon)]
+ mov rcx, 0x7FFC55594530
+ call [System.Activator:CreateInstance[System.__Canon]():System.__Canon]
+ cmp byte ptr [rax], al
+ mov rcx, 0x7FFC555943B0
+ mov rdx, rbx
+ call [Test.Program:Use[System.__Canon](System.__Canon)]
+ mov rcx, 0x7FFC555943B0
+ mov rdx, rbx
+ call [Test.Program:Use[System.__Canon](System.__Canon)]
+ mov rdx, 0x224E76364D0
+ mov rcx, 0x7FFC555943B0
+ call [Test.Program:Use[System.__Canon](System.__Canon)]
+ mov rdx, 0x224E76364D0
+ mov rcx, 0x7FFC555943B0
+ call [Test.Program:Use[System.__Canon](System.__Canon)]
nop
-G_M000_IG04: ;; offset=0x013C
- add rsp, 40
+G_M000_IG03: ;; offset=0x010D
+ add rsp, 32
pop rbx
- pop rbp
- pop rsi
- pop rdi
- ret
見てのとおり、間接的な仮想呼び出しとボックス化が消えている。JIT は GVM 呼び出しを脱仮想化し、そのメソッド本体をインライン化して、さらに最適化できるようになった。
私の環境では、.NET 10 は約6,972ミリ秒かかり、1イテレーションあたり240バイトを割り当てた。.NET 11 では、同じベンチマークが約2,498ミリ秒まで短縮され、割り当ても1イテレーションあたり72バイトだけになった。
実行速度は2.79倍になり、割り当て量は3.33分の1まで減った。
また、この取り組みを通して多大なご支援をいただいた .NET ランタイムチームにも感謝したい。
ここまで紹介した改善は、すべて .NET 11 Preview 7 で利用できる。ちなみに JIT だけでなく、R2R と NativeAOT も同じ恩恵を受けている。そして、実行時ルックアップ(runtime lookup)を伴うケースを含むさらなる改善は .NET 12 で入る見込みとなっている。
ぜひ実際に試していってください!