.NET 7 中的性能改进(七)

原文 | Stephen Toub

翻译 | 郑子铭

Arm64

在.NET 7中,大量的努力用于使Arm64的代码生成与x64的代码生成一样好或更好。我已经讨论了一些与架构无关的PR,还有一些是专门针对Arm的,但还有很多。我们来列举其中的一些。.

寻址模式 (Addressing modes)

"寻址模式 "是指如何指定指令的操作数的术语。它可以是实际的值,也可以是应该加载一个值的地址,还可以是包含该值的寄存器,等等。Arm支持 "缩放 "寻址模式,通常用于对数组进行索引,其中每个元素的大小被提供,指令按指定的比例 "缩放 "所提供的偏移量。dotnet/runtime#60808使JIT能够利用这种寻址模式。更普遍的是,dotnet/runtime#70749使JIT在访问托管数组的元素时使用寻址模式。dotnet/runtime#66902改进了元素类型为字节时的寻址模式。dotnet/runtime#65468改进了用于浮点的寻址模式。dotnet/runtime#67490实现了SIMD向量的寻址模式,特别是对于具有未缩放索引的负载。

更好的指令选择 (Better instruction selection)

dotnet/runtime#61037教JIT如何识别整数的(a * b) + c模式,并将其折叠成一条madd或msub指令,而dotnet/runtime#66621对a - (b * c)和msub也有同样的作用。dotnet/runtime#61045使JIT能够识别某些常数位移操作(在代码中显式或隐式的各种形式的托管数组访问)并发出sbfiz/ubfiz指令。dotnet/runtime#70599、dotnet/runtime#66407和dotnet/runtime#65535都处理了各种形式的优化a % b。来自@SeanWoo的dotnet/runtime#61847删除了一个不必要的movi,它是设置一个取消引用的指针到一个常量值的一部分。来自@SingleAccretion的dotnet/runtime#57926使计算一个64位结果作为两个32位整数的乘法可以用smull/umull完成。而dotnet/runtime#61549用uxtw/sxtw/lsl将带符号扩展或零扩展的加法折叠成一条加法指令,而dotnet/runtime#62630在ldr指令后丢弃多余的零扩展。

矢量化 (Vectorization)

dotnet/runtime#64864增加了新的AdvSimd.LoadPairVector64/AdvSimd.LoadPairVector128硬件本征。

归零 (Zeroing)

很多操作都需要将状态设置为零,比如将一个方法中的所有引用局部初始化为零,作为该方法序幕的一部分(这样GC就不会看到并试图跟踪垃圾引用)。虽然这种功能以前是矢量的,但dotnet/runtime#63422使其能够在Arm上使用128位宽度的矢量指令来实现。而dotnet/runtime#64481改变了用于清零的指令序列,以避免不必要的清零,释放额外的寄存器,并使CPU能够识别各种指令序列并更好地优化。

内存模型 (Memory Model)

dotnet/runtime#62895使存储障碍尽可能地被使用,而不是完全障碍,并对易失性变量使用单向障碍。dotnet/runtime#67384使易失性读/写可以用ldapr指令实现,而dotnet/runtime#64354使用更便宜的指令序列来处理易失性间接操作。还有dotnet/runtime#70600,它能使LSE Atomics用于Interlocked操作;dotnet/runtime#71512,它能在Unix机器上使用atomics指令;以及dotnet/runtime#70921,它能实现同样的功能,但在Windows上。

JIT助手 (JIT helpers)

虽然在逻辑上是运行时的一部分,但JIT实际上与运行时的其他部分是隔离的,只通过一个接口与之交互,使JIT与VM(虚拟机)的其他部分进行通信。那么,有大量的VM功能是JIT赖以获得良好性能的。

