.NET 5中的性能改进

2020-07-14 02:49:59

在.NET Core的以前版本中,我已经在博客中介绍了在该版本中发现的重大性能改进。对于每个帖子,从.NET Core2.0到.NET Core2.1再到.NET Core3.0,我发现自己有越来越多的东西要谈。然而,有趣的是,每次之后,我也发现自己在想,下一次是否有足够的有意义的改进来保证另一个职位。既然.NET5已经发布了预览版,我可以肯定地说,答案再一次是“是的”。.NET5已经看到了大量的性能改进,尽管它直到今年秋天才计划最终发布,而且到那时很可能会有更多的改进,我想强调一下现在已经可以使用的一些改进。在这篇文章中,我将重点介绍大约250个Pull请求,这些请求为.NET 5带来了无数的性能改进。

Benchmark.NET现在是衡量.NET代码性能的标准工具,使分析代码片段的吞吐量和分配变得简单。因此,我在这篇文章中的大多数示例都是使用使用该工具编写的微基准进行测量的。为了便于在家中跟踪(对于我们中的许多人来说,现在确实是这样),我首先创建了一个目录,并使用dotnet工具将其搭建起来:

<;Project SDK=";Microsoft.NET.Sdk&34;>;<;PropertyGroup>;<;OutputType>;exe<;/OutputType>;<;AllowUnsafeBlock>;true<;/AllowUnsafeBlock>;<;ServerGarbageCollection>;true<;/Server。PackageReference包括=";Benchmark dotnet";Version=";0.12.1";/&><;/ItemGroup>;<;ItemGroup条件=";';$(TargetFramework)';=';net48';&34;>;<;PackageReference Include=&";System.Memory&34;PackageReference包括=";System.Text.Json";Version=";4.7.2";/>;<;参考包括=";System.Net.Http";/>;<;/ItemGroup>;<;/项目>;

这让我可以针对.NET Framework4.8、.NET Core3.1和.NET5执行基准测试(我目前安装了针对Preview 8的夜间构建)。.csproj还引用了Benchmark.NET NuGet包(其最新版本是版本12.1),以便能够使用它的功能,然后引用其他几个库和包,特别是为了支持在.NET Framework 4.8上运行测试。

然后,我更新了同一文件夹中生成的Program.cs文件,如下所示:

