性能文章>.NET中委托性能的演变>

.NET中委托性能的演变转载

9月前
245433

.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

点赞收藏
分类:标签:
INCerry
请先登录,查看3条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
3
3