• .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保存所有委托,而不是使用委托的内置多播功能。

    但是,即使使用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:
    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::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 文件中设置 true。这次,我们获得以下基准测试结果:

    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

  • 相关阅读:
    k8s-kubernetes常用命令,服务部署,可视化控制台安装及token的生成
    c语言qsort函数使用教程
    切入点表达式
    java计算机毕业设计水质监测数据采集系统源码+系统+数据库+lw文档+mybatis+运行部署
    最新版成人高考专升本高数一、高数二有什么区别?
    leetcode 1884-鸡蛋掉落-两枚鸡蛋
    与内在功能连接个体变异性相关的基因表达
    新版IDEA没有办法选择Java8版本解决方法
    Java引用和内部类
    Vue的`provide`和`inject`特性:上下文传递与数据共享
  • 原文地址:https://www.cnblogs.com/InCerry/p/the-evolution-of-delegate-performance-in-net-c8f23572b8b1.html