dotnet/runtime#65738重写了各种 "存根 (stubs)",使之更有效率。存根是一些微小的代码,用于执行一些检查,然后将执行重定向到其他地方。例如,当一个接口的调度调用站点预计只用于该接口的单一实现时,JIT可能会采用一个 "调度存根",将对象的类型与它所缓存的单一类型进行比较,如果它们相等,就跳转到正确的目标。当一个PR包含了运行时所针对的每个架构的大量汇编代码时,你就知道你已经进入了运行时的最核心区域。它得到了回报;在我们的自动化性能测试套件中,有一个来自.NET周围的虚拟小组审查性能改进和回归,并将这些归因于可能是原因的PR(这大部分是自动化的,但需要一些人为的监督)。当一个PR被合并几天后,性能信息已经稳定下来,你会看到像这个PR上的大量评论,这总是很好的。

.NET 7 中的性能改进(七)

对于任何熟悉泛型并对性能感兴趣的人来说,你可能听说过这样的说法:泛型虚拟方法是相对昂贵的。相对而言,它们的确很贵。例如,在.NET 6上,这段代码。

private Example _example = new Example();

[Benchmark(Baseline = true)] public void GenericNonVirtual() => _example.GenericNonVirtual<Example>();
[Benchmark] public void GenericVirtual() => _example.GenericVirtual<Example>();

class Example
{
    [MethodImpl(MethodImplOptions.NoInlining)]
    public void GenericNonVirtual<T>() { }

    [MethodImpl(MethodImplOptions.NoInlining)]
    public virtual void GenericVirtual<T>() { }
}

结果是。

方法 平均值 比率
GenericNonVirtual 0.4866 ns 1.00
GenericVirtual 6.4552 ns 13.28

dotnet/runtime#65926稍微缓解了一下痛苦。一些成本来自于在运行时的哈希表中查找一些缓存信息,就像许多映射实现一样,这涉及到计算一个哈希代码并使用mod操作来映射到正确的桶。dotnet/runtime周围的其他哈希表实现,包括Dictionary<,>、HashSet<,>和ConcurrentDictionary<,>以前都切换到 "fastmod "实现;这个PR对这个EEHashtable也是如此,它被用作CORINFO_GENERIC_HANDLE JIT辅助函数采用的一部分。

方法 运行时 平均值 比率
GenericVirtual .NET 6.0 6.475 ns 1.00
GenericVirtual .NET 7.0 6.119 ns 0.95

改善的程度还不足以让我们开始推荐人们使用它们,但5%的改善可以让我们摆脱一点刺痛。

Grab Bag

要涵盖进入JIT的每一个性能变化几乎是不可能的,我也不打算尝试。但是,还有这么多的性能变化,我不能把它们都置之不理,所以这里还有一些快报。

dotnet/runtime#58727来自@benjamin-hodgson。给出一个表达式,如(byte)x | (byte)y,可以变形为(byte)(x | y),这可以优化一些mov。

private int _x, _y;

[Benchmark]
public int Test() => (byte)_x | (byte)_y;
; *** .NET 6 ***
; Program.Test(Int32, Int32)
       movzx     eax,dl
       movzx     edx,r8b
       or        eax,edx
       ret
; Total bytes of code 10

; *** .NET 7 ***
; Program.Test(Int32, Int32)
       or        edx,r8d
       movzx     eax,dl
       ret
; Total bytes of code 7

dotnet/runtime#67182。在支持BMI2的机器上,可以用shlx、sarx和shrx指令进行64位移位。

[Benchmark]
[Arguments(123, 1)]
public ulong Shift(ulong x, int y) => x << y;
; *** .NET 6 ***
; Program.Shift(UInt64, Int32)
       mov       ecx,r8d
       mov       rax,rdx
       shl       rax,cl
       ret
; Total bytes of code 10

; *** .NET 7 ***
; Program.Shift(UInt64, Int32)
       shlx      rax,rdx,r8
       ret
; Total bytes of code 6

dotnet/runtime#69003来自@SkiFoD。模式~x + 1可以被改变为二元互补的否定。

[Benchmark]
[Arguments(42)]
public int Neg(int i) => ~i + 1;
; *** .NET 6 ***
; Program.Neg(Int32)
       mov       eax,edx
       not       eax
       inc       eax
       ret
; Total bytes of code 7

