泛型方法
.NET 中的泛型方法带有一个或多个类型参数,可以在保证类型安全的同时处理不同的数据类型。当我们希望代码针对具体类型进行特化,却又不想为每种类型重复编写一份代码时,泛型方法就很有用。
来看一个将两个值相加的泛型方法:
csharp
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 生成的代码也非常精简:
nasm
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 还可以更进一步,将调用内联。假设我们用常量调用这两个实例化:
csharp
void Test()
{
Console.WriteLine(Add(4, 5));
Console.WriteLine(Add(4.0f, 5.0f));
}
JIT 可以内联这两次调用,再对两次加法进行常量折叠。最终生成的代码只剩下两次对 Console.WriteLine 的调用:
nasm
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 中的虚方法提供了多态行为。基类可以声明一个虚方法:
csharp
class Base
{
public virtual void Display()
{
Console.WriteLine("Base Display");
}
}
接着创建一个派生类来重写它:
csharp
class Derived : Base
{
public override void Display()
{
Console.WriteLine("Derived Display");
}
}
当我们在 Derived 实例上调用 Display 时,执行的是 Derived 中重写后的方法:
csharp
void Test()
{
Base obj = new Derived();
obj.Display(); // Output: Derived Display
}
通常,每次虚调用都需要根据对象的实际运行时类型选择实现。这种分派比直接调用更昂贵。
不过,一种叫作去虚拟化(devirtualization)的优化可以消除这部分开销。在上面的例子中,JIT 能看出 obj 指向一个刚创建的 Derived,因此可以把虚调用目标替换为 Derived.Display。目标确定后,JIT 还可以继续内联这个调用。内联之后,对象分配也不再有用,于是同样会被消除:
nasm
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)。
方法可以是泛型的,也可以是虚的。那么把两者结合起来会怎样?泛型虚方法带有类型参数,但具体实现仍然要根据接收者的运行时类型来选择。
例如,可以在接口中声明一个泛型方法:
csharp
interface ICalculator
{
T Calc<T>(T a, T b) where T : IAdditionOperators<T, T, T>;
}
然后由一个类提供实现:
csharp
class AddCalculator : ICalculator
{
public T Calc<T>(T a, T b) where T : IAdditionOperators<T, T, T>
{
return a + b;
}
}
你可能会觉得,生成的代码应该与前面那个非虚泛型方法差不多。让我们看看通过接口调用时会发生什么。接收者显然是 AddCalculator,两次调用的方法类型参数也都是具体的:
csharp
void Test()
{
ICalculator calc = new AddCalculator();
Console.WriteLine(calc.Calc(4, 5));
Console.WriteLine(calc.Calc(4.0f, 5.0f));
}
.NET 10 生成的代码却是另一回事:
nasm
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
天哪,只是计算两个数的和,竟然生成了这么大一段代码!这里到底发生了什么?
首先,CORINFO_HELP_NEWSFAST 分配了 AddCalculator 对象。两次调用都需要这个对象,因此 JIT 将得到的对象引用保存在 rbx 中。
每次调用时,生成代码都会向 CORINFO_HELP_VIRTUAL_FUNC_PTR 传入三项信息:接收者、ICalculator 接口,以及一个用于标识所请求泛型方法实例化的令牌。该辅助函数解析出正确的实现,并将其地址返回到 rax 中。
随后,程序通过 call rax 调用这个地址。这是一次间接调用。由于 Calc<int> 和 Calc<float> 是不同的 GVM 目标,第二个实例化还要再执行一次相同的解析过程。
辅助函数调用和间接调用只是看得见的成本。更大的损失在于,JIT 始终得不到 Calc<T> 的直接调用目标,因此无法内联任何一个实现。这样一来,前面那个泛型示例中将结果直接折叠为 9 的常量折叠也无法进行。
你可能会说,不如把 AddCalculator 从类改成结构体,看看是否会有帮助。泛型代码经常能在不装箱的情况下调用结构体的接口成员,也许改成结构体就能降低分派成本:
csharp
struct AddCalculator : ICalculator
{
public T Calc<T>(T a, T b) where T : IAdditionOperators<T, T, T>
{
return a + b;
}
}
.NET 10 中生成的代码变成了:
nasm
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 分配装箱对象,紧随其后的存储指令初始化其中的值。之后的两次 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 对调用进行了去虚拟化,就可以直接以拆箱后的入口点为目标,并且有机会一并消除装箱。
第二个维度来自泛型方法既能以值类型实例化,也能以引用类型实例化。JIT 通常会为值类型特化代码,而引用类型实例化通常共享同一份本机代码。
这叫作共享泛型代码。当类型参数是引用类型时,JIT 通常只生成一份方法体,并让所有引用类型实例化共享它。
共享实例化在内部使用一种名为 System.__Canon 的特殊类型表示。由于本机代码是共享的,被调用方有时需要一个隐藏的泛型上下文参数来标识确切实例化。方法体可以利用这个上下文执行与具体类型有关的操作。
例如,来看下面这些共享泛型方法调用:
csharp
void Test()
{
Console.WriteLine(GetType<string>());
Console.WriteLine(GetType<object>());
}
[MethodImpl(MethodImplOptions.NoInlining)]
Type GetType<T>()
{
return typeof(T);
}
所有引用类型只会共享一份 GetType<T>,JIT 会为每个确切实例化传入隐藏的泛型上下文参数。生成代码如下:
nasm
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 还必须提供正确的隐藏上下文参数。
再来看第三个维度。在上面的例子中,编译调用方时类型参数已经确定,因此 JIT 可以直接生成对 GetType<string> 和 GetType<object> 的调用。但如果确切上下文只有在运行时才能获得,JIT 就必须动态查找所需目标或上下文。在内部,这叫作运行时查找。
.NET 11 中的改进
泛型虚方法去虚拟化
我在这个问题上投入了将近一年时间,目前已经取得了很大进展。相关的主要工作内容都汇总在这个 issue 里:JIT: Devirtualization for generic virtual methods · Issue #112596 · dotnet/runtime。
还记得上一节的三个维度吗?在 .NET 11 中,我们改进了前两个维度。
现在,无论接收者是类还是结构体,也无论方法使用值类型还是引用类型实例化,JIT 都可以对 GVM 调用进行去虚拟化。第三个维度,也就是需要运行时查找的情况,仍在开发中,计划在 .NET 12 中完成。
现在看看同一个例子在 .NET 11 中生成的代码。
nasm
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> 约束,所以我们换一个例子。
假设有一个泛型虚方法,可以把传入的字符串解析为目标类型:
csharp
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 生成的代码如下:
nasm
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 生成的代码变成了:
nasm
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)。来看下面的代码:
csharp
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 生成的代码是:
nasm
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 中则变成了:
nasm
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>() 的调用。我还为 .NET 11 贡献了一项改进,让 JIT 知道 Activator.CreateInstance<T>() 的结果具有确切类型 T。这样,JIT 就能对返回对象上的后续调用进行去虚拟化。
.NET 11 中的最终成果
我编写了一个基准测试用来比较 .NET 10 和 .NET 11 中 GVM 调用的性能与分配开销。测试代码和结果可以在 GitHub Gist 中查看。
下面是这个基准测试在 .NET 10 与 .NET 11 中生成代码的差异:
diff
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 毫秒,每次迭代分配 240 字节。.NET 11 运行相同的基准测试只需大约 2,498 毫秒,每次迭代也只分配 72 字节。
性能提升了 2.79 倍,分配量则降至原来的约三分之一。
以上改进现已包含在 .NET 11 Preview 7 中,并且不仅对 JIT 有效,对 R2R 和 NativeAOT 同样生效。而包括运行时查找场景在内的更多改进预计会在 .NET 12 中到来。
读者们不妨亲自试一试看看这些工作带来的变化!