经验首页 前端设计 程序设计 Java相关 移动开发 数据库/运维 软件/图像 大数据/云计算 其他经验
当前位置:技术经验 » 程序设计 » ASP.net » 查看文章
.NET中委托性能的演变
来源:cnblogs  作者:InCerry  时间:2023/3/14 9:56:01  对本文有异议

.NET中的委托

.NET中的委托是一项重要功能,可以实现间接方法调用和函数式编程。

自.NET Framework 1.0起,委托在.NET中就支持多播(multicast)功能。通过多播,我们可以在单个委托调用中调用一系列方法,而无需自己维护方法列表。

即使在今天,委托的多播功能在桌面开发中仍然发挥着至关重要的作用。

让我们通过一个例子快速了解一下。

  1. delegate void FooDelegate(int v);
  2. class MyFoo
  3. {
  4. public FooDelegate? Foo { get; set; }
  5. public void Process()
  6. {
  7. Foo?.Invoke(42);
  8. }
  9. }

我们简单地定义了一个带有单个参数v的委托,并在方法Process中调用了该委托。

要使用上面的代码,我们需要将一些目标添加到委托成员Foo中。

  1. var obj = new MyFoo();
  2. obj.Foo += v => Console.WriteLine(v);
  3. obj.Foo += v => Console.WriteLine(v + 1);
  4. obj.Foo += v => Console.WriteLine(v - 42);
  5. obj.Process();

然后我们会得到如下预期的输出。

  1. 42
  2. 43
  3. 0

但是,在幕后发生了什么?

实际上,编译器会自动将我们的lambda表达式转换为方法,并使用静态字段缓存创建的委托,如下所示。

  1. [CompilerGenerated]
  2. internal class Program
  3. {
  4. [Serializable]
  5. [CompilerGenerated]
  6. private sealed class <>c
  7. {
  8. public static readonly <>c <>9 = new <>c();
  9. public static FooDelegate <>9__0_0;
  10. public static FooDelegate <>9__0_1;
  11. public static FooDelegate <>9__0_2;
  12. internal void <<Main>$>b__0_0(int v)
  13. {
  14. Console.WriteLine(v);
  15. }
  16. internal void <<Main>$>b__0_1(int v)
  17. {
  18. Console.WriteLine(v + 1);
  19. }
  20. internal void <<Main>$>b__0_2(int v)
  21. {
  22. Console.WriteLine(v - 42);
  23. }
  24. }
  25. private static void <Main>$(string[] args)
  26. {
  27. MyFoo myFoo = new MyFoo();
  28. myFoo.Foo = (FooDelegate)Delegate.Combine(myFoo.Foo, <>c.<>9__0_0 ?? (<>c.<>9__0_0 = new FooDelegate(<>c.<>9.<<Main>$>b__0_0)));
  29. myFoo.Foo = (FooDelegate)Delegate.Combine(myFoo.Foo, <>c.<>9__0_1 ?? (<>c.<>9__0_1 = new FooDelegate(<>c.<>9.<<Main>$>b__0_1)));
  30. myFoo.Foo = (FooDelegate)Delegate.Combine(myFoo.Foo, <>c.<>9__0_2 ?? (<>c.<>9__0_2 = new FooDelegate(<>c.<>9.<<Main>$>b__0_2)));
  31. myFoo.Process();
  32. }
  33. }

每个委托只会在第一次创建和缓存,因此当我们再次经过lambda表达式创建的代码路径时,将不会分配委托。

但是请注意包含Delegate.Combine的代码行,它将我们的三个方法有效地组合成单个委托。实际上,.NET中的每个委托都继承自MulticastDelegate,其中包含invocationList以保存调用方法时的方法指针和目标(对象)。Delegate.Combine的实现是线程安全的,因此我们可以在代码的每个角落放心使用它。

便利性与复杂性,以及问题