; *** .NET 7 ***
; Program.Neg(Int32)
       mov       eax,edx
       neg       eax
       ret
; Total bytes of code 5

dotnet/runtime#61412来自@SkiFoD。一个表达式X & 1 == 1来测试一个数字的底层位是否被设置,可以改为更便宜的X & 1(在C#中如果没有后面的 != 0实际上是无法表达的)。

[Benchmark]
[Arguments(42)]
public bool BitSet(int x) => (x & 1) == 1;
; *** .NET 6 ***
; Program.BitSet(Int32)
       test      dl,1
       setne     al
       movzx     eax,al
       ret
; Total bytes of code 10

; *** .NET 7 ***
; Program.BitSet(Int32)
       mov       eax,edx
       and       eax,1
       ret
; Total bytes of code 6

dotnet/runtime#63545来自@Wraith2。表达式x & (x - 1)可以被降低到blsr指令。

[Benchmark]
[Arguments(42)]
public int ResetLowestSetBit(int x) => x & (x - 1);
; *** .NET 6 ***
; Program.ResetLowestSetBit(Int32)
       lea       eax,[rdx+0FFFF]
       and       eax,edx
       ret
; Total bytes of code 6

; *** .NET 7 ***
; Program.ResetLowestSetBit(Int32)
       blsr      eax,edx
       ret
; Total bytes of code 6

dotnet/runtime#62394。/和%由一个向量的.Count组成,并没有认识到Count可以是无符号的,但这样做会导致更好的代码基因。

[Benchmark]
[Arguments(42u)]
public long DivideByVectorCount(uint i) => i / Vector<byte>.Count;
; *** .NET 6 ***
; Program.DivideByVectorCount(UInt32)
       mov       eax,edx
       mov       rdx,rax
       sar       rdx,3F
       and       rdx,1F
       add       rax,rdx
       sar       rax,5
       ret
; Total bytes of code 21

; *** .NET 7 ***
; Program.DivideByVectorCount(UInt32)
       mov       eax,edx
       shr       rax,5
       ret
; Total bytes of code 7

dotnet/runtime#60787. .NET 6中的循环对齐为JIT处理循环对齐的原因和方式提供了一个非常好的探索。这个PR进一步扩展了这一点,试图将发出的对齐指令 "隐藏 "在可能已经存在的无条件jmp后面,以尽量减少处理器必须获取和解码nops的影响。

GC

"Regions "是垃圾收集器 (garbage collector)(GC)的一项功能,已经进行了很多年了。从dotnet/runtime#64688开始,它在.NET 7的64位进程中被默认启用,但与其他多年的功能一样,大量的PR使它成为现实。在3万英尺的水平上,"区域 "取代了目前在GC堆上管理内存的 "段 "的方法;而不是有几个巨大的内存段(例如,每个1GB),通常与一个世代1:1相关联,GC代替维护许多,许多较小的区域(例如,每个4MB)作为自己的实体。这使得GC在操作上更加灵活,比如从一代到另一代的内存区域的重新使用。关于区域的更多信息,来自GC主要开发者的博文Put a DPAD on that GC!仍然是最佳资源。

Native AOT (ahead-of-time)

对许多人来说,软件方面的 "性能 "一词是指吞吐量。一个东西的执行速度有多快?它每秒钟能处理多少数据?它每秒能处理多少个请求?等等。但是,性能还有许多其他方面的问题。它需要消耗多少内存?它启动和到达做一些有用的事情的速度有多快?它在磁盘上消耗多少空间?它要花多长时间来下载?然后还有相关的担忧。为了实现这些目标,需要哪些依赖性?为了实现这些目标,它需要执行什么样的操作,而这些操作在目标环境中是否都被允许?如果你对这段话有任何共鸣,你就是现在在.NET 7中提供的本地AOT支持的目标受众。

