.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
.NET中委托性能的演变的更多相关文章
- C#不用union,而是有更好的方式实现 .net自定义错误页面实现 .net自定义错误页面实现升级篇 .net捕捉全局未处理异常的3种方式 一款很不错的FLASH时种插件 关于c#中委托使用小结 WEB网站常见受攻击方式及解决办法 判断URL是否存在 提升高并发量服务器性能解决思路
C#不用union,而是有更好的方式实现 用过C/C++的人都知道有个union,特别好用,似乎char数组到short,int,float等的转换无所不能,也确实是能,并且用起来十分方便.那C# ...
- C#中委托和事件
目 录 将方法作为方法的参数 将方法绑定到委托 更好的封装性 限制类型能力 范例说明 Observer 设计模式简介 实现范例的Observer 设计模式 .NET 框架中的委托与事件 为什么委托定义 ...
- 深度学习中的序列模型演变及学习笔记(含RNN/LSTM/GRU/Seq2Seq/Attention机制)
[说在前面]本人博客新手一枚,象牙塔的老白,职业场的小白.以下内容仅为个人见解,欢迎批评指正,不喜勿喷![认真看图][认真看图] [补充说明]深度学习中的序列模型已经广泛应用于自然语言处理(例如机器翻 ...
- 【翻译】.NET 5中的性能改进
[翻译].NET 5中的性能改进 在.NET Core之前的版本中,其实已经在博客中介绍了在该版本中发现的重大性能改进. 从.NET Core 2.0到.NET Core 2.1到.NET Core ...
- 优化Web中的性能
优化Web中的性能 简介 web的优化就是一场阻止http请求最终访问到数据库的战争. 优化的方式就是加缓存,在各个节点加缓存. web请求的流程及节点 熟悉流程及节点,才能定位性能的问题.而且优化的 ...
- Ionic中使用Chart.js进行图表展示以及在iOS/Android中的性能差异
Angular Chart 简介 在之前的文章中介绍了使用 Ionic 开发跨平台(iOS & Android)应用中遇到的一些问题的解决方案. 在更新0.1.3版本的过程中遇到了需要使用图表 ...
- 深入理解JavaScript中创建对象模式的演变(原型)
深入理解JavaScript中创建对象模式的演变(原型) 创建对象的模式多种多样,但是各种模式又有怎样的利弊呢?有没有一种最为完美的模式呢?下面我将就以下几个方面来分析创建对象的几种模式: Objec ...
- C#中委托和事件的区别实例解析
这篇文章主要介绍了C#中委托和事件的区别,并分别以实例形式展示了通过委托执行方法与通过事件执行方法,以及相关的执行流程与原理分析,需要的朋友可以参考下 本文实例分析了C#中委托和事件的区别,分享给大家 ...
- 使用ThinkPHP开发中MySQL性能优化的最佳21条经验
使用ThinkPHP开发中MySQL性能优化的最佳21条经验讲解,目前,数据库的操作越来越成为整个应用的性能瓶颈了,这点对于Web应用尤其明显.关于数据库的性能,这并不只是DBA才需要担心的事,而这更 ...
- 详解Objective-C中委托和协议
Objective-C委托和协议本没有任何关系,协议如前所述,就是起到C++中纯虚类的作用,对于“委托”则和协议没有关系,只是我们经常利用协议还实现委托的机制,其实不用协议也完全可以实现委托. AD: ...
随机推荐
- 解决通配符的匹配很全面, 但无法找到元素 'aop:config' 的声明
这是因为在applicationContext.xml文件中没有添加对应的地址 http://www.springframework.org/schema/aop http://www.springf ...
- PostScript语言教程(四、程序变量使用)
4.1.变量定义 POSTSCRIPT 变量 变量的定义是将比那两名和值用def进行关联类似 /ppi 75 def %将ppi定义为75 /ppi ppi 1 add def %将ppi + 1的值 ...
- 【2020NOI.AC省选模拟#2】A. 旋转
题目链接 原题解: 把每个点的坐标视为复数,那么每次询问就是区间求平均数(先求和然后除以个数).一个点绕着原点旋转就是乘以$(\cos 60^\circ +i\sin 60^\circ)$. 一个点绕 ...
- kvm介绍(1)
- Python的入门学习之 Day 7——from“夜曲编程”
Day 7 time: 2021.8.4. 今天主要将"if-else"再扩展, 得到"if-elif-else"模型.它与"if-else" ...
- 题目集4~6的总结性Blog
题目集4~6的总结性Blog (1)前言 在这三次作业中,主要考察了正则表达式以及类间的关系.在这三次作业中,相比之下,第四次以及第五次作业的难度明显高于第六次作业,题量与难度相较于以往的作业也有明显 ...
- bzoj 3309
奇怪的莫比乌斯反演... 题意:定义$f(n)$表示将$n$质因数分解后质因子的最高幂次,求$\sum_{i=1}^{a}\sum_{j=1}^{b}f(gcd(i,j))$ 首先肯定是反演嘛... ...
- redis 常用指令
redis指令有些相似,记忆起来不太容易,在此做一下整理 序号 类型 指令 参数 作用 例子 1 string set key value 存储一个 string 类型的值 set a aa 2 st ...
- 如何跳出forEach循环
for(let ii in this.listData){ console.log("提交前数据",ii) try{ this.listData[ii].forEach((el,i ...
- JMeter参数化(一)--CSV参数化
一.CSV Data Set Config 1.添加配置元件-CSV Data Set Config 其中,分隔符不能是参数化的值中的符号,否则会被截断. 2.调用 3.循环读取文件中数据 假设数据内 ...