在桌面开发中,这确实为我们提供了很大的便利。然而,与此同时,C#中还有另一个关键字叫做“event”。

  1. class MyFoo
  2. {
  3. private List<Delegate> funcs = new();
  4. public event FooDelegate Foo
  5. {
  6. add => funcs.Add(value);
  7. remove
  8. {
  9. if (funcs.IndexOf(value) is int v and not -1) funcs.RemoveAt(v);
  10. }
  11. }
  12. }

使用event关键字,我们可以确定如何添加或删除委托。例如,我们可以使用List<Delegate>保存所有委托,而不是使用委托的内置多播功能。

但是,即使使用event关键字,委托的多播功能也不会消失。那么,为什么我们需要在委托级别上提供多播功能呢?为什么不提供一个线程安全的委托集合类型DelegateCollection,并使自动实现的事件使用该类型,而不是使委托本身成为多播委托?

更糟糕的是,每次调用委托时,运行时都需要迭代调用目标。由于这个原因,JIT编译器无法将委托调用转换为直接调用,从而防止JIT内联目标方法。

即使是在最简单的委托调用中也会发生这种情况。

  1. int Foo() => 42;
  2. void Call(Func<int> f) => Console.WriteLine(f());
  3. Call(Foo);

让我们看看这将如何影响代码生成。

  1. G_M24006_IG02:
  2. mov rcx, 0xD1FFAB1E ; System.Func`1[int]
  3. call CORINFO_HELP_NEWSFAST
  4. mov rsi, rax
  5. lea rcx, bword ptr [rsi+08H]
  6. mov rdx, rsi
  7. call CORINFO_HELP_ASSIGN_REF
  8. mov rcx, 0xD1FFAB1E ; 函数地址
  9. mov qword ptr [rsi+18H], rcx
  10. mov rcx, 0xD1FFAB1E ; cProgram:<Main>g__Foo|0_0():int 中的代码
  11. mov qword ptr [rsi+20H], rcx
  12. mov rcx, gword ptr [rsi+08H]
  13. call [rsi+18H]System.Func`1[int]:Invoke():int:this ; <---- 这里
  14. mov ecx, eax
  15. call [System.Console:WriteLine(int)]
  16. nop

虽然方法 CallDelegate 被调用者内联了,但它仍然必须调用 System.Func<int>::Invoke 来逐个迭代调用列表和所有被调用者,这比简单的间接方法调用(通过直接使用函数指针)慢,并且比直接方法调用(当被调用者可以内联时)要慢得多。

  1. public unsafe class Benchmarks
  2. {
  3. private int Foo() => 42;
  4. private readonly Func<int> f;
  5. public Benchmarks() => f = Foo;
  6. [Benchmark]
  7. public int SumWithDelegate()
  8. {
  9. var lf = this.f; // 将 f 做一个本地拷贝,因为 f 可能随时被其他方法修改,这会阻止一些优化。
  10. var sum = 0;
  11. for (var i = 0; i < 42; i++) sum += lf();
  12. return sum;
  13. }
  14. [Benchmark]
  15. public int SumWithDirectCall()
  16. {
  17. var sum = 0;
  18. for (var i = 0; i < 42; i++) sum += Foo();
  19. return sum;
  20. }
  21. }

