.NET中委托性能的演变
.NET中的委托
.NET中的委托是一项重要功能,可以实现间接方法调用和函数式编程。
自.NET Framework 1.0起,委托在.NET中就支持多播(multicast)功能。通过多播,我们可以在单个委托调用中调用一系列方法,而无需自己维护方法列表。
即使在今天,委托的多播功能在桌面开发中仍然发挥着至关重要的作用。
让我们通过一个例子快速了解一下。
delegate void FooDelegate(int v);
class MyFoo
{
public FooDelegate? Foo { get; set; }
public void Process()
{
Foo?.Invoke(42);
}
}
我们简单地定义了一个带有单个参数v的委托,并在方法Process中调用了该委托。
要使用上面的代码,我们需要将一些目标添加到委托成员Foo中。
var obj = new MyFoo();
obj.Foo += v => Console.WriteLine(v);
obj.Foo += v => Console.WriteLine(v + 1);
obj.Foo += v => Console.WriteLine(v - 42);
obj.Process();
然后我们会得到如下预期的输出。
42
43
0
但是,在幕后发生了什么?
实际上,编译器会自动将我们的lambda表达式转换为方法,并使用静态字段缓存创建的委托,如下所示。
[CompilerGenerated]
internal class Program
{
[Serializable]
[CompilerGenerated]
private sealed class <>c
{
public static readonly <>c <>9 = new <>c();
public static FooDelegate <>9__0_0;
public static FooDelegate <>9__0_1;
public static FooDelegate <>9__0_2;
internal void <<Main>$>b__0_0(int v)
{
Console.WriteLine(v);
}
internal void <<Main>$>b__0_1(int v)
{
Console.WriteLine(v + 1);
}
internal void <<Main>$>b__0_2(int v)
{
Console.WriteLine(v - 42);
}
}
private static void <Main>$(string[] args)
{
MyFoo myFoo = new MyFoo();
myFoo.Foo = (FooDelegate)Delegate.Combine(myFoo.Foo, <>c.<>9__0_0 ?? (<>c.<>9__0_0 = new FooDelegate(<>c.<>9.<<Main>$>b__0_0)));
myFoo.Foo = (FooDelegate)Delegate.Combine(myFoo.Foo, <>c.<>9__0_1 ?? (<>c.<>9__0_1 = new FooDelegate(<>c.<>9.<<Main>$>b__0_1)));
myFoo.Foo = (FooDelegate)Delegate.Combine(myFoo.Foo, <>c.<>9__0_2 ?? (<>c.<>9__0_2 = new FooDelegate(<>c.<>9.<<Main>$>b__0_2)));
myFoo.Process();
}
}
每个委托只会在第一次创建和缓存,因此当我们再次经过lambda表达式创建的代码路径时,将不会分配委托。
但是请注意包含Delegate.Combine的代码行,它将我们的三个方法有效地组合成单个委托。实际上,.NET中的每个委托都继承自MulticastDelegate,其中包含invocationList以保存调用方法时的方法指针和目标(对象)。Delegate.Combine的实现是线程安全的,因此我们可以在代码的每个角落放心使用它。
便利性与复杂性,以及问题
在桌面开发中,这确实为我们提供了很大的便利。然而,与此同时,C#中还有另一个关键字叫做“event”。
class MyFoo
{
private List<Delegate> funcs = new();
public event FooDelegate Foo
{
add => funcs.Add(value);
remove
{
if (funcs.IndexOf(value) is int v and not -1) funcs.RemoveAt(v);
}
}
}
使用event关键字,我们可以确定如何添加或删除委托。例如,我们可以使用List<Delegate>
保存所有委托,而不是使用委托的内置多播功能。
但是,即使使用event关键字,委托的多播功能也不会消失。那么,为什么我们需要在委托级别上提供多播功能呢?为什么不提供一个线程安全的委托集合类型DelegateCollection,并使自动实现的事件使用该类型,而不是使委托本身成为多播委托?
更糟糕的是,每次调用委托时,运行时都需要迭代调用目标。由于这个原因,JIT编译器无法将委托调用转换为直接调用,从而防止JIT内联目标方法。
即使是在最简单的委托调用中也会发生这种情况。
int Foo() => 42;
void Call(Func<int> f) => Console.WriteLine(f());
Call(Foo);
让我们看看这将如何影响代码生成。
G_M24006_IG02:
mov rcx, 0xD1FFAB1E ; System.Func`1[int]
call CORINFO_HELP_NEWSFAST
mov rsi, rax
lea rcx, bword ptr [rsi+08H]
mov rdx, rsi
call CORINFO_HELP_ASSIGN_REF
mov rcx, 0xD1FFAB1E ; 函数地址
mov qword ptr [rsi+18H], rcx
mov rcx, 0xD1FFAB1E ; cProgram:<Main>g__Foo|0_0():int 中的代码
mov qword ptr [rsi+20H], rcx
mov rcx, gword ptr [rsi+08H]
call [rsi+18H]System.Func`1[int]:Invoke():int:this ; <---- 这里
mov ecx, eax
call [System.Console:WriteLine(int)]
nop
虽然方法 CallDelegate 被调用者内联了,但它仍然必须调用 System.Func<int>::Invoke
来逐个迭代调用列表和所有被调用者,这比简单的间接方法调用(通过直接使用函数指针)慢,并且比直接方法调用(当被调用者可以内联时)要慢得多。
public unsafe class Benchmarks
{
private int Foo() => 42;
private readonly Func<int> f;
public Benchmarks() => f = Foo;
[Benchmark]
public int SumWithDelegate()
{
var lf = this.f; // 将 f 做一个本地拷贝,因为 f 可能随时被其他方法修改,这会阻止一些优化。
var sum = 0;
for (var i = 0; i < 42; i++) sum += lf();
return sum;
}
[Benchmark]
public int SumWithDirectCall()
{
var sum = 0;
for (var i = 0; i < 42; i++) sum += Foo();
return sum;
}
}
基准测试结果:
Method | Mean | Error | StdDev |
---|---|---|---|
SumWithDelegate | 60.21 ns | 0.725 ns | 0.678 ns |
SumWithDirectCall | 10.52 ns | 0.155 ns | 0.145 ns |
委托调用比直接调用慢了500%。我们可以通过 JIT 为每个方法的循环体生成的汇编代码来简单解释它:
; Method SumWithDelegate
G_M41830_IG03:
mov rax, gword ptr [rsi+08H]
mov rcx, gword ptr [rax+08H]
call [rax+18H]System.Func`1[int]:Invoke():int:this
add edi, eax
inc ebx
cmp ebx, 42
jl SHORT G_M41830_IG03
; Method SumWithDirectCall
G_M33206_IG03:
add eax, 42
inc edx
cmp edx, 42
jl SHORT G_M33206_IG03
生命、宇宙以及万物之答案
在 .NET 7 之前,我们必须接受委托性能的缺陷,但幸运的是,自 .NET 7 以来整个游戏都发生了变化。
现在我要介绍两个概念:PGO(基于性能分析的优化)和 GDV(带保护的去虚拟化)。
PGO 是一种优化技术,它包含两个部分:一个是对程序进行插桩并收集运行时的性能分析数据,另一个是将收集到的分析数据提供给编译器,以便编译器可以利用这些数据生成更好的代码。
而 GDV 是去虚拟化的一个带保护版本。有时,由于多态性,我们不能简单地去虚拟化一个方法,但我们可以先进行类型测试,这作为一个保护,然后在保护下去虚拟化被调用者:
void Foo(Base obj)
{
obj.VirtualCall(); // 在这里我们无法取消虚拟调用
}
void Foo(Base obj)
{
if (obj is Derived2) // 加入一个守卫代码
((Derived2)obj).VirtualCall(); // 现在我们可以取消虚拟调用
else obj.VirtualCall(); // 否则,回退到标准的虚拟调用
}
但编译器如何确定要测试哪种类型呢?现在,分析数据参与编译过程。例如,如果编译器看到大多数对 VirtualCall 的调用都分派给了 Derived2 类型,编译器可以发出对 Derived2 的保护,并在保护下将调用去虚拟化,使其成为快速路径,而另一方面则回退到标准虚拟调用(如果类型不是 Derived2)。
在 .NET 7 中,我们也有针对委托调用的类似优化,通过收集方法直方图实现。
现在,我将在 .NET 7 中启用动态 PGO,让我们看看会发生什么。
要启用动态 PGO,我们需要在 csproj 文件中设置 <TieredPgo>true</TieredPgo>
。这次,我们获得以下基准测试结果:
Method | Mean | Error | StdDev | Code Size |
---|---|---|---|---|
SumWithDelegate | 15.95 ns | 0.320 ns | 0.299 ns | 69 B |
SumWithDirectCall | 10.25 ns | 0.112 ns | 0.105 ns | 15 B |
性能大幅提升!这次使用委托调用的方法的性能几乎与使用直接调用的方法相当。让我们看看反汇编代码。我在反汇编代码中添加了一些注释,以解释发生了什么。
; Method SumWithDelegate
...
G_M000_IG03:
mov rdx, qword ptr [rcx+18H]
mov rax, 0x7FFED3C041C8 ; 以下是基准测试代码:Benchmarks:Foo():int:this
cmp rdx, rax ; 测试调用者是否是 Foo 方法
jne SHORT G_M000_IG07 ; 如果不是,则回退到虚拟调用
mov eax, 42 ; 否则,取消虚拟调用并进行内联优化
; 这样我们就可以将 Foo 方法的返回值 42 直接加到总和中
G_M000_IG04: ; 而不需要实际调用 Foo 方法
add edi, eax ; 就像我们在 SumWithDirectCall 中所做的一样
inc ebx
cmp ebx, 42
jl SHORT G_M000_IG03
...
G_M000_IG07: ; 执行虚拟调用的慢速路径
mov rcx, gword ptr [rcx+08H]
call rdx
jmp SHORT G_M000_IG04
; Method SumWithDirectCall
... ; 被调用者进行了取消虚拟化和内联优化
G_M000_IG03: ; 因此我们可以将 Foo 方法的返回值 42 直接加到总和中
add eax, 42 ; 而不需要实际调用 Foo 方法
inc edx
cmp edx, 42
jl SHORT G_M000_IG03
反汇编代码中可以看到,通过动态 PGO,编译器已经将委托调用的方法也进行了内联优化,同时引入了 Guarded De-virtualization 技术,通过判断调用的方法历史记录,为委托调用的方法生成了类似于直接调用的优化代码路径。
具体来说,在委托调用方法的汇编代码中,编译器通过对委托对象中所包含的方法历史记录进行测试,判断是否大多数情况下委托调用的方法为某一种类型,如果是,则通过类型检查指令对该类型进行保护,然后将委托调用的方法进行去虚拟化并内联,生成类似于直接调用的汇编代码路径。而如果大多数情况下委托调用的方法不属于任何一种类型,则直接执行缓慢的委托调用路径。
在最终的性能测试结果中,委托调用方法的性能已经接近直接调用方法的性能,这意味着使用 PGO 和 GDV 技术,可以大大提升委托调用方法的性能。
这个能进一步改进吗?
我们现在可以看到,在循环的每次迭代中,我们都在测试委托的目标方法。为什么不将检查提前到循环外部,这样整个循环只需要一次检查就够了呢?
值得庆幸的是,最近在.NET 8中进行的相关工作已经能够在夜间构建中看到改进。现在 SumWithDelegate 方法的反汇编结果如下:
...
G_M41830_IG02:
mov rsi, gword ptr [rcx+08H]
xor edi, edi
xor ebx, ebx
test rsi, rsi
je SHORT G_M41830_IG05
mov rax, qword ptr [rsi+18H]
mov rcx, 0xD1FFAB1E ; 以下是基准测试代码: Benchmarks:Foo():int:this
cmp rax, rcx ; 测试调用者是否是 Foo 方法
jne SHORT G_M41830_IG05 ; 如果不是,则跳转到 G_M41830_IG05,回退到每次迭代中测试调用者的方式
G_M41830_IG03: ; 否则,我们进入了最快的路径,这与 SumWithDirectCall 完全相同
mov eax, 42
add edi, eax
inc ebx
cmp ebx, 42
jl SHORT G_M41830_IG03
...
G_M41830_IG05:
mov rax, qword ptr [rsi+18H]
mov rcx, 0xD1FFAB1E ; 以下是基准测试代码: Benchmarks:Foo():int:this
cmp rax, rcx ; 测试调用者是否是 Foo 方法
jne SHORT G_M41830_IG09 ; 如果不是,则跳转到 G_M41830_IG09,回退到虚拟调用的慢速路径
mov eax, 42 ; 否则,被调用者进行了取消虚拟化和内联优化
G_M41830_IG06:
add edi, eax
inc ebx
cmp ebx, 42
jl SHORT G_M41830_IG05
...
G_M41830_IG09:
mov rcx, gword ptr [rsi+08H]
call [rsi+18H]System.Func`1[int]:Invoke():int:this
jmp SHORT G_M41830_IG06
在正常情况下,.NET 将测试委托的目标方法是否为指定的方法,如果是,将使用快速路径(IG03),否则将使用慢速路径(IG05 和 IG09)。在快速路径中,委托的目标方法被直接调用,而在慢速路径中,将通过虚拟调用或间接调用委托的目标方法。
这个优化可以使委托调用的性能与直接调用方法的性能相同。
这段代码实际上被优化成了:
var sum = 0;
if (f == Foo)
for (var i = 0; i < 42; i++) sum += 42;
else
for (var i = 0; i < 42; i++)
if (f == Foo) sum += 42;
else sum += f();
return sum;
现在在正常情况下,委托调用与直接调用方法的性能表现完全相同。
结尾
虽然 .NET 以前曾经在委托方面做出了一些糟糕的决定,但自 .NET 7 以来,它已经成功地解决了委托的性能问题。
祝编码愉快!
已获得作者授权
作者: hez2010
译者:InCerry
原文链接: https://medium.com/@skyake/the-evolution-of-delegate-performance-in-net-c8f23572b8b1