.NET 5中的ARM64性能

2020-10-08 12:20:46

无论是在一般情况下,还是在ARM64上,.NET团队都在.NET5中显著提高了性能。您可以查看Stephen在.NET5博客中出色而详细的性能改进中的一般改进。在这篇文章中,我将描述我们专门针对ARM64所做的性能改进,并展示对我们使用的基准测试的积极影响。我还将分享一些我们已经确定并计划在未来版本中解决的其他性能改进机会。

虽然我们在RyuJIT中对ARM64的支持已经工作了五年多,但我们所做的大部分工作都是为了确保我们生成的ARM64代码在功能上是正确的。我们只花了很少的时间来评估RyuJIT为ARM64生成的代码的性能。作为.NET5的一部分,我们的重点是在这一领域进行调查,找出RyuJIT中任何可以提高ARM64代码质量(CQ)的明显问题。由于Microsoft VC++团队已经支持Windows ARM64,我们咨询了他们,以了解他们在进行类似练习时遇到的CQ问题。

尽管修复CQ问题很重要,但有时其影响在应用程序中可能并不明显。因此,我们还希望在.NET库的性能方面做出明显的改进,以使面向ARM64的.NET应用程序受益。

以下是我将用来描述我们在.NET5上改进ARM64性能的工作大纲:

在.NET Core3.0中,我们引入了一个称为“Hardware Intrinics”的新功能,它允许访问现代硬件支持的各种矢量化和非矢量化指令。对于x86/x64体系结构,.NET开发人员可以使用命名空间System.Runtime.Intrinsics和System.Runtime.Intrinsics.X86下的API集访问这些指令。在.NET5中,我们在System.Runtime.Intrinsics.Arm下为ARM32/ARM64架构添加了大约384个API。这涉及到实现这些API并使RyuJIT意识到它们,这样它就可以发出适当的ARM32/ARM64指令。我们还优化了Vector64和Vector128的方法,这些方法提供了创建和操作Vector64<;T>;和Vector128&t>;数据类型的方法,大多数硬件内部API都在这些数据类型上运行。如果感兴趣,请参阅此处的示例代码用法以及Vector64和Vector128方法的示例。您可以在这里查看我们的“硬件固有”项目进度。

在.NET Core3.1中,我们使用x86/x64内部函数优化了.NET库的许多关键方法。当在支持x86/x64内部指令的硬件上运行时,这样做提高了这些方法的性能。对于不支持x86/x64内部功能的硬件(如ARM计算机),.NET将退回到这些方法的较慢实现。Dotnet/Runtime#33308列出了这样的.NET库方法。在.NET5中,我们也使用ARM64硬件内部特性优化了这些方法中的大部分。因此,如果您的代码使用这些.NET库方法中的任何一个,它们现在将看到在ARM体系结构上运行的速度提升。我们将精力集中在已经使用x86/x64内部函数进行优化的方法上,因为这些方法是基于早期的性能分析(我们不想重复/重复)选择的,我们希望产品在不同平台上具有大致相似的行为。展望未来,我们希望在优化.NET库方法时同时使用x86/x64和ARM64硬件内部特性作为默认方法。我们仍需决定这将如何影响我们接受的公关政策。

对于我们在.NET5中优化的每个方法,我将向您展示我们用来验证改进的低级基准测试方面的改进。这些基准与现实世界相去甚远。您将在后面的文章中看到所有这些有针对性的改进如何结合在一起,在更大、更真实的场景中极大地改进ARM64上的.NET。

在dotnet/runtime#33749中,@Gnbrkm41对System.Collections.BitArray方法进行了优化。Perf_BitArray微基准的以下测量以纳秒为单位。

BitOperations方法在dotnet/运行时#34486和dotnet/运行时#35636中进行了优化。Perf_BitOperations微基准的以下度量以纳秒为单位。

Matrix4x4方法在dotnet/Runtime#40054中进行了优化。Perf_Matrix4x4微基准的以下测量以纳秒为单位。

SIMD加速类型System.Numerics.Vector2、System.Numerics.Vector3和System.Numerics.Vector4在Dotnet/Runtime#35421、Dotnet/Runtime#36267、Dotnet/Runtime#36512、Dotnet/Runtime#36579和Dotnet/Runtime#37882中进行了优化,以使用硬件内部。Perf_Vector2、Perf_Vector3和Perf_Vector4微基准的以下测量以纳秒为单位。

Span Helpers方法在dotnet/运行时#37624和dotnet/运行时#37934中进行了优化。Span<;T>;.IndexOfValue和ReadOnlyspan.IndexOfString微基准的以下测量以纳秒为单位。