基准测试结果:

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 为每个方法的循环体生成的汇编代码来简单解释它:

  1. ; Method SumWithDelegate
  2. G_M41830_IG03:
  3. mov rax, gword ptr [rsi+08H]
  4. mov rcx, gword ptr [rax+08H]
  5. call [rax+18H]System.Func`1[int]:Invoke():int:this
  6. add edi, eax
  7. inc ebx
  8. cmp ebx, 42
  9. jl SHORT G_M41830_IG03
  10. ; Method SumWithDirectCall
  11. G_M33206_IG03:
  12. add eax, 42
  13. inc edx
  14. cmp edx, 42
  15. jl SHORT G_M33206_IG03

生命、宇宙以及万物之答案

在 .NET 7 之前,我们必须接受委托性能的缺陷,但幸运的是,自 .NET 7 以来整个游戏都发生了变化。

现在我要介绍两个概念:PGO(基于性能分析的优化)和 GDV(带保护的去虚拟化)。

PGO 是一种优化技术,它包含两个部分:一个是对程序进行插桩并收集运行时的性能分析数据,另一个是将收集到的分析数据提供给编译器,以便编译器可以利用这些数据生成更好的代码。

而 GDV 是去虚拟化的一个带保护版本。有时,由于多态性,我们不能简单地去虚拟化一个方法,但我们可以先进行类型测试,这作为一个保护,然后在保护下去虚拟化被调用者:

  1. void Foo(Base obj)
  2. {
  3. obj.VirtualCall(); // 在这里我们无法取消虚拟调用
  4. }
  5. void Foo(Base obj)
  6. {
  7. if (obj is Derived2) // 加入一个守卫代码
  8. ((Derived2)obj).VirtualCall(); // 现在我们可以取消虚拟调用
  9. else obj.VirtualCall(); // 否则,回退到标准的虚拟调用
  10. }

但编译器如何确定要测试哪种类型呢?现在,分析数据参与编译过程。例如,如果编译器看到大多数对 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

性能大幅提升!这次使用委托调用的方法的性能几乎与使用直接调用的方法相当。让我们看看反汇编代码。我在反汇编代码中添加了一些注释,以解释发生了什么。

  1. ; Method SumWithDelegate
  2. ...
  3. G_M000_IG03:
  4. mov rdx, qword ptr [rcx+18H]
  5. mov rax, 0x7FFED3C041C8 ; 以下是基准测试代码:Benchmarks:Foo():int:this
  6. cmp rdx, rax ; 测试调用者是否是 Foo 方法
  7. jne SHORT G_M000_IG07 ; 如果不是,则回退到虚拟调用
  8. mov eax, 42 ; 否则,取消虚拟调用并进行内联优化
  9. ; 这样我们就可以将 Foo 方法的返回值 42 直接加到总和中
  10. G_M000_IG04: ; 而不需要实际调用 Foo 方法
  11. add edi, eax ; 就像我们在 SumWithDirectCall 中所做的一样
  12. inc ebx
  13. cmp ebx, 42
  14. jl SHORT G_M000_IG03
  15. ...
  16. G_M000_IG07: ; 执行虚拟调用的慢速路径
  17. mov rcx, gword ptr [rcx+08H]
  18. call rdx
  19. jmp SHORT G_M000_IG04
  20. ; Method SumWithDirectCall
  21. ... ; 被调用者进行了取消虚拟化和内联优化
  22. G_M000_IG03: ; 因此我们可以将 Foo 方法的返回值 42 直接加到总和中
  23. add eax, 42 ; 而不需要实际调用 Foo 方法
  24. inc edx
  25. cmp edx, 42
  26. jl SHORT G_M000_IG03

反汇编代码中可以看到,通过动态 PGO,编译器已经将委托调用的方法也进行了内联优化,同时引入了 Guarded De-virtualization 技术,通过判断调用的方法历史记录,为委托调用的方法生成了类似于直接调用的优化代码路径。

具体来说,在委托调用方法的汇编代码中,编译器通过对委托对象中所包含的方法历史记录进行测试,判断是否大多数情况下委托调用的方法为某一种类型,如果是,则通过类型检查指令对该类型进行保护,然后将委托调用的方法进行去虚拟化并内联,生成类似于直接调用的汇编代码路径。而如果大多数情况下委托调用的方法不属于任何一种类型,则直接执行缓慢的委托调用路径。

在最终的性能测试结果中,委托调用方法的性能已经接近直接调用方法的性能,这意味着使用 PGO 和 GDV 技术,可以大大提升委托调用方法的性能。

这个能进一步改进吗?

我们现在可以看到,在循环的每次迭代中,我们都在测试委托的目标方法。为什么不将检查提前到循环外部,这样整个循环只需要一次检查就够了呢?

值得庆幸的是,最近在.NET 8中进行的相关工作已经能够在夜间构建中看到改进。现在 SumWithDelegate 方法的反汇编结果如下:

  1. ...
  2. G_M41830_IG02:
  3. mov rsi, gword ptr [rcx+08H]
  4. xor edi, edi
  5. xor ebx, ebx
  6. test rsi, rsi
  7. je SHORT G_M41830_IG05
  8. mov rax, qword ptr [rsi+18H]
  9. mov rcx, 0xD1FFAB1E ; 以下是基准测试代码: Benchmarks:Foo():int:this
  10. cmp rax, rcx ; 测试调用者是否是 Foo 方法
  11. jne SHORT G_M41830_IG05 ; 如果不是,则跳转到 G_M41830_IG05,回退到每次迭代中测试调用者的方式
  12. G_M41830_IG03: ; 否则,我们进入了最快的路径,这与 SumWithDirectCall 完全相同
  13. mov eax, 42
  14. add edi, eax
  15. inc ebx
  16. cmp ebx, 42
  17. jl SHORT G_M41830_IG03
  18. ...
  19. G_M41830_IG05:
  20. mov rax, qword ptr [rsi+18H]
  21. mov rcx, 0xD1FFAB1E ; 以下是基准测试代码: Benchmarks:Foo():int:this
  22. cmp rax, rcx ; 测试调用者是否是 Foo 方法
  23. jne SHORT G_M41830_IG09 ; 如果不是,则跳转到 G_M41830_IG09,回退到虚拟调用的慢速路径
  24. mov eax, 42 ; 否则,被调用者进行了取消虚拟化和内联优化
  25. G_M41830_IG06:
  26. add edi, eax
  27. inc ebx
  28. cmp ebx, 42
  29. jl SHORT G_M41830_IG05
  30. ...
  31. G_M41830_IG09:
  32. mov rcx, gword ptr [rsi+08H]
  33. call [rsi+18H]System.Func`1[int]:Invoke():int:this
  34. jmp SHORT G_M41830_IG06

