<返回更多

在.Net 7中性能改进-常量折叠

2022-09-27    秋风技术
加入收藏

前言

本文是 Performance Improvements in .NET 7 Folding(折叠), propagation(传播), and substitution(替换) 部分的翻译.下面开始正文:

//原文地址: 
// https://devblogs.microsoft.com/dotnet/performance_improvements_in_net_7/

常量折叠是一种优化,编译器在编译时计算只涉及常量的表达式的值,而不是在运行时生成代码来计算值.在.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()  //调用方法A,可以看到没有常量折叠和常量传播
    IL_0006: ldc.i4.2
    IL_0007: mul
    IL_0008: ret
}

您可以看到,C#编译器已经计算出了 3 +(4*5)的值,因为方法A的IL包含了等价的 return 23;但是,方法 B包含等价的 return A()* 2; ,强调C#编译器执行的常量折叠只是在方法内部.下面是JIT生成的结果:

// Program.A()
       mov       eax,17    //17是十六进制,为十进制的23
       ret
// Total bytes of code 6

// Program.B()
       mov       eax,2E    //2E为十六进制,为十进制的46
       ret
// Total bytes of code 6

方法A的汇编代码是不是特别有趣,它只是返回相同的值23(十六进制0x17).但是方法B更有趣.JIT内联了从 B到 A的调用,将 A的内容暴露给 B,这样JIT就有效地将 B的主体看作是等价于 return 23 * 2;此时,JIT可以完成自己的常量折叠,并将B的主体转换为简单地返回46(十六进制0x2e).常量传播与常量折叠有着错综复杂的联系,本质上就是可以将常量值(通常是通过常量折叠计算的值)替换为进一步的表达式,此时它们也可以被折叠.

JIT长期以来一直在执行常量折叠,但它在.NET7中得到了进一步改进.常量折叠可以改进的方法之一是公开更多要折叠的值,这意味着更多的内联.dotnet/runtime#55745帮助理解内联,像M(constant + constant) (注意这些常量可能是其他方法调用的结果)这样的方法调用本身就是将常量传递给M,而传递给方法调用的常量是对内联线的提示,它应该考虑更积极地内联,因为将该常量公开给被调用方的主体可能会显著减少实现被调用方所需的代码量.JIT之前可能已经内联了这样一个方法,但当涉及内联时,JIT都是关于启发式和生成足够的证据来证明内联是值得的;这有助于证明这一点.例如,该模式显示在TimeSpan上的各种FromXx方法中.例如TimeSpan.FromSeconds实现为:

// TicksPerSecond is a constant
public static TimeSpan FromSeconds(double value) => Interval(value, TicksPerSecond);

并且,为了本例的目的,避免参数验证, 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);
}

如果value是一个常量,比如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  //2FAF080为5*1000*1000
       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,然后mov ecx,39)中,然后被存储到返回的Color结构的相关位置(mov[rdx+12],ax和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).其他变化贡献常量折叠包括dotnet/runtime#58171从@SingleAccretion和dotnet/runtime#57605从@SingleAccretion .

然而,一个很大的改进类别来自与传播相关的优化,即前向替换.考虑一下这个不太好的基准测试:

[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  //内联后为80(16进制是0x50)
       ret
// Total bytes of code 6

但是Compute2生成汇编代码有点不同.代码的结构是这样的,对SomethingElse的额外调用最终会略微干扰JIT的分析,而.Net 6最终会得到这样的汇编代码:

// Program.Compute2()
       sub       rsp,28
       call      Program.SomethingElse()
       add       eax,10		//10为16进制16的值
       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的一部分创建的树数据结构.在某些情况下,当它们所操作的树更大,包含更多要分析的内容时,优化可以做得更好.但是,各种操作可以将这些树分解为更小的、单独的树,例如使用作为内联一部分创建的临时变量,这样做可以抑制这些操作.为了有效地将这些树组合一起,需要一些东西,那就是正向替换.你可以把正向替换想象成逆向的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		//在.Net 6生成汇编代码,需要5次add相加操作,这里直接用5次相加的值
       add       rsp,28
       ret
// Total bytes of code 18
声明:本站部分内容来自互联网,如有版权侵犯或其他问题请与我们联系,我们将立即删除或处理。
▍相关推荐
更多资讯 >>>