在.NET6中,我们计划优化点网/运行时#41292,System.Buffers的方法中描述的System.Text.ASCIIUtility的剩余方法,以解决点网/运行时#35033,并合并本·亚当斯在点网/运行时#41097中完成的优化JsonReaderHelper.IndexOfLessThan的工作。

我上面提到的所有度量都来自我们在Ubuntu机器上于2020年8月6日、2020年8月10日和2020年8月28日在Ubuntu机器上运行的性能实验室。

在这一点上,可能很清楚硬件本质有多大的影响力和重要性。我想通过一个例子向你们展示更多。假设Test()返回参数值的前导零计数。

在针对ARM64进行优化之前,代码将执行LeadingZeroCount()的软件回退。如果您看到下面生成的ARM64汇编代码,不仅它很大,而且RyuJIT必须JIT 2个方法-Test(Int)和Log2SoftwareFallback(Int)。

;test(Int):int stp fp,lr,[sp,#-16]!Mov fp,sp cbnz w0,m00_l00 mov w0,#32 b m00_l01 m00_l00:BL System.Numerics.BitOperations:Log2SoftwareFallback(INT):int EOR W0,w0,#31 M00_L01:ldp fp,lr,[sp],#16 ret lr;代码总字节数28,序言大小8;===;System.Numerics.BitOperations:Log2SoftwareFallback(int):int stp fp,lr,[sp,#-16]!MOV FP,SP LSR W1,W0,#1 ORR W0,W0,W1 LSR W1,W0,#2 ORR W0,W0,W1 LSR W0,W0,#4 ORR W0,W0,W1 LSR W1,W0,#8 ORR W0,W0,W1 LSR W1,W0,#16 ORR W0,W0,W1 MOVZ W1,#0xacdd movk W1,#0x7c4 LSL#16 mul W0,W0。#0xc249 movk x1,#0x5405 lsl#16 movk x1,#0x7ffc lsl#32 ldrb w0,[x0,x1]ldp fp,lr,[sp],#16ret lr;代码的总字节数为92,Prolog大小为8。

在我们优化LeadingZeroCount()以使用ARM64内部函数之后,为ARM64生成的代码只是几条指令(包括关键的CLZ)。在本例中,RyuJIT甚至没有JIT Log2SoftwareFallback(Int)方法,因为它没有被调用。因此,通过做这项工作,我们在代码质量和JIT吞吐量方面得到了改进。

;test(Int):int stp fp,lr,[sp,#-16]!MOV FP,SP CLZ W0,W0 LDP FP,LR,[sp],#16 ret LR;代码总字节数24,序言大小8。

在典型情况下,应用程序在运行时使用JIT编译为机器码。生成的目标机器代码非常高效,但缺点是必须在执行期间进行编译,这可能会在应用程序启动期间增加一些延迟。如果事先知道目标平台,则可以为该目标平台创建随时可运行(R2R)的本机映像。这称为提前(AOT)编译。它的优点是启动时间更快,因为在执行过程中不需要生成机器代码。目标机器代码已经存在于二进制文件中,可以直接运行。AOT编译的代码有时可能不是最优的,但最终会被最优的代码所取代。

在.NET 5之前,如果某个方法(.NET库方法或用户定义的方法)调用了ARM64硬件内部API(System.Runtime.Intrinsics和System.Runtime.Intrinsics.Arm下的API),则此类方法永远不会进行AOT编译,并且总是推迟到运行时编译。这影响了一些.NET应用程序的启动时间,这些应用程序在其启动代码中使用了这些方法之一。在.NET5中,我们在dotnet/runtime#38060中解决了这个问题,现在可以编译这样的方法aot。

使用内部函数优化.NET库是一个简单的步骤(遵循我们已经为x86/x64所做的步骤)。一个同等或更重要的项目是提高JIT为ARM64生成的代码质量。使该练习面向数据非常重要。我们选择了我们认为会突出潜在ARM64 CQ问题的基准。我们从我们维护的微基准开始。这些基准大约有1300个。

我们比较了每个基准测试的ARM64和x64性能数字。平价不是我们的目标,然而,有一个基线可供比较总是有用的,特别是用来识别离群值。然后,我们确定了性能最差的基准,并确定了原因。我们尝试使用一些分析器,如WPA和PerfView,但它们在此场景中没有用处。这些分析器会指出给定基准中最热门的方法。但是,由于MicroBenchmark是最多有1~2个方法的小型基准测试,分析器指出的最热门的方法大多是基准测试方法本身。因此,为了理解ARM64 CQ问题,我们决定只检查为给定基准生成的汇编代码,并将其与x64汇编进行比较。这将帮助我们确定RyuJIT的ARM64代码生成器中的基本问题。

通过一些基准测试,我们注意到System.Collections.Concurrent.ConcurrentDictionary类的关键方法的热循环中访问了易失性变量。访问ARM64的易失性变量代价很高,因为它们引入了内存屏障指令。我很快就会描述原因。通过缓存易失性变量并将其存储在循环外部的本地变量(dotnet/运行时#34225、dotnet/运行时#36976和dotnet/运行时#37081)中,可以提高性能,如下所示。所有的测量都以纳秒为单位。

我们在System.Threading.ThreadPool(作为dotnet/运行时#36697的一部分)和System.Diagnostis.Tracing.EventCount(作为dotnet/运行时#37309类的一部分)中进行了类似的优化。

ARM体系结构具有弱有序的内存模型。处理器可以对存储器访问指令重新排序以提高性能。它可以重新排列指令,以减少处理器访问内存所需的时间。不能保证写入指令的顺序,而是可以根据给定指令的存储器访问成本来执行指令。此方法不会影响单核计算机,但会对运行在多核计算机上的多线程程序产生负面影响。在这种情况下,会有指令告诉处理器不要在给定点重新安排内存访问。限制这种重新排列的这类指令的专业术语称为“记忆屏障”。ARM64中的DMB指令充当阻止处理器将指令移过栅栏的屏障。您可以在ARM开发人员文档中阅读有关它的更多信息。

指定在代码中添加内存屏障的方法之一是使用易失性变量。使用易失性,可以保证运行时、JIT和处理器不会重新安排对内存位置的读写操作以提高性能。为了实现这一点,RyuJIT将在每次访问(读/写)易失性变量时发出针对ARM64的DMB(数据存储器屏障)指令。

例如,以下代码摘自Perf_Volatil微基准测试。它对本地FIELD_LOCATION执行易失性读取。

公共类Perf_Volatile{private Double_Location=0;[Benchmark]PUBLIC DOUBLE READ_DOUBLE()=>;Volatile。Read(REF_LOCATION);}。

代码首先获取_location字段的地址,将值加载到d0寄存器中,然后执行充当数据内存屏障的DMB ishld。

虽然这保证了内存排序,但也存在与之相关的成本。处理器现在必须保证在存储器屏障之前完成的所有数据访问对于屏障指令之后的所有内核都是可见的,这可能很耗时。因此,尽量避免或最大限度地减少在热方法和循环中使用此类数据访问非常重要。

在.NET5中,我们在处理用户代码中出现的大型常量的方式上做了一些改进。我们开始消除dotnet/runtime#39096中大常量的冗余加载,这使我们为所有.NET库生成的ARM64码的大小改进了大约1%(准确地说是521K字节)。

值得注意的是,有时JIT改进不会反映在微基准运行中,但在总体代码质量方面是有益的。在这种情况下,RyuJIT团队报告了在.NET库代码大小方面所做的改进。RyuJIT在更改前后的整个.NET库DLL上运行,以了解优化产生了多大影响,以及哪些库比其他库优化得更多。在预览版8中,针对ARM64目标的整个.NET库发出的代码大小为45MB。1%的改进意味着我们在.NET5中减少了450KB的代码,这是相当可观的。您可以看到这里改进的各个方法的数量。

ARM64具有固定长度编码的指令集体系结构(ISA),每条指令的长度正好是32位。因此,移动指令MOV仅具有编码最多16位无符号常量空间。要移动更大的常量值,我们需要使用16位块(movz/movk)分多个步骤移动值。因此,生成多个MOV指令以构造需要保存在寄存器中的单个更大的常量。或者,在x64中,单个mov可以加载更大的常量。

Public static uint GetHashCode(uint a,uint b){Return((a*2981231)*b)+2981235;}。

在优化此模式之前,我们将生成代码来构造每个常量。因此,如果它们出现在循环中,则会在每次迭代中构造它们。

移动w2,#0x7d6f移动w2,#45 LSL#16;<;--在w2--w0,w0,w2--移动w0,w0,w1移动w1中加载2981231,#0x7d73移动w1,#45 lsl#16;<;--在w1中加载2981235加上w0,w0,w1。

在.NET5中,我们现在只在寄存器中加载一次这样的常量,并且只要有可能,就在代码中重用它们。如果有多个常量与优化常量的差值低于某个阈值,则使用寄存器中已有的优化常量来构造其他常量。下面,我们使用寄存器w2中的值(本例中为2981231)来计算常数2981235。

移动w2,#0x7d6f移动w2,#45 LSL#16;<;--加载2981231--加载2981231--w0,w0,w1添加w1,w2,#4;<;--加载2981235添加w0,w0,w1。

这种优化不仅对加载常量很有帮助,而且对加载方法地址也很有帮助,因为它们在ARM64上是64位长的。

我们在优化返回C#struct的ARM64场景方面取得了很好的进展,在.NET库中获得了0.19%的代码大小改进。在.NET5之前,我们总是在堆栈上创建一个结构,然后再对其进行任何操作。对其字段的任何更新都将在堆栈上进行更新。返回时,必须将字段从堆栈复制到返回寄存器。同样,当从方法返回结构时,我们会在对其进行操作之前将其存储在堆栈中。在.NET5中,我们开始注册可以使用dotnet/Runtime#36862中的多个寄存器返回的结构,这意味着在某些情况下,结构不会在堆栈上创建,而是直接使用寄存器创建和操作。这样,我们就省略了使用结构的方法中昂贵的内存访问。这是改进在堆栈上操作的场景的大量工作。

对于ReadOnlySpan<;T>;和在ReadOnlySpan<;T>;和Span<;T>;结构上运行的Span<;T>;.ctor()微基准,以下测量以纳秒为单位。

在.NET Core3.1中,当函数创建并返回一个结构,该结构包含可以放入Float之类的寄存器中的字段时,我们总是在堆栈上创建和存储该结构。让我们看一个例子:

Public struct MyStruct{public Float a;public Float b;}[MethodImpl(MethodImplOptions.。无内联)]public static MyStruct GetMyStruct(Float i,Float j){MyStruct mys=new MyStruct();my.。A=i+j;Mys.。B=i-j;返回Mys;}公共静态浮点GetTotal(Float i,Float j){MyStruct Mys=GetMyStruct(i,j);Return Mys。A+Mys.。B;}public static void main(){GetTotal(1.5f,2.5f);}。

以下是我们在.NET Core3.1中生成的代码。如下所示,我们在堆栈上的位置[fp+24]创建了结构,然后将i+j和i-j结果分别存储在位于[fp+24]和[fp+28]的字段a和b中。最后,我们将这些字段从堆栈加载到寄存器S0和S1中,以返回结果。调用方GetTotal()还会在对返回的结构进行操作之前将其保存在堆栈上。

;GetMyStruct(Float,Float):struct stp fp,lr,[sp,#-32]!Mov fp,sp str xzr,[fp,#24]add x0,fp,#24;<;--在堆栈上创建的结构,位于[fp+24]str xzr,[X0]FADD s16,s0,s1 str s16,[fp,#24];<;--mys.a=i+j fsub s16,s0,s1 str s16,[fp,#28];<;--mys.a=i-j ldr s0,[fp,#24];返回S0 LDR S1,[FP,#28]中的结构字段';a';;返回S1 LDP FP,LR,[sp],#32 ret LR中的结构字段';b';代码总字节数52,序言大小12;===;GetTotal(Float,Float):Float stp FP,LR,[sp,#-32]!MOV FP,sp调用[GetMyStruct(Float,Float):MyStruct]字符串s0,[fp,#24];将mys.a存储在堆栈字符串s1,[fp,#28];将mys.b存储在堆栈add x0,fp,#24 LDR s0,[x0];再次加载到寄存器LDR s16,[x0,#4]FADD s0,s0,s16 ldp fp,LR,[sp],#32 ret LR;代码总字节数44,Prolog大小8。

通过注册工作,我们在某些情况下不再在堆栈上创建结构。这样,我们就不必将字段值从堆栈加载到返回寄存器。下面是.NET 5中的优化代码:

;GetMyStruct(Float,Float):MyStruct stp FP,LR,[sp,#-16]!Mov fp,sp fadd s16,s0,s1 fsub s1,s0,s1;s1包含';b';fmov s0,s16;s0包含';a';ldp fp,LR,[sp],#16 ret LR的值;代码28的总字节数,序言大小8;===;getTotal(浮点,浮点):浮点stp FP,LR,[sp,#-16]!Mov fp,sp调用[GetMyStruct(Float,Float):MyStruct]fmov S16,S1 FADD S0,S0,S16 LDP FP,LR,[sp],#16 ret LR;代码总字节数28,序言大小8

代码大小减少了43%,我们总共消除了GetMyStruct()和GetTotal()中的10次内存访问。这两种方法所需的堆栈空间也从32字节减少到16字节。

Dotnet/Runtime#39326是一项正在进行的工作,旨在以类似的方式优化寄存器中传递的结构字段,我们将在下一个版本中发布。我们也有。

.