.NET平台系列17 .NET5中的ARM64性能

  .NET团队使.NET 5大大提高了常规性能和ARM64性能。在《.NET5中的性能改进》博客中可以查看总体改进情况。在这篇文章中,将描述我们专门针对ARM64进行的性能改进,并展示对我们使用的基准的积极影响。我还将分享一些我们已经确定并计划在将来的版本中进行性能改进的其他机会。

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

  尽管解决CQ问题是至关重要的,但有时它的影响在应用程序中可能并不明显。因此,我们还希望对.NET库的性能进行明显的改进,以使针对ARM64的.NET应用程序受益。下面是我将用来描述我们在.NET 5上改进ARM64性能的工作的概要:

  • .NET库中特定于ARM64的优化
  • RyuJIT产生的代码质量评估和结果
.NET库中的ARM64硬件内部函数

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

使用ARM64硬件内部函数优化.NET库代码

  在.NET Core 3.1中,我们使用x86 / x64内部函数优化了.NET库的许多关键方法。当在支持x86 / x64内部指令的硬件上运行时,这样做可以提高此类方法的性能。对于不支持x86 / x64内在函数的硬件(例如ARM机器),. NET将回退到这些方法的较慢实现。dotnet /运行时#33308列出此类.NET库方法。在.NET 5中,我们还使用ARM64硬件内在函数对这些方法中的大多数进行了优化。因此,如果您的代码使用任何这些.NET库方法,则它们现在将看到在ARM体系结构上运行的速度提高。我们将精力集中在已经使用x86 / x64内在函数进行了优化的方法上,因为这些方法是基于较早的性能分析(我们不想重复/重复)而选择的,并且我们希望该产品在各个平台上具有大致相似的行为。展望未来,当我们优化.NET库方法时,我们期望同时使用x86 / x64和ARM64硬件内在函数作为我们的默认方法。我们仍然必须决定这将如何影响我们接受的PR的政策。

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

  • System.Collections

    • System.Collections.BitArray

  • System.Numerics

    • System.Numerics.BitOperations

  • System.SpanHelpers

  • System.Text

我们还在的几个类别中优化了方法。System.Text

  在.NET中6,我们计划以优化其余的方法中所描述的dotnet /运行#41292,方法到地址的dotnet /运行#35033和合并工作,以优化用做本·亚当斯DOTNET /运行#41097System.Text.ASCIIUtilitySystem.BuffersJsonReaderHelper.IndexOfLessThan

  上面提到的所有度量均来自我们在8/6 / 2020、8 / 10/20208/28/2020的Ubuntu计算机上进行的性能实验室运行。

具有ARM64内部函数的方法的AOT编译

  在典型情况下,应用程序在运行时使用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性能数字。奇偶校验不是我们的目标,但是,有一个基准进行比较总是很有用的,尤其是用于识别异常值。然后,我们确定性能最差的基准,并确定为什么会这样。我们尝试使用一些分析器,例如WPAPerfView但在这种情况下它们没有用。那些剖析器会指出给定基准中最热门的方法。但是,由于MicroBenchmarks是使用最多1〜2种方法的微小基准,因此探查器指出的最热方法主要是基准方法本身。因此,为了了解ARM64 CQ问题,我们决定只检查为给定基准所产生的汇编代码,并将其与x64汇编进行比较。这将有助于我们确定RyuJIT的ARM64代码生成器中的基本问题。
  • ARM64中的内存屏障

  通过一些基准测试,我们注意到 volatile 类的关键方法的热循环中易失性变量的访问。访问ARM64的易失性变量非常昂贵,因为它们引入了内存屏障指令。通过缓存volatile变量并将其存储在循环外部的局部变量dotnet / runtime#34225dotnet / runtime#36976dotnet / runtime#37081中,可以提高性能,如下所示。所有的测量单位都是纳秒。

System.Collections.Concurrent.ConcurrentDictionary

  • ARM内存模型

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

  在代码中指定添加内存屏障的一种方法是使用volatile变量使用volatile,可以确保运行时,JIT和处理器不会重新安排对内存位置的读写,以提高性能。为此,dmb每次对volatile变量进行访问(读/写)时,RyuJIT都会为ARM64发出(数据存储屏障)指令

  • ARM64和大常量

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

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

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

窥孔分析

  数据驱动工程方法,用于发现其他重要的ARM64代码质量增强并对其进行优先级排序。当用几个基准检查为.NET库生成的ARM64代码时,我们意识到有几种指令模式可以用更好,性能更高的指令代替。在编译器文献中,“窥孔优化”是进行此类优化的阶段。RyuJIT当前没有窥视孔优化阶段。添加新的编译器阶段是一项艰巨的任务,并且很容易花费数月的时间才能使其正确完成,同时又不影响其他指标(如JIT吞吐量)。此外,我们不确定代码的大小或加快这种优化的速度能为我们带来多少。因此,我们以一种有趣的方式收集了数据,以发现窥视孔优化中的各种机会并确定其优先级。我们写了一个实用工具AnalyzeAsm它将扫描大约1GB的文件,其中包含.NET库方法的ARM64反汇编代码,并报告我们感兴趣的指令模式及其使用的方法的频率。有了这些信息,对于我们来说,确定窥视孔优化阶段的最小实施非常重要变得更加容易。使用AnalyzeAsm,我们发现了一些窥孔,它们可以使.NET库的代码大小大致提高0.75%。在.NET 5中,我们通过消除dotnet / runtime#38179中的冗余相反mov指令来优化指令模式,这为我们提供了0.28%代码大小的改善。从百分比的角度来看,改进并不大,但是对于整个产品而言,它们却是有意义的。

  • 用【ldp】替换【ldr】对
  • 用【stp】替换【str】对
  • 用【str xzr】替换【str wzr】对
  • 删除冗余的【ldr】和【str】
  • 将【 ldr】替换为【mov】
  • 使用movz / movk加载大常量
  • 调用间接和虚拟存根
Techempower基准

  在Techempower基准测试中显着改善了ARM64性能。以下是针对请求/秒的度量(越高越好)

结论

  在.NET 5中,我们在提高ARM64目标的速度和代码大小方面取得了长足的进步。我们不仅在.NET API中公开了ARM64内在函数,而且还在我们的库代码中使用了它们以优化关键方法。通过我们的数据驱动工程方法,我们能够对.NET 5中具有高影响力的工作项目进行优先级排序。在进行性能调查时,我们还发现了dotnet / runtime#35853中总结的一些机会,我们计划继续为.NET工作。 6.我们与Arm Holdings的@TamarChristinaArm建立了良好的合作关系,他们不仅实现了一些ARM64硬件内在函数,而且还提供了宝贵的建议和反馈以提高我们的代码质量。我们要感谢多个贡献者,他们使得能够发布在ARM64目标上运行的.NET 5成为可能。

我们鼓励大家下载适用于ARM64的.NET 5最新版本,并让我们知道您的反馈。

 


参考文献:

  • //devblogs.microsoft.com/dotnet/Arm64-performance-in-net-5/
  • 适用于ARMv8-A的ARM Cortex-A系列程序员指南 //developer.arm.com/documentation/den0024/a/memory-ordering