长期以来,.NET一直支持AOT代码的生成。例如,.NET Framework以ngen的形式支持,而.NET Core以crossgen的形式支持。这两种解决方案都涉及到一个标准的.NET可执行文件,它的一些IL已经被编译为汇编代码,但并不是所有的方法都会有汇编代码生成,各种事情会使生成的汇编代码失效,没有任何本地汇编代码的外部.NET汇编可以被加载,等等,在所有这些情况下,运行时继续使用JIT编译器。本地AOT是不同的。它是CoreRT的进化,而CoreRT本身就是.NET Native的进化,它完全不需要JIT。发布构建的二进制文件是一个完全独立的可执行文件,采用目标平台的特定文件格式(如Windows上的COFF,Linux上的ELF,macOS上的Mach-O),除了该平台的标准文件(如libc)外,没有其他外部依赖。而且它完全是原生的:看不到IL,没有JIT,什么都没有。所有需要的代码都被编译和/或链接到可执行文件中,包括用于标准.NET应用程序和服务的相同的GC,以及提供线程和类似服务的最小运行时间。所有这些都带来了巨大的好处:超快的启动时间、小型和完全自包含的部署,以及在JIT编译器不允许的地方运行的能力(例如,因为可写的内存页随后不能执行)。它也带来了一些限制:没有JIT意味着不能动态加载任意程序集(如Assembly.LoadFile)和不能反射发射(如DynamicMethod),所有的东西都被编译和链接到应用程序中,意味着使用(或可能使用)的功能越多,你的部署就越大,等等。即使有这些限制,对于某类应用来说,Native AOT是一个令人激动和欢迎的.NET 7的补充。

在建立Native AOT栈的过程中,有太多的PR需要提及。部分原因是它已经工作了多年(作为已归档的dotnet/corert项目的一部分,然后作为dotnet/runtimelab/feature/NativeAOT的一部分),部分原因是自从代码最初从dotnet/runtimelab带到dotnet/runtime#62563和dotnet/runtime#62611时,仅在dotnet/runtime就有超过100个PR用于将Native AOT提升到可交付状态。在这种情况下,再加上没有以前的版本可以比较它的性能,与其关注逐个PR的改进,不如看看如何使用它和它带来的好处。

今天,Native AOT专注于控制台应用,所以我们来创建一个控制台应用。

dotnet new console -o nativeaotexample

现在我们有了nativeaotexample目录,其中包含nativeaotexample.csproj和 "hello, world" Program.cs。为了能够用Native AOT发布应用程序,编辑.csproj,在现有的...中包含这个。

<PublishAot>true</PublishAot>

然后......实际上,就是这样。我们的应用程序现在已经完全配置好了,可以针对Native AOT。剩下的就是发布了。由于我目前是在我的Windows x64机器上写这篇文章,我将以它为目标。

dotnet publish -r win-x64 -c Release

我现在在输出的发布目录中有我生成的可执行文件。

    Directory: C:\nativeaotexample\bin\Release\net7.0\win-x64\publish

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a---           8/27/2022  6:18 PM        3648512 nativeaotexample.exe
-a---           8/27/2022  6:18 PM       14290944 nativeaotexample.pdb

这个约3.5MB的.exe是可执行文件,旁边的.pdb是调试信息,实际上不需要和应用程序一起部署。我现在可以把nativeaotexample.exe复制到任何64位的Windows机器上,无论该机器上是否安装了.NET,我的应用程序都可以运行。现在,如果你真正关心的是大小,而3.5MB对你来说太大,你可以开始做更多的权衡。你可以将一些开关传递给本地AOT编译器(ILC)和修剪器,这些开关会影响哪些代码被包含在结果图像中。让我把转盘调高一点。

    <PublishAot>true</PublishAot>

    <InvariantGlobalization>true</InvariantGlobalization>
    <UseSystemResourceKeys>true</UseSystemResourceKeys>

    <IlcOptimizationPreference>Size</IlcOptimizationPreference>
    <IlcGenerateStackTraceData>false</IlcGenerateStackTraceData>

    <DebuggerSupport>false</DebuggerSupport>
    <EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
    <EventSourceSupport>false</EventSourceSupport>
    <HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
    <MetadataUpdaterSupport>false</MetadataUpdaterSupport>