在正常情况下,.NET 将测试委托的目标方法是否为指定的方法,如果是,将使用快速路径(IG03),否则将使用慢速路径(IG05 和 IG09)。在快速路径中,委托的目标方法被直接调用,而在慢速路径中,将通过虚拟调用或间接调用委托的目标方法。

这个优化可以使委托调用的性能与直接调用方法的性能相同。

这段代码实际上被优化成了:

  1. var sum = 0;
  2. if (f == Foo)
  3. for (var i = 0; i < 42; i++) sum += 42;
  4. else
  5. for (var i = 0; i < 42; i++)
  6. if (f == Foo) sum += 42;
  7. else sum += f();
  8. return sum;

现在在正常情况下,委托调用与直接调用方法的性能表现完全相同。

结尾

虽然 .NET 以前曾经在委托方面做出了一些糟糕的决定,但自 .NET 7 以来,它已经成功地解决了委托的性能问题。

祝编码愉快!

已获得作者授权
作者: hez2010
译者:InCerry
原文链接: https://medium.com/@skyake/the-evolution-of-delegate-performance-in-net-c8f23572b8b1

原文链接:https://www.cnblogs.com/InCerry/p/the-evolution-of-delegate-performance-in-net-c8f23572b8b1.html

 友情链接:直通硅谷  点职佳  北美留学生论坛

本站QQ群:前端 618073944 | Java 606181507 | Python 626812652 | C/C++ 612253063 | 微信 634508462 | 苹果 692586424 | C#/.net 182808419 | PHP 305140648 | 运维 608723728

W3xue 的所有内容仅供测试,对任何法律问题及风险不承担任何责任。通过使用本站内容随之而来的风险与本站无关。
关于我们  |  意见建议  |  捐助我们  |  报错有奖  |  广告合作、友情链接(目前9元/月)请联系QQ:27243702 沸活量
皖ICP备17017327号-2 皖公网安备34020702000426号