使用BenchmarkDotNet。属性;使用BenchmarkDotNet。诊断程序;使用BenchmarkDotNet。运行;使用系统;使用系统。缓冲器。文本;使用系统。集合;使用系统。收藏品。并发;使用系统。收藏品。通用的;使用系统的。收藏品。不可变的;使用系统的。IO;使用系统。linq;使用系统。NET;使用系统。NET。HTTP;使用系统。NET。安全;使用系统。NET。套接字;使用系统。运行时。CompilerServices;使用系统。线程;使用系统。穿线。任务;使用系统。文本;使用系统。文本。JSON;使用系统。文本。RegularExpressions;[MemoryDiagnoser]公共类Program{static void main(string[]args)=>;BenchmarkSwitcher。FromAssemblies(new[]{typeof(Program)。组件})。Run(Args);//此处显示基准}。

对于每个测试,我将每个示例中显示的基准代码复制/粘贴到它显示";//基准在这里";的位置。

使用.NET Framework4.8外围应用(这是所有三个目标的最低公分母,因此适用于所有目标)构建基准。

针对.NET Framework 4.8、.NET Core 3.1和.NET 5中的每一个运行基准测试。

将来自所有基准测试的所有结果的输出结合在一起,并在运行结束时显示(而不是散布在整个过程中)。

在某些特定目标不存在有问题的API的情况下,我只是省略了命令行的这一部分。

我上一篇基准测试帖子是关于.NET Core3.0的。我没有写一篇关于.NET Core3.1的文章,因为从运行时和核心库的角度来看,与几个月前发布的前身相比,它的改进相对较少。然而,也有一些改进,在某些情况下,我们已经将针对.NET 5所做的改进重新移植到.NET Core 3.1中,这些更改被认为影响足够大,有理由添加到长期支持(LTS)版本中。因此,我在这里的所有比较都是与最新的.NET Core3.1服务版本(3.1.5)进行比较,而不是与.NET Core3.0进行比较。

由于比较的是.NET5和.NET Core3.1,而且.NET Core3.1不包括mono运行时,所以我没有介绍对mono所做的改进,以及专门针对“Blazor”的核心库改进。因此,当我提到“运行时”时,我指的是coreclr,尽管在.NET5的保护伞下有多个运行时,而且所有的运行时都得到了改进。

我的大多数示例都运行在Windows上,因为我还希望能够与.NET Framework4.8进行比较。但是,除非另有说明,否则所示的所有示例都同样适用于Windows、Linux和MacOS。

标准警告:这里的所有测量数据都在我的台式机上,您的里程数可能会有所不同。微基准测试可能对许多因素非常敏感,包括处理器数量、处理器体系结构、内存和高速缓存速度等等。不过,总的来说,我关注的是性能改进,并包含了通常应能承受任何此类差异的示例。

对于任何对.NET和性能感兴趣的人来说,垃圾回收通常是他们的头等大事。在减少分配上花费了大量精力,这不是因为分配操作本身特别昂贵,而是因为通过垃圾收集器(GC)进行分配后的后续清理成本。然而,无论在减少分配方面投入多少工作,绝大多数工作负载都会产生分配,因此不断突破GC能够完成的任务范围和速度是很重要的。

此版本在改进GC方面做了大量工作。例如,dotnet/coreclr#25986为GC的“标记”阶段实现了一种形式的工作窃取。.NET GC是一个“跟踪”收集器,这意味着(在非常高的级别上)当它运行时,它从一组“根”(本质上可访问的已知位置,如静态字段)开始,并从一个对象遍历到另一个对象,将每个对象“标记”为可访问的;在所有这样的遍历之后,任何未标记的对象都是不可访问的,并且可以被收集。此标记占执行集合所用时间的很大一部分,并且此PR通过更好地平衡集合中涉及的每个线程执行的工作来提高标记性能。当使用“Server GC”运行时,集合中涉及每个核心的一个线程,当线程完成其分配的标记工作部分时,它们现在能够从其他线程“窃取”未完成的工作,以帮助整个集合更快地完成。

作为另一个例子,dotnet/Runtime#35896优化了“临时”段上的分解(Gen0和Gen1被称为“临时”,因为它们是预期只会持续很短时间的对象)。释放是指在该段上的最后一个活动对象之后的段末尾将内存页交还给操作系统的行为。因此,GC面临的问题是,这种取消应该在什么时候发生,以及在任何时间点上应该取消多少,因为在不久的将来的某个时候,GC可能最终需要分配额外的页面来进行额外的分配。

或者以dotnet/Runtime#32795为例,它通过减少GC的静态扫描中涉及的锁争用,提高了GC在内核计数较高的机器上的可伸缩性。或者dotnet/Runtime#37894,它避免了代价高昂的内存重置(实质上是告诉操作系统相关的内存不再有用),除非GC发现它处于内存不足的情况。或者dotnet/runtime#37159,它(虽然还没有合并,但预计将用于.NET5)构建在@DamageBoy工作的基础上,以向量化GC中使用的排序。或者dotnet/coreclr#27729,它减少了GC挂起线程所需的时间,这是获得稳定视图所必需的,这样它就可以准确地确定正在使用哪些线程。

这只是为改进GC本身所做的更改的一部分,但最后一项将我带到一个特别吸引我的主题,因为它说明了我们近年来在.NET中所做的大量工作。在此版本中,我们继续甚至加速了将coreclr运行时中的本机实现从C/C++移植到System.Private.Corelib中的普通C#托管代码的过程。这样做有很多好处,包括使我们更容易跨多个运行时(如coreclr和mono)共享单个实现,甚至使我们更容易发展API外围应用,例如通过重用相同的逻辑来处理数组和跨区。但有一件事让一些人大吃一惊,那就是这样的好处还包括多方面的性能。一种这样的方式让人回想起使用托管运行库的最初动机之一:安全性。默认情况下,用C#编写的代码是“安全的”,因为运行库确保所有内存访问都经过边界检查,并且只能通过代码中可见的显式操作(例如,使用unsafe关键字、Marshal类、unsafe类等)。开发人员是否能够删除此类验证。因此,作为开放源码项目的维护者,当贡献以托管代码的形式出现时,我们交付安全系统的工作就变得容易得多:虽然这样的代码当然可以包含可能通过代码审查和自动化测试的错误,但我们可以在晚上睡得更好,因为知道这样的错误引入安全问题的可能性大大降低了。这反过来意味着我们更有可能以更快的速度接受对托管代码的改进,贡献者提供的速度更快,我们帮助验证的速度也更快。我们还发现,当性能以C#而不是C的形式出现时,有更多的贡献者对探索性能改进感兴趣,更多的人以更快的速度进行更多的实验,可以获得更好的性能。

然而,我们已经从这样的移植中看到了更直接的性能改进形式。托管代码调入运行库所需的开销相对较少,但当频繁进行此类调用时,这样的开销会累积起来。考虑一下dotnet/coreclr#27700,它将基元类型数组排序的实现从coreclr中的本机代码移到了Corelib中的C#中。除了为新的公共API提供支持以对范围进行排序的代码之外,它还降低了对较小数组进行排序的成本,在这些数组中,这样做的成本主要由从托管代码过渡而来。我们

使用系统;使用系统。诊断;使用系统。线程;类Program{public static void main(){new Thread(()=>;{var a=new int[20];while(True)Array。排序(A);}){IsBackground=true}。start();var sw=new stopwatch();while(True){sw.。Restart();for(int i=0;i<;10;i++){gc。收集();穿线。睡眠(15);}控制台。WriteLine(软件。已经过去了。TotalSeconds);}。

这是在旋转一个线程,该线程位于一个紧密的循环中,反复对一个小数组进行排序,而在主线程上,它执行10个GC,每个GC之间的间隔大约为15毫秒。因此,我们预计这个循环需要150毫秒多一点的时间。但是当我在.NET Core 3.1上运行它时,我得到的秒数如下所示:

在这里,GC很难中断执行排序的线程,从而导致GC暂停时间比期望的要高得多。值得庆幸的是,当我在.NET5上运行它时,我得到的数字如下所示:

这正是我们预测的结果。通过将Array.Sort实现移动到托管代码中,运行时可以在需要时更容易地挂起该实现,我们已经使GC能够更好地完成其工作。

这不仅限于数组。当然,排序。一批PR执行这样的移植,例如dotnet/runtime#32722将stdelemref和ldelemaref JIT帮助器移动到C#,dotnet/runtime#32353将解箱帮助器的一部分移动到C#(并用适当的GC轮询位置检测其余部分,使GC在其余部分适当挂起),dotnet/coreclr#27603/dotnet/coreclr#27634/dotnet/coreclr#27123/dotnet/coreclr。和dotnet/coreclr#27792将Enum.CompareTo移动到C#。其中一些更改随后实现了收益,例如使用dotnet/runtime#32342和dotnet/runtime#35733,它们利用Buffer.Memmove中的改进在各种字符串和数组方法中实现了额外的收益。

作为这组更改的最后一个想法,需要注意的另一件有趣的事情是,一个版本中所做的微优化可能是基于后来无效的假设,并且在使用这种微优化时,需要做好准备并愿意适应。在我的.NETCore3.0博客文章中,我提到了像dotnet/coreclr#21756这样的“花生酱”更改,它将许多调用点从使用Array.Copy(source,Destination,Length)改为使用Array.Copy(source,source Offset,Destination,DestinationOffset,Length),因为前者获得源和目标数组的下限所涉及的开销是可测量的。但是,通过前面提到的将数组处理代码转移到C#的更改,更简单的过载开销消失了,使其成为这些操作的更简单、更快速的选择。因此,对于.NET5PR,dotnet/coreclr#27641和dotnet/corefx#42343将所有这些调用点都切换回使用更简单的重载。Dotnet/Runtime#36304是另一个撤销先前优化的例子,因为更改使它们过时或实际上有害。您总是能够将单个字符传递给String.Split,例如version.Split(';.';)。然而,问题是它可以绑定到的唯一Split重载是Split(params char[]分隔符),这意味着每次这样的调用都会导致C#编译器生成char[]分配。为了解决这个问题,以前的版本添加了缓存,提前分配数组并将它们存储到静态中,然后拆分调用可以使用它们来避免每次调用的char[]。既然.NET中有一个Split(字符分隔符,StringSplitOptions Options=StringSplitOptions.None)重载,我们就不再需要该数组了。

作为最后一个示例,我展示了将代码移出运行库并移入托管代码如何有助于GC暂停,当然,保留在运行库中的代码还有其他方式可以帮助实现这一点。dotnet/Runtime#36179通过确保运行时处于抢占模式(例如获取“Watson”存储桶参数(基本上是用于报告目的的唯一标识此特定异常和调用堆栈的一组数据)),减少了由于异常处理而导致的GC暂停。

对于即时(JIT)编译器来说,.NET5也是一个令人兴奋的版本,在这个发行版中有很多各种方式的改进。与任何编译器一样,对JIT所做的改进可以产生广泛的影响。通常,单个更改对单个代码片段的影响很小,但是这样的更改会被它们所应用的位置的绝对数量放大。

可以添加到JIT的优化几乎是无限的,如果有无限的时间来运行这些优化,JIT可以为任何给定的场景创建最优的代码。但是JIT并没有无限的时间。JIT的“即时”特性意味着它在应用程序运行时执行编译:当调用尚未编译的方法时,JIT需要按需为其提供汇编代码。这意味着线程在编译完成之前不能继续前进,这反过来又意味着JIT在应用什么优化以及如何使用其有限的时间预算方面需要有战略眼光。使用各种技术来给JIT更多的时间,比如在应用程序的某些部分使用“提前”编译(AOT),以便在应用程序执行之前尽可能多地完成编译工作(例如,核心库都是使用名为“ReadyToRun”的技术进行AOT编译的,您可能会听到这种技术被称为“R2R”,甚至是“CrossGen”,它是生成这些图像的工具),或者使用“分层编译”,这允许JIT最初。并且只需要花费更多的时间重新编译,并进行更多的优化。

.