译。NET7中的性能改进(五)
回 复 " 1 " 获 取 开 发 者 路 线 图
学 习 分 享 丨 作 者 / 郑 子 铭
这 是 D o t N e t N B 公 众 号 的 第 210 篇 原 创 文 章
原文 | Stephen Toub
翻译 | 郑子铭循环提升和克隆 (Loop Hoisting and Cloning)
我们之前看到PGO是如何与循环提升和克隆互动的,这些优化也有其他改进。
从历史上看,JIT对提升的支持仅限于将一个不变量提升到一个层级。
考虑一下这个例子:[Benchmark]
public void Compute()
{
for (int thousands = 0; thousands < 10; thousands++)
{
for (int hundreds = 0; hundreds < 10; hundreds++)
{
for (int tens = 0; tens < 10; tens++)
{
for (int ones = 0; ones < 10; ones++)
{
int n = ComputeNumber(thousands, hundreds, tens, ones);
Process(n);
}
}
}
}
}
static int ComputeNumber(int thousands, int hundreds, int tens, int ones) =>
(thousands * 1000) +
(hundreds * 100) +
(tens * 10) +
ones;
[MethodImpl(MethodImplOptions.NoInlining)]
static void Process(int n) { }
乍一看,你可能会说:"有什么可提升的,n的计算需要所有的循环输入,而所有的计算都在ComputeNumber中。" 但从编译器的角度来看,ComputeNumber函数是可内联的,因此在逻辑上可以成为其调用者的一部分,n的计算实际上被分成了多块,每块都可以被提升到不同的层级,例如,十的计算可以提升出一层,百的提升出两层,千的提升出三层。下面是[DisassemblyDiagnoser]对.NET 6的输出。; Program.Compute()
push r14
push rdi
push rsi
push rbp
push rbx
sub rsp,20
xor esi,esi
M00_L00:
xor edi,edi
M00_L01:
xor ebx,ebx
M00_L02:
xor ebp,ebp
imul ecx,esi,3E8
imul eax,edi,64
add ecx,eax
lea eax,[rbx+rbx*4]
lea r14d,[rcx+rax*2]
M00_L03:
lea ecx,[r14+rbp]
call Program.Process(Int32)
inc ebp
cmp ebp,0A
jl short M00_L03
inc ebx
cmp ebx,0A
jl short M00_L02
inc edi
cmp edi,0A
jl short M00_L01
inc esi
cmp esi,0A
jl short M00_L00
add rsp,20
pop rbx
pop rbp
pop rsi
pop rdi
pop r14
ret
; Total bytes of code 84
我们可以看到,这里发生了一些提升。毕竟,最里面的循环(标记为M00_L03)只有五条指令:增加ebp(这时是1的计数器值),如果它仍然小于0xA(10),就跳回到M00_L03,把r14中的任何数字加到1上。很好,所以我们已经把所有不必要的计算从内循环中取出来了,只剩下把1的位置加到其余的数字中。让我们再往外走一级。M00_L02是十位数循环的标签。我们在这里看到了什么?有问题。两条指令imul ecx,esi,3E8和imul eax,edi,64正在进行千位数1000和百位数100的操作,突出表明这些本来可以进一步提升的操作被卡在了最下层的循环中。现在,这是我们在.NET 7中得到的结果,在dotnet/runtime#68061中,这种情况得到了改善:; Program.Compute()
push r15
push r14
push r12
push rdi
push rsi
push rbp
push rbx
sub rsp,20
xor esi,esi
M00_L00:
xor edi,edi
imul ebx,esi,3E8
M00_L01:
xor ebp,ebp
imul r14d,edi,64
add r14d,ebx
M00_L02:
xor r15d,r15d
lea ecx,[rbp+rbp*4]
lea r12d,[r14+rcx*2]
M00_L03:
lea ecx,[r12+r15]
call qword ptr [Program.Process(Int32)]
inc r15d
cmp r15d,0A
jl short M00_L03
inc ebp
cmp ebp,0A
jl short M00_L02
inc edi
cmp edi,0A
jl short M00_L01
inc esi
cmp esi,0A
jl short M00_L00
add rsp,20
pop rbx
pop rbp
pop rsi
pop rdi
pop r12
pop r14
pop r15
ret
; Total bytes of code 99
现在注意一下这些imul指令的位置。有四个标签,每个标签对应一个循环,我们可以看到最外层的循环有imul ebx,esi,3E8(用于千位计算),下一个循环有imul r14d,edi,64(用于百位计算),突出表明这些计算被提升到了适当的层级(十位和一位计算仍然在正确的位置)。
在克隆方面有了更多的改进。以前,循环克隆只适用于从低值到高值的1次迭代循环。有了dotnet/runtime#60148,与上值的比较可以是<=,而不仅仅是<。有了dotnet/runtime#67930,向下迭代的循环也可以被克隆,增量和减量大于1的循环也是如此。private int[] _values = Enumerable.Range(0, 1000).ToArray();
[Benchmark]
[Arguments(0, 0, 1000)]
public int LastIndexOf(int arg, int offset, int count)
{
int[] values = _values;
for (int i = offset + count - 1; i >= offset; i--)
if (values[i] == arg)
return i;
return 0;
}
如果没有循环克隆,JIT不能假设offset到offset+count都在范围内,因此对数组的每个访问都需要进行边界检查。有了循环克隆,JIT可以生成一个没有边界检查的循环版本,并且只在它知道所有的访问都是有效的时候使用。这正是现在.NET 7中发生的事情。下面是我们在.NET 6中得到的情况。; Program.LastIndexOf(Int32, Int32, Int32)
sub rsp,28
mov rcx,[rcx+8]
lea eax,[r8+r9+0FFFF]
cmp eax,r8d
jl short M00_L01
mov r9d,[rcx+8]
nop word ptr [rax+rax]
M00_L00:
cmp eax,r9d
jae short M00_L03
movsxd r10,eax
cmp [rcx+r10*4+10],edx
je short M00_L02
dec eax
cmp eax,r8d
jge short M00_L00
M00_L01:
xor eax,eax
add rsp,28
ret
M00_L02:
add rsp,28
ret
M00_L03:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 72
注意在核心循环中,在标签M00_L00处,有一个边界检查(cmp eax,r9d and jae short M00_L03,它跳到一个调用CORINFO_HELP_RNGCHKFAIL)。而这里是我们在.NET 7中得到的结果。; Program.LastIndexOf(Int32, Int32, Int32)
sub rsp,28
mov rax,[rcx+8]
lea ecx,[r8+r9+0FFFF]
cmp ecx,r8d
jl short M00_L02
test rax,rax
je short M00_L01
test ecx,ecx
jl short M00_L01
test r8d,r8d
jl short M00_L01
cmp [rax+8],ecx
jle short M00_L01
M00_L00:
mov r9d,ecx
cmp [rax+r9*4+10],edx
je short M00_L03
dec ecx
cmp ecx,r8d
jge short M00_L00
jmp short M00_L02
M00_L01:
cmp ecx,[rax+8]
jae short M00_L04
mov r9d,ecx
cmp [rax+r9*4+10],edx
je short M00_L03
dec ecx
cmp ecx,r8d
jge short M00_L01
M00_L02:
xor eax,eax
add rsp,28
ret
M00_L03:
mov eax,ecx
add rsp,28
ret
M00_L04:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 98
注意到代码大小是如何变大的,以及现在有两个循环的变化:一个在 M00_L00,一个在 M00_L01。第二个,M00_L01,有一个分支到那个相同的调用 CORINFO_HELP_RNGCHKFAIL,但第一个没有,因为那个循环最终只会在证明偏移量、计数和 _values.Length 是这样的,即索引将总是在界内之后被使用。
dotnet/runtime#59886使JIT能够选择不同的形式来发出选择快速或慢速循环路径的条件,例如,是否发出所有的条件,与它们一起,然后分支(if (! (cond1 & cond2)) goto slowPath),或者是否单独发出每个条件(if (!cond1) goto slowPath; if (!cond2) goto slowPath)。 dotnet/runtime#66257使循环变量被初始化为更多种类的表达式时,循环克隆得以启动(例如,for (int fromindex = lastIndex - lengthToClear; ...) )。dotnet/runtime#70232增加了JIT克隆具有更广泛操作的主体的循环的意愿。折叠、传播和替换 (Folding, propagation, and substitution)
常量折叠是一种优化,编译器在编译时计算只涉及常量的表达式的值,而不是在运行时生成代码来计算该值。在.NET中有多个级别的常量折叠,有些常量折叠由C#编译器执行,有些常量折叠由JIT编译器执行。例如,给定C#代码。[Benchmark]
public int A() => 3 + (4 * 5);
[Benchmark]
public int B() => A() * 2;
C#编译器将为这些方法生成IL,如下所示。.method public hidebysig instance int32 A () cil managed
{
.maxstack 8
IL_0000: ldc.i4.s 23
IL_0002: ret
}
.method public hidebysig instance int32 B () cil managed
{
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance int32 Program::A()
IL_0006: ldc.i4.2
IL_0007: mul
IL_0008: ret
}
你可以看到,C#编译器已经计算出了3+(4*5)的值,因为方法A的IL只是包含了相当于返回23;的内容。然而,方法B包含了相当于return A() * 2;的内容,突出表明C#编译器所进行的常量折叠只是在方法内部进行的。现在是JIT生成的内容。; Program.A()
mov eax,17
ret
; Total bytes of code 6
; Program.B()
mov eax,2E
ret
; Total bytes of code 6
方法A的汇编并不特别有趣;它只是返回相同的值23(十六进制0x17)。但方法B更有趣。JIT已经内联了从B到A的调用,将A的内容暴露给B,这样JIT就有效地将B的主体视为等同于返回23*2;。在这一点上,JIT可以做自己的常量折叠,它将B的主体转化为简单的返回46(十六进制0x2e)。常量传播与常量折叠有着错综复杂的联系,本质上就是你可以将一个常量值(通常是通过常量折叠计算出来的)替换到进一步的表达式中,这时它们也可以被折叠。
JIT长期以来一直在进行恒定折叠,但它在.NET 7中得到了进一步改善。常量折叠的改进方式之一是暴露出更多需要折叠的值,这往往意味着更多的内联。dotnet/runtime#55745帮助inliner理解像M(constant + constant)这样的方法调用(注意到这些常量可能是其他方法调用的结果)本身就是在向M传递常量,而常量被传递到方法调用中是在提示inliner应该考虑更积极地进行内联,因为将该常量暴露给被调用者的主体有可能大大减少实现被调用者所需的代码量。JIT之前可能已经内联了这样的方法,但是当涉及到内联时,JIT是关于启发式方法和产生足够的证据来证明值得内联的东西;这有助于这些证据。例如,这种模式出现在TimeSpan的各种FromXx方法中。例如,TimeSpan.FromSeconds被实现为。public static TimeSpan FromSeconds(double value) => Interval(value, TicksPerSecond); // TicksPerSecond is a constant
并且,为了这个例子的目的,避开了参数验证,Interval是。private static TimeSpan Interval(double value, double scale) => IntervalFromDoubleTicks(value * scale);
private static TimeSpan IntervalFromDoubleTicks(double ticks) => ticks == long.MaxValue ? TimeSpan.MaxValue : new TimeSpan((long)ticks);
如果所有的东西都被内联,意味着FromSeconds本质上是。public static TimeSpan FromSeconds(double value)
{
double ticks = value * 10_000_000;
return ticks == long.MaxValue ? TimeSpan.MaxValue : new TimeSpan((long)ticks);
}
如果值是一个常数,比方说5,整个事情可以被常数折叠(在ticks == long.MaxValue分支上消除了死代码),简单地说。return new TimeSpan(50_000_000);
我就不说.NET 6的程序集了,但在.NET 7上,用这样的基准来衡量。[Benchmark]
public TimeSpan FromSeconds() => TimeSpan.FromSeconds(5);
我们现在得到的是简单和干净。; Program.FromSeconds()
mov eax,2FAF080
ret
; Total bytes of code 6
另一个改进常量折叠的变化包括来自@SingleAccretion的dotnet/runtime#57726,它在一种特殊的情况下解除了常量折叠,这种情况有时表现为对从方法调用返回的结构进行逐字段赋值。作为一个小例子,考虑这个微不足道的属性,它访问了Color.DarkOrange属性,而后者又做了new Color(KnownColor.DarkOrange)。[Benchmark]
public Color DarkOrange() => Color.DarkOrange;
在.NET 6中,JIT生成了这个。; Program.DarkOrange()
mov eax,1
mov ecx,39
xor r8d,r8d
mov [rdx],r8
mov [rdx+8],r8
mov [rdx+10],cx
mov [rdx+12],ax
mov rax,rdx
ret
; Total bytes of code 32
有趣的是,一些常量(39,是KnownColor.DarkOrange的值,和1,是一个私有的StateKnownColorValid常量)被加载到寄存器中(mov eax, 1 then mov ecx, 39),然后又被存储到被返回的颜色结构的相关位置(mov [rdx+12],ax and mov [rdx+10],cx)。在.NET 7中,它现在产生了。; Program.DarkOrange()
xor eax,eax
mov [rdx],rax
mov [rdx+8],rax
mov word ptr [rdx+10],39
mov word ptr [rdx+12],1
mov rax,rdx
ret
; Total bytes of code 25
直接将这些常量值分配到它们的目标位置(mov word ptr [rdx+12],1 和 mov word ptr [rdx+10],39)。其他有助于常量折叠的变化包括来自@SingleAccretion的dotnet/runtime#58171和来自@SingleAccretion的dotnet/runtime#57605。
然而,一大类改进来自与传播有关的优化,即正向替换。考虑一下这个愚蠢的基准。[Benchmark]
public int Compute1() => Value + Value + Value + Value + Value;
[Benchmark]
public int Compute2() => SomethingElse() + Value + Value + Value + Value + Value;
private static int Value => 16;
[MethodImpl(MethodImplOptions.NoInlining)]
private static int SomethingElse() => 42;
如果我们看一下在.NET 6上为Compute1生成的汇编代码,它看起来和我们希望的一样。我们将Value加了5次,Value被简单地内联,并返回一个常量值16,因此我们希望为Compute1生成的汇编代码实际上只是返回值80(十六进制0x50),这正是发生的情况。; Program.Compute1()
mov eax,50
ret
; Total bytes of code 6
但Compute2有点不同。代码的结构是这样的:对SomethingElse的额外调用最终会稍微扰乱JIT的分析,而.NET 6最终会得到这样的汇编代码。; Program.Compute2()
sub rsp,28
call Program.SomethingElse()
add eax,10
add eax,10
add eax,10
add eax,10
add eax,10
add rsp,28
ret
; Total bytes of code 29
我们不是用一个mov eax, 50来把数值0x50放到返回寄存器中,而是用5个独立的add eax, 10来建立同样的0x50(80)的数值。这......并不理想。
事实证明,许多JIT的优化都是在解析IL的过程中创建的树状数据结构上进行的。在某些情况下,当它们接触到更多的程序时,优化可以做得更好,换句话说,当它们所操作的树更大,包含更多需要分析的内容时。然而,各种操作可以将这些树分解成更小的、单独的树,比如作为内联的一部分而创建的临时变量,这样做可以抑制这些操作。为了有效地将这些树缝合在一起,我们需要一些东西,这就是前置置换 (forward substitution)。你可以把前置置换看成是CSE的逆向操作;与其通过计算一次数值并将其存储到一个临时变量中来寻找重复的表达式并消除它们,不如前置置换来消除这个临时变量并有效地将表达式树移到它的使用位置。显然,如果这样做会否定CSE并导致重复工作的话,你是不想这样做的,但是对于那些只定义一次并使用一次的表达式来说,这种前置传播是很有价值的。 dotnet/runtime#61023添加了一个最初的有限的前置置换版本,然后dotnet/runtime#63720添加了一个更强大的通用实现。随后,dotnet/runtime#70587将其扩展到了一些SIMD向量,然后dotnet/runtime#71161进一步改进了它,使其能够替换到更多的地方(在这种情况下是替换到调用参数)。有了这些,我们愚蠢的基准现在在.NET 7上产生如下结果。; Program.Compute2()
sub rsp,28
call qword ptr [7FFCB8DAF9A8]
add eax,50
add rsp,28
ret
; Total bytes of code 18
原文链接
Performance Improvements in .NET 7
推荐阅读: 【译】.NET 7 中的性能改进(四)
【译】.NET 7 中的性能改进(三)
【译】.NET 7 中的性能改进(二)
【译】.NET 7 中的性能改进(一)
一款针对EF Core轻量级分表分库、读写分离的开源项目
跟我一起 掌握AspNetCore底层技术和构建原理
点击下方卡片关注DotNet NB
一起交流学习
▲ 点击上方卡片关注DotNet NB,一起交流学习
请在公众号后台 回复 【路线图】 获取.NET 2021开发者路线图 回复 【原创内容】 获取公众号原创内容 回复 【峰会视频】 获取.NET Conf开发者大会视频 回复 【个人简介】 获取作者个人简介 回复 【年终总结】 获取作者年终总结 回复 【 加群 】 加入DotNet NB 交流学习群
和我一起,交流学习,分享心得。
冬春新航季启动多家航司宣布恢复增加客运航线30日开始,全国民航正式执行冬春航季航班计划。随着新航季的到来,多家国内航空公司从10月底,陆续恢复并加密多条国内及国际航线。在北京,首都国际机场和大兴国际机场的客运航班量均有所增
最新崩盘跑路问题平台1智慧赟这个所谓的区块链项目陷阱多多,智慧赟项目隶属于道赟有限公司,该公司称在SBANK区块链技术的基础上,自主研发了智慧赟支付平台。而且宣称自己建立消费经济一体化生态循环系统,实
令人惊叹!显微镜下的硅藻竟然神似古罗马斗兽场硅藻是一类具有色素体的单细胞植物,常由几个或很多细胞个体连结成各式各样的群体。硅藻的形态多种多样。硅藻常用一分为二的繁殖方法产生。分裂之后,在原来的壳里,各产生一个新的下壳。盒面和
人脸识别生活中到处都是现在人脸识别技术在一些特定场景下已经非常成熟了,达到了很高的精度,且已经得到大范围应用。由文章人脸识别技术发展现状及未来趋势1可知2015年以来,我国相继出台了关于银行业金融机构远
揭秘令人毛骨悚然的鸟粪虫揭示相当令人毛骨悚然的都灵昆虫(偶尔蜘蛛)有一个蜘蛛的同伴,名叫都灵雄性(鸟粪),有点可怜的名字。它看起来不像鸟粪,但它看起来不像蜘蛛。在这个同伴中,奥托诺洪达马西给人一种特别令人
遍地仙股开花随着经济的下滑,A股出现了大量的23元股票,季报出来了,又是一片惊悚。但是我们发现,个股价格已经很低了,而股指却在2900点,根本的原因是A股变胖了,横向发展了。体量越大,提供发行
杨元庆走的这一步棋,你看得懂?中国数字经济领导企业联想集团此前在北京举行了202223财年誓师大会。在会中,联想集团董事长兼CEO杨元庆对联想的前三年的净利润做了总结,联想营业额再创新高,达成新的里程碑。同时,
AI绘画来了,不必感到忧心忡忡作者熊志AI机器人先后击败了人类顶尖的围棋选手电竞冠军扑克牌选手,如今又瞄上了画师。张择端用了1年时间绘就了传世名画清明上河图,达芬奇创作蒙娜丽莎大约花了3年。如今在键盘简单敲下几
Python100天26python输入与输出input与printInputOutput输入与输出计算机中输入设备有键盘鼠标摄像头扫描仪光笔手写输入板游戏杆语音输入装置等。输入设备是向计算机输入数据和信息的设备,是计算机与用户或其他设备通信的桥梁
外媒全球半导体市场开始重新洗牌了美之所以能够成为全球科技霸主,是因为其掌握比较多的核心技术,全球科技巨头企业的生产线上或多或少都使用的有美技术。也正因为如此,美才可以随意地修改芯片规则,限制出货,来阻碍我国科技领
借助美元霸权美国挤压多国货币政策空间随着美联储激进加息,美元持续走强,多国面临输入型通胀加剧和本币贬值的危机。10月28日,日本总务省数据显示,日本东京都10月核心消费价格指数同比增长3。4,该指数已连续14个月上涨
关系再好也不能说的四句话关系再好也不能说的四句话,一定要烂在肚子里。尤其是第三句,一旦说了你就会后悔不已,如果你不信的话听我说完你慢慢品。一不要把家里不开心的事儿去给朋友倾诉,你要记住,没有人会同情你,反
什么时候使我不再任性?什么时候使我不再任性什么时候使我不再任性?是上帝让我遇见了你。连加个微信我都不敢向你直说。难道心里藏着什么秘密,不愿让任何人发现?美丽动人往往是阻拦自由行为的藩篱。我只能透过稀疏的
一个人欠缺什么,就要给出去什么头条创作挑战赛宇宙间有一个法则叫吸引力法则。说的是万事万物之间有吸引力,物体之间如果存在着相同的频率,他们就会不自觉的靠近对方,不管是人,还是事,还是物。当你心甘情愿给出去的东西,
壬寅中秋节敬祝朋友圈朋友!中秋快乐!幸福安康!中秋月合家欢月何时圆?今晚磨(末),明日修(休),十五十六夜夜圆。月圆华光满,金蟾献瑞,嫦娥送福,天上人间共婵娟。疫什时净?上月除,此月无。八月
富养自己的3种方式你要搞清楚自己人生的剧本不是你父母的续集,不是你子女的前传,更不是你朋友的外篇。从现在开始,富养自己,做个外在年轻内在丰盈的人。NO。1富养身体,年龄只是数字药补不如食补,食补不如
一篇好文章,用心之成果樱花愿我能把你忘记。樱花树下,漫地淡粉。春风微袭,一只只粉色的蝴蝶翻飞,落在了树下的这个沉思的少女身上。这一个孤单的身影一落落。落落从小就喜欢樱花,她觉得樱花很纯净,淡雅,每每想起
人老了,管好自己的嘴,话有五不宜01hr古人云舌为利害本,口是祸福门。小孩子乱说话,是童言无忌,没有人会计较老人乱说话,就是有意而为,属于缺德。我们不能总是口无遮拦,年纪越大,越要收敛自己。如果要开口,就是口吐芬
编程语言这么多,如何选择入门语言?编程语言那么多,该怎么选呢?作为编程小白,起初都会有这样的困扰。了解清楚编程语言的种类,无论是对找工作还是规划将来的职业发展,都有很大的好处。今天先和小华来了解下编程的种类吧1。C
世预赛努尔基奇21分9板ampampamp富尼耶24分6失误波黑双加时胜法国直播吧8月28日讯男篮世预赛欧洲区比赛,波黑经历双加时以9690战胜强敌法国。此役,努尔基奇砍下21分9篮板3助攻2抢断1盖帽,领衔波黑全队4人得分上双前NBA球员扎南穆萨贡献17
10!C罗连场替补卡塞米罗英超首秀B费制胜球曼联夺取2连胜北京时间8月27日晚,英超第4轮开踢,一场焦点战役在圣玛丽球场打响,南安普顿坐镇主场对阵红魔曼联。两支球队本赛季前2轮表现都不好,南安普顿1平1负,曼联在惨败给布伦特福德后更是惨遭
10!英超头号黑马诞生,曼联苦主4轮抢10分,力压5大豪门排第二英超第4轮上演疯狂一夜,利物浦90狂胜伯恩茅斯,曼城42逆转水晶宫,切尔西21击败莱斯特城,曼联10战胜南安普顿,BIG6球队的4大豪门全部赢球。此外,还有一支球队不可忽视,即是布