8

.NET中委托性能的演变 - InCerry

 1 year ago
source link: https://www.cnblogs.com/InCerry/p/the-evolution-of-delegate-performance-in-net-c8f23572b8b1.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

.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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK