5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

.NET 11 でジェネリック仮想メソッドを高速化してみた

5
Last updated at Posted at 2026-08-13

ジェネリックメソッド

.NET のジェネリックメソッドは、1つ以上の型パラメーターを持つメソッドだ。型安全性を維持しながら、さまざまなデータ型を扱える。型ごとにソースコードを複製せず、コードを特殊化したい場面で特に威力を発揮する。

2つの値を加算する次のジェネリックメソッドを考えてみよう。

T Add<T>(T a, T b) where T : IAdditionOperators<T, T, T>
{
    return a + b;
}

このメソッドは intfloat のほか、IAdditionOperators<T, T, T> を実装する任意の型で呼び出せる。

この柔軟性は強力なだけでなく、効率もよい。値型でインスタンス化した場合、JIT は通常、その型に特殊化したコードを生成する。そのため、ボックス化や実行時の型チェックを挟まず、基になる演算を直接使える。intfloat に対して生成されるコードも、次のように小さい。

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_NEWSFASTAddCalculator オブジェクトを割り当てる。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 がそのターゲットを確定できれば、処理全体を次のように縮められる。

  1. 実行時の GVM ルックアップが不要になる。
  2. 間接呼び出しが直接呼び出しになる。
  3. 直接呼び出しをインライン化でき、メソッド本体に対してさらに最適化を行える。

しかし .NET 10 では最初の段階が行われず、その後の最適化もすべて阻まれていた。JIT はレシーバーが AddCalculator だと分かっていても、その情報から正確な GVM ターゲットを解決できなかった。生成コードにはヘルパーによるルックアップと間接呼び出しが必要なままで、インライナーがメソッド本体を見る機会もなかった。

ジェネリック仮想メソッド呼び出しの脱仮想化

問題が分かったところで、.NET 11 でこれを解決していこう。

GVM 呼び出しには、いくつかの異なる形がある。

  1. レシーバーがクラスか構造体か。
  2. インスタンス化に使う型が値型か参照型か。
  3. 正確なジェネリックコンテキストがコンパイル時に分かるか、実行時ルックアップが必要か。

一番最初の形は分かりやすい。構造体がボックス化され、インターフェイスを通して呼び出されると、ディスパッチ先がアンボックス化スタブ(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

TestClassTestStruct は、それぞれクラスと構造体に対して Calc を呼び出している。見てのとおり、生成コードは最適な形になった。GVM 呼び出しは脱仮想化され、構造体のボックス化も取り除かれている。JIT は Calc をインライン化して両方の加算を定数畳み込みできるため、9Console.WriteLine に直接渡される。

メソッドを参照型でインスタンス化した場合にも機能する。stringIAdditionOperators<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 で入る見込みとなっている。

ぜひ実際に試していってください!

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?