我重新发布,现在我有了。

    Directory: C:\nativeaotexample\bin\Release\net7.0\win-x64\publish

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a---           8/27/2022  6:19 PM        2061824 nativeaotexample.exe
-a---           8/27/2022  6:19 PM       14290944 nativeaotexample.pdb

所以2M而不是3.5MB。当然,为了这个显著的减少,我已经放弃了一些东西。

  • 将InvariantGlobalization设置为 "true "意味着我现在不尊重文化信息,而是在大多数全球化操作中使用一组不变的数据。

  • 将UseSystemResourceKeys设置为 "true "意味着将剥离漂亮的异常信息。

  • 将IlcGenerateStackTraceData设置为false意味着如果我需要调试一个异常,我将得到相当差的堆栈跟踪。

  • 将DebuggerSupport设置为false......祝你调试顺利。

  • ...你会明白的。

对于习惯于.NET的开发者来说,Native AOT的一个潜在的令人费解的方面是,正如它在罐子上所说的,它确实是原生的。在发布应用程序后,没有IL参与,甚至没有JIT可以处理它。这使得.NET 7中的一些其他投资更有价值,例如,在源码生成器中的所有投资都在发生。以前依靠反射发射获得良好性能的代码将需要另一种方案。我们可以看到,比如说Regex。在历史上,为了获得Regex的最佳吞吐量,建议使用RegexOptions.Compiled,它在运行时使用反射emit来生成一个指定模式的优化实现。但如果你看一下Regex构造函数的实现,你会发现这个小插曲。

if (RuntimeFeature.IsDynamicCodeCompiled)
{
    factory = Compile(pattern, tree, options, matchTimeout != InfiniteMatchTimeout);
}

在JIT中,IsDynamicCodeCompiled是真的。但在Native AOT中,它是假的。因此,在Native AOT和Regex中,指定RegexOptions.Compiled和不指定RegexOptions.Compiled没有区别,需要另一种机制来获得RegexOptions.Compiled所承诺的吞吐量优势。进入[GeneratedRegex(...)],它与.NET 7 SDK中的新regex源生成器一起,将C#代码排放到使用它的程序集中。该C#代码取代了在运行时发生的反射发射,因此能够与Native AOT成功合作。

private static readonly string s_haystack = new HttpClient().GetStringAsync("https://www.gutenberg.org/files/1661/1661-0.txt").Result;

private Regex _interpreter = new Regex(@"^.*elementary.*$", RegexOptions.Multiline);

private Regex _compiled = new Regex(@"^.*elementary.*$", RegexOptions.Compiled | RegexOptions.Multiline);

[GeneratedRegex(@"^.*elementary.*$", RegexOptions.Multiline)]
private partial Regex SG();

[Benchmark(Baseline = true)] public int Interpreter() => _interpreter.Count(s_haystack);

[Benchmark] public int Compiled() => _compiled.Count(s_haystack);

[Benchmark] public int SourceGenerator() => SG().Count(s_haystack);
方法 平均值 比率
Interpreter 9,036.7 us 1.00
Compiled 9,064.8 us 1.00
SourceGenerator 426.1 us 0.05

所以,是的,有一些与Native AOT相关的限制,但也有解决这些限制的方法。而且,这些限制实际上可以带来更多的好处。考虑一下dotnet/runtime#64497。还记得我们在动态PGO中谈到的 "受保护的去虚拟化 (guarded devirtualization)"吗?在这种情况下,JIT可以通过检测来确定在特定的调用地点最可能使用的类型并对其进行特殊处理。在Native AOT中,程序的全部内容在编译时就已经知道了,不支持Assembly.LoadFrom之类的东西。这意味着在编译时,编译器可以进行整个程序分析,以确定哪些类型实现了哪些接口。如果一个给定的接口只有一个实现它的单一类型,那么通过该接口的每一个调用站点都可以无条件地去虚拟化,而不需要任何类型检查的防护。

这是一个真正令人兴奋的空间,我们希望看到它在未来的版本中蓬勃发展。

原文链接

Performance Improvements in .NET 7