在.NET Core以前的版本中,其实已经在博客中介绍了在该版本中发现的重大性能改进。 从.NET Core 2.0到.NET Core 2.1到.NET Core 3.0的每一篇文章,发现
谈论愈来愈多的东西。 然而有趣的是,每次都想知道下一次是否有足够的意义的改进以保证再发表一篇文章。 .NET 5已经实现了许多性能改进,尽管直到今年秋天才计划发布最终版本,而且到那时颇有可能会有更多的改进,可是还要强调一下,如今已提供的改进。 在这篇文章中,重点介绍约250个PR,这些请求为整个.NET 5的性能提高作出了巨大贡献。
html
Benchmark.NET如今是衡量.NET代码性能的规范工具,可轻松分析代码段的吞吐量和分配。 所以,本文中大部分示例都是使用使用该工具编写的微基准来衡量的。首先建立了一个目录,而后使用dotnet工具对其进行了扩展:
c++
mkdir Benchmarks cd Benchmarks dotnet new console
生成的Benchmarks.csproj的内容扩展为以下所示:
git
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <AllowUnsafeBlocks>true</AllowUnsafeBlocks> <ServerGarbageCollection>true</ServerGarbageCollection> <TargetFrameworks>net5.0;netcoreapp3.1;net48</TargetFrameworks> </PropertyGroup> <ItemGroup> <PackageReference Include="benchmarkdotnet" Version="0.12.1" /> </ItemGroup> <ItemGroup Condition=" '$(TargetFramework)' == 'net48' "> <PackageReference Include="System.Memory" Version="4.5.4" /> <PackageReference Include="System.Text.Json" Version="4.7.2" /> <Reference Include="System.Net.Http" /> </ItemGroup> </Project>
这样,就能够针对.NET Framework 4.8,.NET Core 3.1和.NET 5执行基准测试(目前已为Preview 8安装了每晚生成的版本)。.csproj还引用Benchmark.NET NuGet软件包(其最新版本为12.1版),以便可以使用其功能,而后引用其余几个库和软件包,特别是为了支持可以在其上运行测试 .NET Framework 4.8。
而后,将生成的Program.cs文件更新到同一文件夹中,以下所示:
github
using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Diagnosers; using BenchmarkDotNet.Running; using System; using System.Buffers.Text; using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Security; using System.Net.Sockets; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; [MemoryDiagnoser] public class Program { static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args); // BENCHMARKS GO HERE }
对于每次测试,每一个示例中显示的基准代码复制/粘贴将显示"// BENCHMARKS GO HERE"
的位置。
为了运行基准测试,而后作:
web
dotnet run -c Release -f net48 --runtimes net48 netcoreapp31 netcoreapp50 --filter ** --join
这告诉Benchmark.NET:
正则表达式
在某些状况下,针对特定目标的API并不存在,我只是省略了命令行的这一部分。
最后,请注意如下几点:
数据库
让咱们开始吧…
express
对于全部对.NET和性能感兴趣的人来讲,垃圾收集一般是他们最关心的。在减小分配上花费了大量的精力,不是由于分配行为自己特别昂贵,而是由于经过垃圾收集器(GC)清理这些分配以后的后续成本。然而,不管减小分配须要作多少工做,绝大多数工做负载都会致使这种状况发生,所以,重要的是要不断提升GC可以完成的任务和速度。
这个版本在改进GC方面作了不少工做。例如, dotnet/coreclr#25986 为GC的“mark”阶段实现了一种形式的工做窃取。.NET GC是一个“tracing”收集器,这意味着(在很是高的级别上)当它运行时,它从一组“roots”(已知的固有可访问的位置,好比静态字段)开始,从一个对象遍历到另外一个对象,将每一个对象“mark”为可访问;在全部这些遍历以后,任何没有标记的对象都是不可访问的,能够收集。此标记表明了执行集合所花费的大部分时间,而且此PR经过更好地平衡集合中涉及的每一个线程执行的工做来改进标记性能。当使用“Server GC”运行时,每一个核都有一个线程参与收集,当线程完成分配给它们的标记工做时,它们如今可以从其余线程“steal” 未完成的工做,以帮助更快地完成整个收集。
另外一个例子是,dotnet/runtime#35896 “ephemeral”段的解压进行了优化(gen0和gen1被称为 “ephemeral”,由于它们是预期只持续很短期的对象)。在段的最后一个活动对象以后,将内存页返回给操做系统。那么GC的问题就变成了,这种解解应该在何时发生,以及在任什么时候候应该解解多少,由于在不久的未来,它可能须要为额外的分配分配额外的页面。
或者以dotnet/runtime#32795,为例,它经过减小在GC静态扫描中涉及的锁争用,提升了在具备较高核心计数的机器上的GC可伸缩性。或者dotnet/runtime#37894,它避免了代价高昂的内存重置(本质上是告诉操做系统相关的内存再也不感兴趣),除非GC看到它处于低内存的状况。或者dotnet/runtime#37159,它(虽然尚未合并,预计将用于.NET5 )构建在@damageboy的工做之上,用于向量化GC中使用的排序。或者 dotnet/coreclr#27729,它减小了GC挂起线程所花费的时间,这对于它得到一个稳定的视图,从而准确地肯定正在使用的线程是必要的。
这只是改进GC自己所作的部分更改,但最后一点给我带来了一个特别吸引个人话题,由于它涉及到近年来咱们在.NET中所作的许多工做。在这个版本中,咱们继续,甚至加快了从C/C++移植coreclr运行时中的本地实现,以取代System.Private.Corelib中的普通c#托管代码。此举有大量的好处,包括让咱们更容易共享一个实现跨多个运行时(如coreclr和mono),甚至对咱们来讲更容易进化API表面积,如经过重用相同的逻辑来处理数组和跨越。但让一些人吃惊的是,这些好处还包括多方面的性能。其中一种方法回溯到使用托管运行时的最初动机:安全性。默认状况下,用c#编写的代码是“safe”,由于运行时确保全部内存访问都检查了边界,只有经过代码中可见的显式操做(例如使用unsafe关键字,Marshal类,unsafe类等),开发者才能删除这种验证。结果,做为一个开源项目的维护人员,咱们的工做的航运安全系统在很大程度上使当贡献托管代码的形式:虽然这样的代码能够固然包含错误,可能会经过代码审查和自动化测试,咱们能够晚上睡得更好知道这些bug引入安全问题的概率大大下降。这反过来意味着咱们更有可能接受托管代码的改进,而且速度更快,贡献者提供的更快,咱们帮助验证的更快。咱们还发现,当使用c#而不是C时,有更多的贡献者对探索性能改进感兴趣,并且更多的人以更快的速度进行实验,从而得到更好的性能。
然而,咱们从移植中看到了更直接的性能改进。托管代码调用运行时所需的开销相对较小,可是若是调用频率很高,那么开销就会增长。考虑dotnet/coreclr#27700,它将原始类型数组排序的实现从coreclr的本地代码移到了Corelib的c#中。除了这些代码以外,它还为新的公共api提供了对跨度进行排序的支持,它还下降了对较小数组进行排序的成本,由于排序的成本主要来自于从托管代码的转换。咱们能够在一个小的基准测试中看到这一点,它只是使用数组。对包含10个元素的int[], double[]和string[]数组进行排序:
json
public class DoubleSorting : Sorting<double> { protected override double GetNext() => _random.Next(); } public class Int32Sorting : Sorting<int> { protected override int GetNext() => _random.Next(); } public class StringSorting : Sorting<string> { protected override string GetNext() { var dest = new char[_random.Next(1, 5)]; for (int i = 0; i < dest.Length; i++) dest[i] = (char)('a' + _random.Next(26)); return new string(dest); } } public abstract class Sorting<T> { protected Random _random; private T[] _orig, _array; [Params(10)] public int Size { get; set; } protected abstract T GetNext(); [GlobalSetup] public void Setup() { _random = new Random(42); _orig = Enumerable.Range(0, Size).Select(_ => GetNext()).ToArray(); _array = (T[])_orig.Clone(); Array.Sort(_array); } [Benchmark] public void Random() { _orig.AsSpan().CopyTo(_array); Array.Sort(_array); } }
Type | Runtime | Mean | Ratio |
---|---|---|---|
DoubleSorting | .NET FW 4.8 | 88.88 ns | 1.00 |
DoubleSorting | .NET Core 3.1 | 73.29 ns | 0.83 |
DoubleSorting | .NET 5.0 | 35.83 ns | 0.40 |
Int32Sorting | .NET FW 4.8 | 66.34 ns | 1.00 |
Int32Sorting | .NET Core 3.1 | 48.47 ns | 0.73 |
Int32Sorting | .NET 5.0 | 31.07 ns | 0.47 |
StringSorting | .NET FW 4.8 | 2,193.86 ns | 1.00 |
StringSorting | .NET Core 3.1 | 1,713.11 ns | 0.78 |
StringSorting | .NET 5.0 | 1,400.96 ns | 0.64 |
这自己就是此次迁移的一个很好的好处,由于咱们在.NET5中经过dotnet/runtime#37630 添加了System.Half,一个新的原始16位浮点,而且在托管代码中,这个排序实现的优化几乎当即应用到它,而之前的本地实现须要大量的额外工做,由于没有c++标准类型的一半。可是,这里还有一个更有影响的性能优点,这让咱们回到我开始讨论的地方:GC。
GC的一个有趣指标是“pause time”,这实际上意味着GC必须暂停运行时多长时间才能执行其工做。更长的暂停时间对延迟有直接的影响,而延迟是全部工做负载方式的关键指标。正如前面提到的,GC可能须要暂停线程为了获得一个一致的世界观,并确保它能安全地移动对象,可是若是一个线程正在执行C/c++代码在运行时,GC可能须要等到调用完成以前暂停的线程。所以,咱们在托管代码而不是本机代码中作的工做越多,GC暂停时间就越好。咱们能够使用相同的数组。排序的例子,看看这个。考虑一下这个程序:
小程序
using System; using System.Diagnostics; using System.Threading; class Program { public static void Main() { new Thread(() => { var a = new int[20]; while (true) Array.Sort(a); }) { IsBackground = true }.Start(); var sw = new Stopwatch(); while (true) { sw.Restart(); for (int i = 0; i < 10; i++) { GC.Collect(); Thread.Sleep(15); } Console.WriteLine(sw.Elapsed.TotalSeconds); } } }
这是让一个线程在一个紧密循环中不断地对一个小数组排序,而在主线程上,它执行10次GCs,每次GCs之间大约有15毫秒。咱们预计这个循环会花费150毫秒多一点的时间。但当我在.NET Core 3.1上运行时,我获得的秒数是这样的
6.6419048 5.5663149 5.7430339 6.032052 7.8892468
在这里,GC很难中断执行排序的线程,致使GC暂停时间远远高于预期。幸运的是,当我在 .NET5 上运行这个时,我获得了这样的数字:
0.159311 0.159453 0.1594669 0.1593328 0.1586566
这正是咱们预测的结果。经过移动数组。将实现排序到托管代码中,这样运行时就能够在须要时更容易地挂起实现,咱们使GC可以更好地完成其工做。
固然,这不只限于Array.Sort。 一堆PR进行了这样的移植,例如dotnet/runtime#32722将stdelemref和ldelemaref JIT helper 移动到C#,dotnet/runtime#32353 将unbox helpers的一部分移动到C#(并使用适当的GC轮询位置来检测其他部分) GC在其他位置适当地暂停),dotnet/coreclr#27603 / dotnet/coreclr#27634 / dotnet/coreclr#27123 / dotnet/coreclr#27776 移动更多的数组实现,如Array.Clear和Array.Copy到C#, dotnet/coreclr#27216 将更多Buffer移至C#,而dotnet/coreclr#27792将Enum.CompareTo移至C#。 这些更改中的一些而后启用了后续增益,例如 dotnet/runtime#32342和dotnet/runtime#35733,它们利用Buffer.Memmove的改进来在各类字符串和数组方法中得到额外的收益。
关于这组更改的最后一个想法是,须要注意的另外一件有趣的事情是,在一个版本中所作的微优化是如何基于后来被证实无效的假设的,而且当使用这种微优化时,须要准备并愿意适应。在个人.NET Core 3.0博客中,我提到了像dotnet/coreclr#21756这样的“peanut butter”式的改变,它改变了不少使用数组的调用站点。复制(源,目标,长度),而不是使用数组。复制(source, sourceOffset, destination, destinationOffset, length),由于前者获取源数组和目标数组的下限的开销是可测量的。可是经过前面提到的将数组处理代码移动到c#的一系列更改,更简单的重载的开销消失了,使其成为这些操做更简单、更快的选择。这样,.NET5 PRs dotnet/coreclr#27641和dotnet/corefx#42343切换了全部这些呼叫站点,更多地回到使用更简单的过载。dotnet/runtime#36304是另外一个取消以前优化的例子,由于更改使它们过期或实际上有害。你老是可以传递一个字符到字符串。分裂,如version.Split (' . ')。然而,问题是,这个绑定到Split的惟一重载是Split(params char[] separator),这意味着每次这样的调用都会致使c#编译器生成一个char[]分配。为了解决这个问题,之前的版本添加了缓存,提早分配数组并将它们存储到静态中,而后能够被分割调用使用,以免每一个调用都使用char[]。既然.NET中有一个Split(char separator, StringSplitOptions options = StringSplitOptions. none)重载,咱们就再也不须要数组了。
做为最后一个示例,我展现了将代码移出运行时并转移到托管代码中如何帮助GC暂停,可是固然还有其余方式能够使运行时中剩余的代码对此有所帮助。dotnet/runtime#36179经过确保运行时处于代码争抢模式下(例如获取“Watson”存储桶参数(基本上是一组用于惟一标识此特定异常和调用堆栈以用于报告目的的数据)),从而减小了因为异常处理而致使的GC暂停。 。暂停。
.NET5 也是即时(JIT)编译器的一个使人兴奋的版本,该版本中包含了各类各样的改进。与任何编译器同样,对JIT的改进能够产生普遍的影响。一般,单独的更改对单独的代码段的影响很小,可是这样的更改会被它们应用的地方的数量放大。
能够向JIT添加的优化的数量几乎是无限的,若是给JIT无限的时间来运行这种优化,JIT就能够为任何给定的场景建立最优代码。可是JIT的时间并非无限的。JIT的“即时”特性意味着它在应用程序运行时执行编译:当调用还没有编译的方法时,JIT须要按需为其提供汇编代码。这意味着在编译完成以前线程不能向前推动,这反过来意味着JIT须要在应用什么优化以及如何选择使用有限的时间预算方面有策略。各类技术用于给JIT更多的时间,好比使用“提早”(AOT)编译应用程序的一些部分作尽量多的编译工做前尽量执行应用程序(例如,AOT编译核心库都使用一个叫“ReadyToRun”的技术,你可能会听到称为“R2R”甚至“crossgen”,是产生这些图像的工具),或使用“tiered compilation”,它容许JIT在最初编译一个应用了从少到少优化的方法,所以速度很是快,只有在它被认为有价值的时候(即该方法被重复使用的时候),才会花更多的时间使用更多优化来从新编译它。然而,更广泛的状况是,参与JIT的开发人员只是选择使用分配的时间预算进行优化,根据开发人员编写的代码和他们使用的代码模式,这些优化被证实是有价值的。这意味着,随着.NET的发展并得到新的功能、新的语言特性和新的库特性,JIT也会随着适合于编写的较新的代码风格的优化而发展。
一个很好的例子是@benaadams的dotnet/runtime#32538。 Span 一直渗透到.NET堆栈的全部层,由于从事运行时,核心库,ASP.NET Core的开发人员以及其余人在编写安全有效的代码(也统一了字符串处理)时认识到了它的强大功能 ,托管数组,本机分配的内存和其余形式的数据。 相似地,值类型(结构)被愈来愈广泛地用做经过堆栈分配避免对象分配开销的一种方式。 可是,对此类类型的严重依赖也给运行时带来了更多麻烦。 coreclr运行时使用“precise” garbage collector,这意味着GC可以100%准确地跟踪哪些值引用托管对象,哪些值不引用托管对象; 这样作有好处,但也有代价(相反,mono运行时使用“conservative”垃圾收集器,这具备一些性能上的好处,但也意味着它能够解释堆栈上的任意值,而该值刚好与 被管理对象的地址做为对该对象的实时引用)。 这样的代价之一是,JIT须要经过确保在GC注意以前将任何能够解释为对象引用的局部都清零来帮助GC。 不然,GC可能最终会在还没有设置的本地中看到一个垃圾值,并假定它引用的是有效对象,这时可能会发生“bad things”。 参考当地人越多,须要进行的清理越多。 若是您只清理一些当地人,那可能不会引发注意。 可是随着数量的增长,清除这些本地对象所花费的时间可能加起来,尤为是在很是热的代码路径中使用的一种小方法中。 这种状况在跨度和结构中变得更加广泛,在这种状况下,编码模式一般会致使须要为零的更多引用(Span 包含引用)。 前面提到的PR经过更新JIT生成的序号块的代码来解决此问题,这些序号块使用xmm寄存器而不是rep stosd指令来执行该清零操做。 有效地,它对归零进行矢量化处理。 您能够经过如下基准测试看到此影响:
[Benchmark] public int Zeroing() { ReadOnlySpan<char> s1 = "hello world"; ReadOnlySpan<char> s2 = Nop(s1); ReadOnlySpan<char> s3 = Nop(s2); ReadOnlySpan<char> s4 = Nop(s3); ReadOnlySpan<char> s5 = Nop(s4); ReadOnlySpan<char> s6 = Nop(s5); ReadOnlySpan<char> s7 = Nop(s6); ReadOnlySpan<char> s8 = Nop(s7); ReadOnlySpan<char> s9 = Nop(s8); ReadOnlySpan<char> s10 = Nop(s9); return s1.Length + s2.Length + s3.Length + s4.Length + s5.Length + s6.Length + s7.Length + s8.Length + s9.Length + s10.Length; } [MethodImpl(MethodImplOptions.NoInlining)] private static ReadOnlySpan<char> Nop(ReadOnlySpan<char> span) => default;
在个人机器上,我获得以下结果:
Method | Runtime | Mean | Ratio |
---|---|---|---|
Zeroing | .NET FW 4.8 | 22.85 ns | 1.00 |
Zeroing | .NET Core 3.1 | 18.60 ns | 0.81 |
Zeroing | .NET 5.0 | 15.07 ns | 0.66 |
请注意,这种零实际上须要在比我提到的更多的状况下。特别是,默认状况下,c#规范要求在执行开发人员的代码以前,将全部本地变量初始化为默认值。你能够经过这样一个例子来了解这一点:
using System; using System.Runtime.CompilerServices; using System.Threading; unsafe class Program { static void Main() { while (true) { Example(); Thread.Sleep(1); } } [MethodImpl(MethodImplOptions.NoInlining)] static void Example() { Guid g; Console.WriteLine(*&g); } }
运行它,您应该只看到全部0输出的guid。这是由于c#编译器在编译的示例方法的IL中发出一个.locals init标志,而.locals init告诉JIT它须要将全部的局部变量归零,而不只仅是那些包含引用的局部变量。然而,在.NET 5中,运行时中有一个新属性(dotnet/runtime#454):
namespace System.Runtime.CompilerServices { [AttributeUsage(AttributeTargets.Module | AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Constructor | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Event | AttributeTargets.Interface, Inherited = false)] public sealed class SkipLocalsInitAttribute : Attribute { } }
c#编译器能够识别这个属性,它用来告诉编译器在其余状况下不发出.locals init。若是咱们对前面的示例稍加修改,就能够将属性添加到整个模块中:
using System; using System.Runtime.CompilerServices; using System.Threading; [module: SkipLocalsInit] unsafe class Program { static void Main() { while (true) { Example(); Thread.Sleep(1); } } [MethodImpl(MethodImplOptions.NoInlining)] static void Example() { Guid g; Console.WriteLine(*&g); } }
如今应该会看到不一样的结果,特别是极可能会看到非零的guid。在dotnet/runtime#37541中,.NET5 中的核心库如今都使用这个属性来禁用.locals init(在之前的版本中,.locals init在构建核心库时经过编译后的一个步骤删除)。请注意,c#编译器只容许在不安全的上下文中使用SkipLocalsInit,由于它很容易致使未通过适当验证的代码损坏(所以,若是/当您应用它时,请三思)。
除了使零的速度更快,也有改变,以消除零彻底。例如,dotnet/runtime#31960, dotnet/runtime#36918, dotnet/runtime#37786,和dotnet/runtime#38314 都有助于消除零,当JIT能够证实它是重复的。
这样的零是托管代码的一个例子,运行时须要它来保证其模型和上面语言的需求。另外一种此类税收是边界检查。使用托管代码的最大优点之一是,在默认状况下,整个类的潜在安全漏洞都变得可有可无。运行时确保数组、字符串和span的索引被检查,这意味着运行时注入检查以确保被请求的索引在被索引的数据的范围内(即greather大于或等于0,小于数据的长度)。这里有一个简单的例子:
public static char Get(string s, int i) => s[i];
为了保证这段代码的安全,运行时须要生成一个检查,检查i是否在字符串s的范围内,这是JIT经过以下程序集完成的:
; Program.Get(System.String, Int32) sub rsp,28 cmp edx,[rcx+8] jae short M01_L00 movsxd rax,edx movzx eax,word ptr [rcx+rax*2+0C] add rsp,28 ret M01_L00: call CORINFO_HELP_RNGCHKFAIL int 3 ; Total bytes of code 28
这个程序集是经过Benchmark的一个方便特性生成的。将[DisassemblyDiagnoser]添加到包含基准测试的类中,它就会吐出被分解的汇编代码。咱们能够看到,大会将字符串(经过rcx寄存器)和加载字符串的长度(8个字节存储到对象,所以,[rcx + 8]),与我通过比较,edx登记,若是与一个无符号的比较(无符号,这样任何负环绕大于长度)我是长度大于或等于,跳到一个辅助COREINFO_HELP_RNGCHKFAIL抛出一个异常。只有几条指令,可是某些类型的代码可能会花费大量的循环索引,所以,当JIT能够消除尽量多的没必要要的边界检查时,这是颇有帮助的。
JIT已经可以在各类状况下删除边界检查。例如,当你写循环:
int[] arr = ...; for (int i = 0; i < arr.Length; i++) Use(arr[i]);
JIT能够证实我永远不会超出数组的边界,所以它能够省略它将生成的边界检查。在.NET5 中,它能够在更多的地方删除边界检查。例如,考虑这个函数,它将一个整数的字节做为字符写入一个span:
private static bool TryToHex(int value, Span<char> span) { if ((uint)span.Length <= 7) return false; ReadOnlySpan<byte> map = new byte[] { (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F' }; ; span[0] = (char)map[(value >> 28) & 0xF]; span[1] = (char)map[(value >> 24) & 0xF]; span[2] = (char)map[(value >> 20) & 0xF]; span[3] = (char)map[(value >> 16) & 0xF]; span[4] = (char)map[(value >> 12) & 0xF]; span[5] = (char)map[(value >> 8) & 0xF]; span[6] = (char)map[(value >> 4) & 0xF]; span[7] = (char)map[value & 0xF]; return true; } private char[] _buffer = new char[100]; [Benchmark] public bool BoundsChecking() => TryToHex(int.MaxValue, _buffer);
首先,在这个例子中,值得注意的是咱们依赖于c#编译器的优化。注意:
ReadOnlySpan<byte> map = new byte[] { (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F' };
这看起来很是昂贵,就像咱们在每次调用TryToHex时都要分配一个字节数组。事实上,它并非这样的,它实际上比咱们作的更好:
private static readonly byte[] s_map = new byte[] { (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F' }; ... ReadOnlySpan<byte> map = s_map;
C#编译器能够识别直接分配给ReadOnlySpan的新字节数组的模式(它也能够识别sbyte和bool,但因为字节关系,没有比字节大的)。由于数组的性质被span彻底隐藏了,C#编译器经过将字节实际存储到程序集的数据部分而发出这些字节,而span只是经过将静态数据和长度的指针包装起来而建立的:
IL_000c: ldsflda valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=16' '<PrivateImplementationDetails>'::'2125B2C332B1113AAE9BFC5E9F7E3B4C91D828CB942C2DF1EEB02502ECCAE9E9' IL_0011: ldc.i4.s 16 IL_0013: newobj instance void valuetype [System.Runtime]System.ReadOnlySpan'1<uint8>::.ctor(void*, int32)
因为ldc.i4,这对于本次JIT讨论很重要。s16在上面。这就是IL加载16的长度来建立跨度,JIT能够看到这一点。它知道跨度的长度是16,这意味着若是它能够证实访问老是大于或等于0且小于16的值,它就不须要对访问进行边界检查。dotnet/runtime#1644 就是这样作的,它能够识别像array[index % const]这样的模式,并在const小于或等于长度时省略边界检查。在前面的TryToHex示例中,JIT能够看到地图跨长度16,和它能够看到全部的索引到完成& 0 xf,意义最终将全部值在范围内,所以它能够消除全部的边界检查地图。结合的事实可能已经看到,没有边界检查须要写进跨度(由于它能够看到前面长度检查的方法保护全部索引到跨度),和整个方法是在.NET bounds-check-free 5。在个人机器上,这个基准测试的结果以下:
Method | Runtime | Mean | Ratio | Code Size |
---|---|---|---|---|
BoundsChecking | .NET FW 4.8 | 14.466 ns | 1.00 | 830 B |
BoundsChecking | .NET Core 3.1 | 4.264 ns | 0.29 | 320 B |
BoundsChecking | .NET 5.0 | 3.641 ns | 0.25 | 249 B |
注意.NET5的运行速度不只比.NET Core 3.1快15%,咱们还能够看到它的汇编代码大小小了22%(额外的“Code Size”一栏来自于我在benchmark类中添加了[DisassemblyDiagnoser])。
另外一个很好的边界检查移除来自dotnet/runtime#36263中的@nathan-moore。我提到过,JIT已经可以删除很是常见的从0迭代到数组、字符串或span长度的模式的边界检查,可是在此基础上还有一些比较常见的变化,但之前没有认识到。例如,考虑这个微基准测试,它调用一个方法来检测一段整数是否被排序:
private int[] _array = Enumerable.Range(0, 1000).ToArray(); [Benchmark] public bool IsSorted() => IsSorted(_array); private static bool IsSorted(ReadOnlySpan<int> span) { for (int i = 0; i < span.Length - 1; i++) if (span[i] > span[i + 1]) return false; return true; }
这种与之前识别的模式的微小变化足以防止JIT忽略边界检查。如今不是了.NET5在个人机器上能够快20%的执行:
Method | Runtime | Mean | Ratio | Code Size |
---|---|---|---|---|
IsSorted | .NET FW 4.8 | 1,083.8 ns | 1.00 | 236 B |
IsSorted | .NET Core 3.1 | 581.2 ns | 0.54 | 136 B |
IsSorted | .NET 5.0 | 463.0 ns | 0.43 | 105 B |
JIT确保对某个错误类别进行检查的另外一种状况是空检查。JIT与运行时协同完成这一任务,JIT确保有适当的指令来引起硬件异常,而后与运行时一块儿将这些错误转换为.NET异常(这里))。但有时指令只用于null检查,而不是完成其余必要的功能,并且只要须要的null检查是因为某些指令发生的,没必要要的重复指令能够被删除。考虑这段代码:
private (int i, int j) _value; [Benchmark] public int NullCheck() => _value.j++;
做为一个可运行的基准测试,它所作的工做太少,没法用基准测试进行准确的度量.NET,但这是查看生成的汇编代码的好方法。在.NET Core 3.1中,此方法产生以下assembly:
; Program.NullCheck() nop dword ptr [rax+rax] cmp [rcx],ecx add rcx,8 add rcx,4 mov eax,[rcx] lea edx,[rax+1] mov [rcx],edx ret ; Total bytes of code 23
cmp [rcx],ecx指令在计算j的地址时执行null检查,而后mov eax,[rcx]指令执行另外一个null检查,做为取消引用j的位置的一部分。所以,第一个null检查其实是没必要要的,由于该指令没有提供任何其余好处。因此,多亏了像dotnet/runtime#1735和dotnet/runtime#32641这样的PRs,这样的重复被JIT比之前更多地识别,对于.NET 5,咱们如今获得了:
; Program.NullCheck() add rcx,0C mov eax,[rcx] lea edx,[rax+1] mov [rcx],edx ret ; Total bytes of code 12
协方差是JIT须要注入检查以确保开发人员不会意外地破坏类型或内存安全性的另外一种状况。考虑一下代码
class A { } class B { } object[] arr = ...; arr[0] = new A();
这个代码有效吗?视状况而定。.NET中的数组是“协变”的,这意味着我能够传递一个数组派生类型[]做为BaseType[],其中派生类型派生自BaseType。这意味着在本例中,arr能够被构造为新A[1]或新对象[1]或新B[1]。这段代码应该在前两个中运行良好,但若是arr其实是一个B[],试图存储一个实例到其中必须失败;不然,使用数组做为B[]的代码可能尝试使用B[0]做为B,事情可能很快就会变得很糟糕。所以,运行时须要经过协方差检查来防止这种状况发生,这实际上意味着当引用类型实例存储到数组中时,运行时须要检查所分配的类型实际上与数组的具体类型兼容。使用dotnet/runtime#189, JIT如今可以消除更多的协方差检查,特别是在数组的元素类型是密封的状况下,好比string。所以,像这样的微基准如今运行得更快了:
private string[] _array = new string[1000]; [Benchmark] public void CovariantChecking() { string[] array = _array; for (int i = 0; i < array.Length; i++) array[i] = "default"; }
Method | Runtime | Mean | Ratio | Code Size |
---|---|---|---|---|
CovariantChecking | .NET FW 4.8 | 2.121 us | 1.00 | 57 B |
CovariantChecking | .NET Core 3.1 | 2.122 us | 1.00 | 57 B |
CovariantChecking | .NET 5.0 | 1.666 us | 0.79 | 52 B |
与此相关的是类型检查。我以前提到过Span
using System; class Program { static void Main() => new Span<A>(new B[42]); } class A { } class B : A { }
System.ArrayTypeMismatchException: Attempted to access an element as a type incompatible with the array
if (!typeof(T).IsValueType && array.GetType() != typeof(T[])) ThrowHelper.ThrowArrayTypeMismatchException();
PR dotnet/runtime#32790就是这样优化数组的.GetType()!= typeof(T [])检查什么时候密封T,而dotnet/runtime#1157识别typeof(T).IsValueType模式并将其替换为常量 值(PR dotnet/runtime#1195对于typeof(T1).IsAssignableFrom(typeof(T2))进行了相同的操做)。 这样作的最终结果是极大地改善了微基准,例如:
class A { } sealed class B : A { } private B[] _array = new B[42]; [Benchmark] public int Ctor() => new Span<B>(_array).Length;
我获得的结果以下:
Method | Runtime | Mean | Ratio | Code Size |
---|---|---|---|---|
Ctor | .NET FW 4.8 | 48.8670 ns | 1.00 | 66 B |
Ctor | .NET Core 3.1 | 7.6695 ns | 0.16 | 66 B |
Ctor | .NET 5.0 | 0.4959 ns | 0.01 | 17 B |
当查看生成的程序集时,差别的解释就很明显了,即便不是彻底精通程序集代码。如下是[DisassemblyDiagnoser]在.NET Core 3.1上生成的内容:
; Program.Ctor() push rdi push rsi sub rsp,28 mov rsi,[rcx+8] test rsi,rsi jne short M00_L00 xor eax,eax jmp short M00_L01 M00_L00: mov rcx,rsi call System.Object.GetType() mov rdi,rax mov rcx,7FFE4B2D18AA call CORINFO_HELP_TYPEHANDLE_TO_RUNTIMETYPE cmp rdi,rax jne short M00_L02 mov eax,[rsi+8] M00_L01: add rsp,28 pop rsi pop rdi ret M00_L02: call System.ThrowHelper.ThrowArrayTypeMismatchException() int 3 ; Total bytes of code 66
下面是.NET5的内容:
; Program.Ctor() mov rax,[rcx+8] test rax,rax jne short M00_L00 xor eax,eax jmp short M00_L01 M00_L00: mov eax,[rax+8] M00_L01: ret ; Total bytes of code 17
另外一个例子是,在前面的GC讨论中,我提到了将本地运行时代码移植到c#代码中所带来的一些好处。有一点我以前没有提到,但如今将会提到,那就是它致使了咱们对系统进行了其余改进,解决了移植的关键阻滞剂,但也改善了许多其余状况。一个很好的例子是dotnet/runtime#38229。当咱们第一次将本机数组排序实现移动到managed时,咱们无心中致使了浮点值的回归,这个回归被@nietras 发现,随后在dotnet/runtime#37941中修复。回归是因为本机实现使用一个特殊的优化,咱们失踪的管理端口(浮点数组,将全部NaN值数组的开始,后续的比较操做能够忽略NaN)的可能性,咱们成功了。然而,问题是这个的方式表达并无致使大量的代码重复:本机实现模板,使用和管理实现使用泛型,但限制与泛型等,内联 helpers介绍,以免大量的代码重复致使non-inlineable在每一个比较采用那种方法调用。PR dotnet/runtime#38229经过容许JIT在同一类型内嵌共享泛型代码解决了这个问题。考虑一下这个微基准测试:
private C c1 = new C() { Value = 1 }, c2 = new C() { Value = 2 }, c3 = new C() { Value = 3 }; [Benchmark] public int Compare() => Comparer<C>.Smallest(c1, c2, c3); class Comparer<T> where T : IComparable<T> { public static int Smallest(T t1, T t2, T t3) => Compare(t1, t2) <= 0 ? (Compare(t1, t3) <= 0 ? 0 : 2) : (Compare(t2, t3) <= 0 ? 1 : 2); [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int Compare(T t1, T t2) => t1.CompareTo(t2); } class C : IComparable<C> { public int Value; public int CompareTo(C other) => other is null ? 1 : Value.CompareTo(other.Value); }
最小的方法比较提供的三个值并返回最小值的索引。它是泛型类型上的一个方法,它调用同一类型上的另外一个方法,这个方法反过来调用泛型类型参数实例上的方法。因为基准使用C做为泛型类型,并且C是引用类型,因此JIT不会专门为C专门化此方法的代码,而是使用它生成的用于全部引用类型的“shared”实现。为了让Compare方法随后调用到CompareTo的正确接口实现,共享泛型实现使用了一个从泛型类型映射到正确目标的字典。在. net的早期版本中,包含那些通用字典查找的方法是不可行的,这意味着这个最小的方法不能内联它所作的三个比较调用,即便Compare被归为methodimploptions .侵略化的内联。前面提到的PR消除了这个限制,在这个例子中产生了一个很是可测量的加速(并使数组排序回归修复可行):
Method | Runtime | Mean | Ratio |
---|---|---|---|
Compare | .NET FW 4.8 | 8.632 ns | 1.00 |
Compare | .NET Core 3.1 | 9.259 ns | 1.07 |
Compare | .NET 5.0 | 5.282 ns | 0.61 |
这里提到的大多数改进都集中在吞吐量上,JIT产生的代码执行得更快,而更快的代码一般(尽管不老是)更小。从事JIT工做的人们实际上很是关注代码大小,在许多状况下,将其做为判断更改是否有益的主要指标。更小的代码并不老是更快的代码(能够是相同大小的指令,但开销不一样),但从高层次上来讲,这是一个合理的度量,更小的代码确实有直接的好处,好比对指令缓存的影响更小,须要加载的代码更少,等等。在某些状况下,更改彻底集中在减小代码大小上,好比在出现没必要要的重复的状况下。考虑一下这个简单的基准:
private int _offset = 0; [Benchmark] public int Throw helpers() { var arr = new int[10]; var s0 = new Span<int>(arr, _offset, 1); var s1 = new Span<int>(arr, _offset + 1, 1); var s2 = new Span<int>(arr, _offset + 2, 1); var s3 = new Span<int>(arr, _offset + 3, 1); var s4 = new Span<int>(arr, _offset + 4, 1); var s5 = new Span<int>(arr, _offset + 5, 1); return s0[0] + s1[0] + s2[0] + s3[0] + s4[0] + s5[0]; }
Span
M00_L00: call System.ThrowHelper.ThrowArgumentOutOfRangeException() int 3 M00_L01: call System.ThrowHelper.ThrowArgumentOutOfRangeException() int 3 M00_L02: call System.ThrowHelper.ThrowArgumentOutOfRangeException() int 3 M00_L03: call System.ThrowHelper.ThrowArgumentOutOfRangeException() int 3 M00_L04: call System.ThrowHelper.ThrowArgumentOutOfRangeException() int 3 M00_L05: call System.ThrowHelper.ThrowArgumentOutOfRangeException() int 3
在.NET 5中,感谢dotnet/coreclr#27113, JIT可以识别这种重复,而不是全部的6个呼叫站点,它将最终合并成一个:
M00_L00: call System.ThrowHelper.ThrowArgumentOutOfRangeException() int 3
全部失败的检查都跳到这个共享位置,而不是每一个都有本身的副本
Method | Runtime | Code Size |
---|---|---|
Throw helpers | .NET FW 4.8 | 424 B |
Throw helpers | .NET Core 3.1 | 252 B |
Throw helpers | .NET 5.0 | 222 B |
这些只是.NET 5中对JIT进行的众多改进中的一部分。还有许多其余改进。dotnet/runtime#32368致使JIT将数组的长度视为无符号,这使得JIT可以对在长度上执行的某些数学运算(例如除法)使用更好的指令。 dotnet/runtime#25458 使JIT能够对某些无符号整数运算使用更快的基于0的比较。 当开发人员实际编写> = 1时,使用等于!= 0的值。dotnet/runtime#1378容许JIT将“ constantString” .Length识别为常量值。 dotnet/runtime#26740 经过删除nop填充来减少ReadyToRun图像的大小。 dotnet/runtime#330234使用加法而不是乘法来优化当x为浮点数或双精度数时执行x * 2时生成的指令。dotnet/runtime#27060改进了为Math.FusedMultiplyAdd内部函数生成的代码。 dotnet/runtime#27384经过使用比之前更好的篱笆指令使ARM64上的易失性操做便宜,而且dotnet/runtime#38179在ARM64上执行窥视孔优化以删除大量冗余mov指令。 等等。
JIT中还有一些默认禁用的重要更改,目的是得到关于它们的真实反馈,并可以在默认状况下post-启用它们。净5。例如,dotnet/runtime#32969提供了“On Stack Replacement”(OSR)的初始实现。我在前面提到了分层编译,它使JIT可以首先为一个方法生成优化最少的代码,而后当该方法被证实是重要的时,用更多的优化从新编译该方法。这容许代码运行得更快,而且只有在运行时才升级有效的方法,从而实现更快的启动时间。可是,分层编译依赖于替换实现的能力,下次调用它时,将调用新的实现。可是长时间运行的方法呢?对于包含循环(或者,更具体地说,向后分支)的方法,分层编译在默认状况下是禁用的,由于它们可能会运行很长时间,以致于没法及时使用替换。OSR容许方法在执行代码时被更新,而它们是“在堆栈上”的;PR中包含的设计文档中有不少细节(也与分层编译有关,dotnet/runtime#1457改进了调用计数机制,分层编译经过这种机制决定哪些方法应该从新编译以及什么时候从新编译)。您能够经过将COMPlus_TC_QuickJitForLoops和COMPlus_TC_OnStackReplacement环境变量设置为1来试验OSR。另外一个例子是,dotnet/runtime#1180 改进了try块内代码的生成代码质量,使JIT可以在寄存器中保存之前不能保存的值。您能够经过将COMPlus_EnableEHWriteThr环境变量设置为1来进行试验。
还有一堆等待拉请求JIT还没有合并,但极可能在.NET 5发布(除此以外,我预计还有更多在.NET 5发布以前尚未发布的内容)。例如,dotnet/runtime#32716容许JIT替换一些分支比较,如a == 42 ?3: 2无分支实现,当硬件没法正确预测将采用哪一个分支时,能够帮助提升性能。或dotnet/runtime#37226,它容许JIT采用像“hello”[0]这样的模式并将其替换为h;虽然开发人员一般不编写这样的代码,但在涉及内联时,这能够提供帮助,经过将常量字符串传递给内联的方法,并将其索引到常量位置(一般在长度检查以后,因为dotnet/runtime#1378,长度检查也能够成为常量)。或dotnet/runtime#1224,它改进了Bmi2的代码生成。MultiplyNoFlags内在。或者dotnet/runtime#37836,它将转换位操做。将PopCount转换为一个内因,使JIT可以识别什么时候使用常量参数调用它,并将整个操做替换为一个预先计算的常量。或dotnet/runtime#37254,它删除使用const字符串时发出的空检查。或者来自@damageboy的dotnet/runtime#32000 ,它优化了双重否认。
在.NET Core 3.0中,超过1000种新的硬件内置方法被添加并被JIT识别,从而使c#代码可以直接针对指令集,如SSE4和AVX2(docs)。而后,在核心库中的一组api中使用了这些工具。可是,intrinsic仅限于x86/x64架构。在.NET 5中,咱们投入了大量的精力来增长数千个组件,特别是针对ARM64,这要感谢众多贡献者,特别是来自Arm Holdings的@TamarChristinaArm。与对应的x86/x64同样,这些内含物在核心库功能中获得了很好的利用。例如,BitOperations.PopCount()方法以前被优化为使用x86 POPCNT内在的,对于.NET 5, dotnet/runtime#35636 加强了它,使它也可以使用ARM VCNT或等价的ARM64 CNT。相似地,dotnet/runtime#34486修改了位操做。LeadingZeroCount, TrailingZeroCount和Log2利用相应的instrincs。在更高的级别上,来自@Gnbrkm41的dotnet/runtime#33749加强了位数组中的多个方法,以使用ARM64内含物来配合以前添加的对SSE2和AVX2的支持。为了确保Vector api在ARM64上也能很好地执行,咱们作了不少工做,好比dotnet/runtime#33749和dotnet/runtime#36156。
除ARM64以外,还进行了其余工做以向量化更多操做。 例如,@Gnbrkm41还提交了dotnet/runtime#31993,该文件利用x64上的ROUNDPS / ROUNDPD和ARM64上的FRINPT / FRINTM来改进为新Vector.Ceiling和Vector.Floor方法生成的代码。 BitOperations(这是一种相对低级的类型,针对大多数操做以最合适的硬件内部函数的1:1包装器的形式实现),不只在@saucecontrol 的dotnet/runtime#35650中获得了改进,并且在Corelib中的使用也获得了改进 更有效率。
最后,JIT进行了大量的修改,以更好地处理硬件内部特性和向量化,好比dotnet/runtime#35421, dotnet/runtime#31834, dotnet/runtime#1280, dotnet/runtime#35857, dotnet/runtime#36267和 dotnet/runtime#35525。
GC和JIT表明了运行时的大部分,可是在运行时中这些组件以外仍然有至关一部分功能,而且这些功能也有相似的改进。
有趣的是,JIT不会为全部东西从头生成代码。JIT在不少地方调用了预先存在的 helpers函数,运行时提供这些 helpers,对这些 helpers的改进能够对程序产生有意义的影响。dotnet/runtime#23548 是一个很好的例子。在像System这样的图书馆中。Linq,咱们避免为协变接口添加额外的类型检查,由于它们的开销比普通接口高得多。本质上,dotnet/runtime#23548 (随后在dotnet/runtime#34427中进行了调整)增长了一个缓存,这样这些数据转换的代价被平摊,最终整体上更快了。这从一个简单的微基准测试中就能够明显看出:
private List<string> _list = new List<string>(); // IReadOnlyCollection<out T> is covariant [Benchmark] public bool IsIReadOnlyCollection() => IsIReadOnlyCollection(_list); [MethodImpl(MethodImplOptions.NoInlining)] private static bool IsIReadOnlyCollection(object o) => o is IReadOnlyCollection<int>;
Method | Runtime | Mean | Ratio | Code Size |
---|---|---|---|---|
IsIReadOnlyCollection | .NET FW 4.8 | 105.460 ns | 1.00 | 53 B |
IsIReadOnlyCollection | .NET Core 3.1 | 56.252 ns | 0.53 | 59 B |
IsIReadOnlyCollection | .NET 5.0 | 3.383 ns | 0.03 | 45 B |
另外一组有影响的更改出如今dotnet/runtime#32270中(在dotnet/runtime#31957中支持JIT)。在过去,泛型方法只维护了几个专用的字典槽,能够用于快速查找与泛型方法相关的类型;一旦这些槽用完,它就会回到一个较慢的查找表。这种限制再也不存在,这些更改使快速查找槽可用于全部通用查找。
[Benchmark] public void GenericDictionaries() { for (int i = 0; i < 14; i++) GenericMethod<string>(i); } [MethodImpl(MethodImplOptions.NoInlining)] private static object GenericMethod<T>(int level) { switch (level) { case 0: return typeof(T); case 1: return typeof(List<T>); case 2: return typeof(List<List<T>>); case 3: return typeof(List<List<List<T>>>); case 4: return typeof(List<List<List<List<T>>>>); case 5: return typeof(List<List<List<List<List<T>>>>>); case 6: return typeof(List<List<List<List<List<List<T>>>>>>); case 7: return typeof(List<List<List<List<List<List<List<T>>>>>>>); case 8: return typeof(List<List<List<List<List<List<List<List<T>>>>>>>>); case 9: return typeof(List<List<List<List<List<List<List<List<List<T>>>>>>>>>); case 10: return typeof(List<List<List<List<List<List<List<List<List<List<T>>>>>>>>>>); case 11: return typeof(List<List<List<List<List<List<List<List<List<List<List<T>>>>>>>>>>>); case 12: return typeof(List<List<List<List<List<List<List<List<List<List<List<List<T>>>>>>>>>>>>); default: return typeof(List<List<List<List<List<List<List<List<List<List<List<List<List<T>>>>>>>>>>>>>); } }
Method | Runtime | Mean | Ratio |
---|---|---|---|
GenericDictionaries | .NET FW 4.8 | 104.33 ns | 1.00 |
GenericDictionaries | .NET Core 3.1 | 76.71 ns | 0.74 |
GenericDictionaries | .NET 5.0 | 51.53 ns | 0.49 |
基于文本的处理是许多应用程序的基础,而且在每一个版本中都花费了大量的精力来改进基础构建块,其余全部内容都构建在这些基础构建块之上。这些变化从 helpers处理单个字符的微优化一直延伸到整个文本处理库的大修。
系统。Char在NET 5中获得了一些不错的改进。例如,dotnet/coreclr#26848提升了char的性能。经过调整实现来要求更少的指令和更少的分支。改善char。IsWhiteSpace随后在一系列依赖于它的其余方法中出现,好比string.IsEmptyOrWhiteSpace和调整:
[Benchmark] public int Trim() => " test ".AsSpan().Trim().Length;
Method | Runtime | Mean | Ratio | Code Size |
---|---|---|---|---|
Trim | .NET FW 4.8 | 21.694 ns | 1.00 | 569 B |
Trim | .NET Core 3.1 | 8.079 ns | 0.37 | 377 B |
Trim | .NET 5.0 | 6.556 ns | 0.30 | 365 B |
另外一个很好的例子,dotnet/runtime#35194改进了char的性能。ToUpperInvariant和char。经过改进各类方法的内联性,将调用路径从公共api简化到核心功能,并进一步调整实现以确保JIT生成最佳代码,从而实现owerinvariant。
[Benchmark] [Arguments("It's exciting to see great performance!")] public int ToUpperInvariant(string s) { int sum = 0; for (int i = 0; i < s.Length; i++) sum += char.ToUpperInvariant(s[i]); return sum; }
Method | Runtime | Mean | Ratio | Code Size |
---|---|---|---|---|
ToUpperInvariant | .NET FW 4.8 | 208.34 ns | 1.00 | 171 B |
ToUpperInvariant | .NET Core 3.1 | 166.10 ns | 0.80 | 164 B |
ToUpperInvariant | .NET 5.0 | 69.15 ns | 0.33 | 105 B |
除了单个字符以外,实际上在.NET Core的每一个版本中,咱们都在努力提升现有格式化api的速度。此次发布也没有什么不一样。尽管以前的版本取得了巨大的成功,但这一版本将门槛进一步提升。Int32.ToString()
是一个很是常见的操做,重要的是它要快。来自@ts2do的dotnet/runtime#32528 经过为该方法使用的关键格式化例程添加不可连接的快速路径,并经过简化各类公共api到达这些例程的路径,使其更快。其余原始ToString操做也获得了改进。例如,dotnet/runtime#27056简化了一些代码路径,以减小从公共API到实际将位写入内存的位置的冗余。
[Benchmark] public string ToString12345() => 12345.ToString(); [Benchmark] public string ToString123() => ((byte)123).ToString();
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
ToString12345 | .NET FW 4.8 | 45.737 ns | 1.00 | 40 B |
ToString12345 | .NET Core 3.1 | 20.006 ns | 0.44 | 32 B |
ToString12345 | .NET 5.0 | 10.742 ns | 0.23 | 32 B |
ToString123 | .NET FW 4.8 | 42.791 ns | 1.00 | 32 B |
ToString123 | .NET Core 3.1 | 18.014 ns | 0.42 | 32 B |
ToString123 | .NET 5.0 | 7.801 ns | 0.18 | 32 B |
相似的,在以前的版本中,咱们对DateTime和DateTimeOffset作了大量的优化,但这些改进主要集中在日/月/年/等等的转换速度上。将数据转换为正确的字符或字节,并将其写入目的地。在dotnet/runtime#1944中,@ts2do专一于以前的步骤,优化提取日/月/年/等等。DateTime{Offset}从原始滴答计数中存储。最终很是富有成果,致使可以输出格式如“o”(“往返日期/时间模式”)比之前快了30%(变化也应用一样的分解优化在其余地方在这些组件的代码库须要从一个DateTime,但改进是最容易显示在一个标准格式):
private byte[] _bytes = new byte[100]; private char[] _chars = new char[100]; private DateTime _dt = DateTime.Now; [Benchmark] public bool FormatChars() => _dt.TryFormat(_chars, out _, "o"); [Benchmark] public bool FormatBytes() => Utf8Formatter.TryFormat(_dt, _bytes, out _, 'O');
Method | Runtime | Mean | Ratio |
---|---|---|---|
FormatChars | .NET Core 3.1 | 242.4 ns | 1.00 |
FormatChars | .NET 5.0 | 176.4 ns | 0.73 |
FormatBytes | .NET Core 3.1 | 235.6 ns | 1.00 |
FormatBytes | .NET 5.0 | 176.1 ns | 0.75 |
对字符串的操做也有不少改进,好比dotnet/coreclr#26621和dotnet/coreclr#26962,在某些状况下显著提升了区域性感知的Linux上的起始和结束操做的性能。
固然,低级处理是很好的,可是如今的应用程序花费了大量的时间来执行高级操做,好比以特定格式编码数据,好比以前的.NET Core版本是对Encoding.UTF8进行了优化,但在.NET 5中仍有进一步的改进。dotnet/runtime#27268优化它,特别是对于较小的投入,以更好地利用堆栈分配和改进了JIT devirtualization (JIT是可以避免虚拟调度因为可以发现实际的具体类型实例的处理)。
[Benchmark] public string Roundtrip() { byte[] bytes = Encoding.UTF8.GetBytes("this is a test"); return Encoding.UTF8.GetString(bytes); }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
Roundtrip | .NET FW 4.8 | 113.69 ns | 1.00 | 96 B |
Roundtrip | .NET Core 3.1 | 49.76 ns | 0.44 | 96 B |
Roundtrip | .NET 5.0 | 36.70 ns | 0.32 | 96 B |
与UTF8一样重要的是“ISO-8859-1”编码,也被称为“Latin1”(如今公开表示为编码)。Encoding.Latin1经过dotnet/runtime#37550),也很是重要,特别是对于像HTTP这样的网络协议。dotnet/runtime#32994对其实现进行了向量化,这在很大程度上是基于之前对Encoding.ASCII进行的相似优化。这将产生很是好的性能提高,这能够显著地影响诸如HttpClient这样的客户机和诸如Kestrel这样的服务器中的高层使用。
private static readonly Encoding s_latin1 = Encoding.GetEncoding("iso-8859-1"); [Benchmark] public string Roundtrip() { byte[] bytes = s_latin1.GetBytes("this is a test. this is only a test. did it work?"); return s_latin1.GetString(bytes); }
Method | Runtime | Mean | Allocated |
---|---|---|---|
Roundtrip | .NET FW 4.8 | 221.85 ns | 209 B |
Roundtrip | .NET Core 3.1 | 193.20 ns | 200 B |
Roundtrip | .NET 5.0 | 41.76 ns | 200 B |
编码性能的改进也扩展到了System.Text.Encodings中的编码器。来自@gfoidl的PRs dotnet/corefx#42073和dotnet/runtime#284改进了各类TextEncoder类型。这包括使用SSSE3指令向量化FindFirstCharacterToEncodeUtf8以及JavaScriptEncoder中的FindFirstCharToEncode。默认实现。
private char[] _dest = new char[1000]; [Benchmark] public void Encode() => JavaScriptEncoder.Default.Encode("This is a test to see how fast we can encode something that does not actually need encoding", _dest, out _, out _);
一种很是特殊但很是常见的解析形式是经过正则表达式。早在4月初,我就分享了一篇关于。net 5中System.Text.RegularExpressions大量性能改进的详细博客文章。我不打算在这里重复全部这些内容,可是若是你尚未读过,我鼓励你去读它,由于它表明了图书馆的重大进步。然而,我还在那篇文章中指出,咱们将继续改进正则表达式,特别是增长了对特殊但常见状况的更多支持。
其中一个改进是在指定RegexOptions时的换行处理。Multiline,它改变^和$锚点的含义,使其在任何行的开始和结束处匹配,而不只仅是整个输入字符串的开始和结束处。以前咱们没有对起始行锚作任何特殊的处理(当Multiline被指定时^),这意味着做为FindFirstChar操做的一部分(请参阅前面提到的博客文章,了解它指的是什么),咱们不会尽量地跳过它。dotnet/runtime#34566教会FindFirstChar如何使用矢量化的索引向前跳转到下一个相关位置。这一影响在这个基准中获得了强调,它处理从Project Gutenberg下载的“罗密欧与朱丽叶”文本:
private readonly string _input = new HttpClient().GetStringAsync("http://www.gutenberg.org/cache/epub/1112/pg1112.txt").Result; private Regex _regex; [Params(false, true)] public bool Compiled { get; set; } [GlobalSetup] public void Setup() => _regex = new Regex(@"^.*\blove\b.*$", RegexOptions.Multiline | (Compiled ? RegexOptions.Compiled : RegexOptions.None)); [Benchmark] public int Count() => _regex.Matches(_input).Count;
Method | Runtime | Compiled | Mean | Ratio |
---|---|---|---|---|
Count | .NET FW 4.8 | False | 26.207 ms | 1.00 |
Count | .NET Core 3.1 | False | 21.106 ms | 0.80 |
Count | .NET 5.0 | False | 4.065 ms | 0.16 |
Count | .NET FW 4.8 | True | 16.944 ms | 1.00 |
Count | .NET Core 3.1 | True | 15.287 ms | 0.90 |
Count | .NET 5.0 | True | 2.172 ms | 0.13 |
另外一个改进是在处理RegexOptions.IgnoreCase方面。IgnoreCase的实现使用char.ToLower{Invariant}以得到要比较的相关字符,但因为区域性特定的映射,这样作会带来一些开销。dotnet/runtime#35185容许在惟一可能与被比较字符小写的字符是该字符自己时避免这些开销。
private readonly Regex _regex = new Regex("hello.*world", RegexOptions.Compiled | RegexOptions.IgnoreCase); private readonly string _input = "abcdHELLO" + new string('a', 128) + "WORLD123"; [Benchmark] public bool IsMatch() => _regex.IsMatch(_input);
Method | Runtime | Mean | Ratio |
---|---|---|---|
IsMatch | .NET FW 4.8 | 2,558.1 ns | 1.00 |
IsMatch | .NET Core 3.1 | 789.3 ns | 0.31 |
IsMatch | .NET 5.0 | 129.0 ns | 0.05 |
与此相关的改进是dotnet/runtime#35203,它也服务于RegexOptions。IgnoreCase减小了实现对CultureInfo进行的虚拟调用的数量。缓存TextInfo,而不是CultureInfo从它来。
private readonly Regex _regex = new Regex("Hello, \\w+.", RegexOptions.Compiled | RegexOptions.IgnoreCase); private readonly string _input = "This is a test to see how well this does. Hello, world."; [Benchmark] public bool IsMatch() => _regex.IsMatch(_input);
Method | Runtime | Mean | Ratio |
---|---|---|---|
IsMatch | .NET FW 4.8 | 712.9 ns | 1.00 |
IsMatch | .NET Core 3.1 | 343.5 ns | 0.48 |
IsMatch | .NET 5.0 | 100.9 ns | 0.14 |
最近我最喜欢的优化之一是dotnet/runtime#35824(随后在dotnet/runtime#35936中进一步加强)。regex的认可变化,从一个原子环(一个明确的书面或更常见的一个原子的升级到自动的分析表达式),咱们能够更新扫描循环中的下一个起始位置(再一次,详见博客)基于循环的结束,而不是开始。对于许多输入,这能够大大减小开销。使用基准测试和来自https://github.com/mariomka/regex benchmark的数据:
private Regex _email = new Regex(@"[\w\.+-]+@[\w\.-]+\.[\w\.-]+", RegexOptions.Compiled); private Regex _uri = new Regex(@"[\w]+://[^/\s?#]+[^\s?#]+(?:\?[^\s#]*)?(?:#[^\s]*)?", RegexOptions.Compiled); private Regex _ip = new Regex(@"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9])", RegexOptions.Compiled); private string _input = new HttpClient().GetStringAsync("https://raw.githubusercontent.com/mariomka/regex-benchmark/652d55810691ad88e1c2292a2646d301d3928903/input-text.txt").Result; [Benchmark] public int Email() => _email.Matches(_input).Count; [Benchmark] public int Uri() => _uri.Matches(_input).Count; [Benchmark] public int IP() => _ip.Matches(_input).Count;
Method | Runtime | Mean | Ratio |
---|---|---|---|
.NET FW 4.8 | 1,036.729 ms | 1.00 | |
.NET Core 3.1 | 930.238 ms | 0.90 | |
.NET 5.0 | 50.911 ms | 0.05 | |
Uri | .NET FW 4.8 | 870.114 ms | 1.00 |
Uri | .NET Core 3.1 | 759.079 ms | 0.87 |
Uri | .NET 5.0 | 50.022 ms | 0.06 |
IP | .NET FW 4.8 | 75.718 ms | 1.00 |
IP | .NET Core 3.1 | 61.818 ms | 0.82 |
IP | .NET 5.0 | 6.837 ms | 0.09 |
最后,并非全部的焦点都集中在实际执行正则表达式的原始吞吐量上。开发人员使用Regex得到最佳吞吐量的方法之一是指定RegexOptions。编译,它使用反射发射在运行时生成IL,反过来须要JIT编译。根据所使用的表达式,Regex可能会输出大量IL,而后须要大量的JIT处理才能生成汇编代码。dotnet/runtime#35352改进了JIT自己来帮助解决这种状况,修复了regex生成的IL触发的一些可能的二次执行时代码路径。而dotnet/runtime#35321对Regex引擎使用的IL操做进行了调整,使其使用的模式更接近于c#编译器发出的模式,这一点很重要,由于JIT对这些模式进行了更多的优化。在一些具备数百个复杂正则表达式的实际工做负载上,将它们组合起来能够将JIT表达式所花的时间减小20%以上。
net 5中关于异步的最大变化之一其实是默认不启用的,但这是另外一个得到反馈的实验。net 5中的异步ValueTask池博客更详细地解释,但本质上dotnet/coreclr#26310介绍了异步ValueTask能力和异步ValueTask
[Benchmark] public async Task ValueTaskCost() { for (int i = 0; i < 1_000; i++) await YieldOnce(); } private static async ValueTask YieldOnce() => await Task.Yield();
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
ValueTaskCost | .NET FW 4.8 | 1,635.6 us | 1.00 | 294010 B |
ValueTaskCost | .NET Core 3.1 | 842.7 us | 0.51 | 120184 B |
ValueTaskCost | .NET 5.0 | 812.3 us | 0.50 | 186 B |
c#编译器中的一些变化为.NET 5中的异步方法带来了额外的好处(在 .NET5中的核心库是用更新的编译器编译的)。每一个异步方法都有一个负责生成和完成返回任务的“生成器”,而c#编译器将生成代码做为异步方法的一部分来使用。避免做为代码的一部分生成结构副本,这能够帮助减小开销,特别是对于async ValueTask方法,其中构建器相对较大(并随着T的增加而增加)。一样来自@benaadams的dotnet/roslyn#45262也调整了相同的生成代码,以更好地发挥前面讨论的JIT的零改进。
在特定的api中也有一些改进。dotnet/runtime#35575诞生于一些特定的任务使用Task.ContinueWith,其中延续纯粹用于记录“先行”任务continue from中的异常。一般状况下,任务不会出错,而PR在这种状况下会作得更好。
const int Iters = 1_000_000; private AsyncTaskMethodBuilder[] tasks = new AsyncTaskMethodBuilder[Iters]; [IterationSetup] public void Setup() { Array.Clear(tasks, 0, tasks.Length); for (int i = 0; i < tasks.Length; i++) _ = tasks[i].Task; } [Benchmark(OperationsPerInvoke = Iters)] public void Cancel() { for (int i = 0; i < tasks.Length; i++) { tasks[i].Task.ContinueWith(_ => { }, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); tasks[i].SetResult(); } }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
Cancel | .NET FW 4.8 | 239.2 ns | 1.00 | 193 B |
Cancel | .NET Core 3.1 | 140.3 ns | 0.59 | 192 B |
Cancel | .NET 5.0 | 106.4 ns | 0.44 | 112 B |
也有一些调整,以帮助特定的架构。因为x86/x64架构采用了强内存模型,当针对x86/x64时,volatile在JIT时基本上就消失了。ARM/ARM64的状况不是这样,它的内存模型较弱,而且volatile会致使JIT发出围栏。dotnet/runtime#36697删除了每一个排队到线程池的工做项的几个volatile访问,使ARM上的线程池更快。dotnet/runtime#34225将ConcurrentDictionary中的volatile访问从一个循环中抛出,这反过来提升了ARM上ConcurrentDictionary的一些成员的吞吐量高达30%。而dotnet/runtime#36976则彻底从另外一个ConcurrentDictionary字段中删除了volatile。
多年来,c#已经得到了大量有价值的特性。这些特性中的许多都是为了让开发人员可以更简洁地编写代码,而语言/编译器负责全部样板文件,好比c# 9中的记录。然而,有一些特性更注重性能而不是生产力,这些特性对核心库来讲是一个巨大的恩惠,它们能够常用它们来提升每一个人的程序的效率。来自@benaadams的dotnet/runtime#27195就是一个很好的例子。PR改进了Dictionary<TKey, TValue>,利用了c# 7中引入的ref返回和ref局部变量。>的实现是由字典中的数组条目支持的,字典有一个核心例程用于在其条目数组中查找键的索引;而后在多个函数中使用该例程,如indexer、TryGetValue、ContainsKey等。可是,这种共享是有代价的:经过返回索引并将其留给调用者根据须要从槽中获取数据,调用者将须要从新索引到数组中,从而致使第二次边界检查。有了ref返回,共享例程就能够把一个ref递回给槽,而不是原始索引,这样调用者就能够避免第二次边界检查,同时也避免复制整个条目。PR还包括对生成的程序集进行一些低级调优、从新组织字段和用于更新这些字段的操做,以便JIT可以更好地调优生成的程序集。
字典<TKey,TValue>的性能进一步提升了几个PRs。像许多哈希表同样,Dictionary<TKey,TValue>被划分为“bucket”,每一个bucket本质上是一个条目链表(存储在数组中,而不是每一个项都有单独的节点对象)。对于给定的键,一个哈希函数(TKey ' s GetHashCode或提供的IComparer ' s GetHashCode)用于计算提供的键的哈希码,而后该哈希码肯定地映射到一个bucket;找到bucket以后,实现将遍历该bucket中的条目链,查找目标键。该实现试图保持每一个bucket中的条目数较小,并在必要时进行增加和从新平衡以维护该条件。所以,查找的很大一部分开销是计算hashcode到bucket的映射。为了帮助在bucket之间保持良好的分布,特别是当提供的TKey或比较器使用不太理想的哈希代码生成器时,字典使用质数的bucket,而bucket映射由hashcode % numBuckets完成。可是在这里重要的速度,%操做符采用的除法是相对昂贵的。基于Daniel Lemire的工做,dotnet/coreclr#27299(来自@benaadams)和dotnet/runtime#406改变了64位进程中%的使用,而不是使用一对乘法和移位来实现相同的结果,但更快。
private Dictionary<int, int> _dictionary = Enumerable.Range(0, 10_000).ToDictionary(i => i); [Benchmark] public int Sum() { Dictionary<int, int> dictionary = _dictionary; int sum = 0; for (int i = 0; i < 10_000; i++) if (dictionary.TryGetValue(i, out int value)) sum += value; return sum; }
Method | Runtime | Mean | Ratio |
---|---|---|---|
Sum | .NET FW 4.8 | 77.45 us | 1.00 |
Sum | .NET Core 3.1 | 67.35 us | 0.87 |
Sum | .NET 5.0 | 44.10 us | 0.57 |
HashSet很是相似于Dictionary<TKey, TValue>。虽然它公开了一组不一样的操做(没有双关的意思),除了只存储一个键而不是一个键和一个值以外,它的数据结构基本上是相同的……或者至少过去是同样的。多年来,考虑到使用Dictionary<TKey,TValue>比HashSet多多少,咱们花费了更多的努力来优化Dictionary<TKey,TValue>的实现,这两种实现已经漂移了。dotnet/corefx#40106 @JeffreyZhao移植的一些改进词典散列集,而后dotnet/runtime#37180有效地改写HashSet
private HashSet<int> _set = Enumerable.Range(0, 10_000).ToHashSet(); [Benchmark] public int Sum() { HashSet<int> set = _set; int sum = 0; for (int i = 0; i < 10_000; i++) if (set.Contains(i)) sum += i; return sum; }
Method | Runtime | Mean | Ratio |
---|---|---|---|
Sum | .NET FW 4.8 | 76.29 us | 1.00 |
Sum | .NET Core 3.1 | 79.23 us | 1.04 |
Sum | .NET 5.0 | 42.63 us | 0.56 |
相似地,dotnet/runtime#37081移植了相似的改进,从Dictionary<TKey, TValue>到ConcurrentDictionary<TKey, TValue>。
private ConcurrentDictionary<int, int> _dictionary = new ConcurrentDictionary<int, int>(Enumerable.Range(0, 10_000).Select(i => new KeyValuePair<int, int>(i, i))); [Benchmark] public int Sum() { ConcurrentDictionary<int, int> dictionary = _dictionary; int sum = 0; for (int i = 0; i < 10_000; i++) if (dictionary.TryGetValue(i, out int value)) sum += value; return sum; }
Method | Runtime | Mean | Ratio |
---|---|---|---|
Sum | .NET FW 4.8 | 115.25 us | 1.00 |
Sum | .NET Core 3.1 | 84.30 us | 0.73 |
Sum | .NET 5.0 | 49.52 us | 0.43 |
System.Collections。不可变的版本也有改进。dotnet/runtime#1183是@hnrqbaggio经过添加[MethodImpl(methodimploptions.ancsiveinlining)]到ImmutableArray的GetEnumerator方法来提升对ImmutableArray的GetEnumerator方法的foreach性能。咱们一般很是谨慎洒AggressiveInlining:它能够使微基准测试看起来很好,由于它最终消除调用相关方法的开销,但它也能够大大提升代码的大小,而后一大堆事情产生负面影响,如致使指令缓存变得不那么有效了。然而,在这种状况下,它不只提升了吞吐量,并且实际上还减小了代码的大小。内联是一种强大的优化,不只由于它消除了调用的开销,还由于它向调用者公开了被调用者的内容。JIT一般不作过程间分析,这是因为JIT用于优化的时间预算有限,可是内联经过合并调用者和被调用者克服了这一点,在这一点上调用者因素的JIT优化被调用者因素。假设一个方法public static int GetValue() => 42;调用者执行if (GetValue() * 2 > 100){…不少代码…}。若是GetValue()没有内联,那么比较和“大量代码”将会被JIT处理,可是若是GetValue()内联,JIT将会看到这就像(84 > 100){…不少代码…},则整个块将被删除。幸运的是,这样一个简单的方法几乎老是会自动内联,可是ImmutableArray的GetEnumerator足够大,JIT没法自动识别它的好处。在实践中,当内联GetEnumerator时,JIT最终可以更好地识别出foreach在遍历数组,而不是为Sum生成代码:
; Program.Sum() push rsi sub rsp,30 xor eax,eax mov [rsp+20],rax mov [rsp+28],rax xor esi,esi cmp [rcx],ecx add rcx,8 lea rdx,[rsp+20] call System.Collections.Immutable.ImmutableArray'1[[System.Int32, System.Private.CoreLib]].GetEnumerator() jmp short M00_L01 M00_L00: cmp [rsp+28],edx jae short M00_L02 mov rax,[rsp+20] mov edx,[rsp+28] movsxd rdx,edx mov eax,[rax+rdx*4+10] add esi,eax M00_L01: mov eax,[rsp+28] inc eax mov [rsp+28],eax mov rdx,[rsp+20] mov edx,[rdx+8] cmp edx,eax jg short M00_L00 mov eax,esi add rsp,30 pop rsi ret M00_L02: call CORINFO_HELP_RNGCHKFAIL int 3 ; Total bytes of code 97
就像在.NET Core 3.1中同样,在.NET 5中也是如此
; Program.Sum() sub rsp,28 xor eax,eax add rcx,8 mov rdx,[rcx] mov ecx,[rdx+8] mov r8d,0FFFFFFFF jmp short M00_L01 M00_L00: cmp r8d,ecx jae short M00_L02 movsxd r9,r8d mov r9d,[rdx+r9*4+10] add eax,r9d M00_L01: inc r8d cmp ecx,r8d jg short M00_L00 add rsp,28 ret M00_L02: call CORINFO_HELP_RNGCHKFAIL int 3 ; Total bytes of code 59
所以,更小的代码和更快的执行:
private ImmutableArray<int> _array = ImmutableArray.Create(Enumerable.Range(0, 100_000).ToArray()); [Benchmark] public int Sum() { int sum = 0; foreach (int i in _array) sum += i; return sum; }
Method | Runtime | Mean | Ratio |
---|---|---|---|
Sum | .NET FW 4.8 | 187.60 us | 1.00 |
Sum | .NET Core 3.1 | 187.32 us | 1.00 |
Sum | .NET 5.0 | 46.59 us | 0.25 |
ImmutableList
private ImmutableList<int> _list = ImmutableList.Create(Enumerable.Range(0, 1_000).ToArray()); [Benchmark] public int Sum() { int sum = 0; for (int i = 0; i < 1_000; i++) if (_list.Contains(i)) sum += i; return sum; }
Method | Runtime | Mean | Ratio |
---|---|---|---|
Sum | .NET FW 4.8 | 22.259 ms | 1.00 |
Sum | .NET Core 3.1 | 22.872 ms | 1.03 |
Sum | .NET 5.0 | 2.066 ms | 0.09 |
前面强调的集合改进都是针对通用集合的,即用于开发人员须要存储的任何数据。但并非全部的集合类型都是这样的:有些更专门用于特定的数据类型,而这样的集合在。net 5中也能够看到性能的改进。位数组就是这样的一个例子,与几个PRs这个释放做出重大改进,以其性能。特别地,来自@Gnbrkm41的dotnet/corefx#41896使用了AVX2和SSE2特性来对BitArray的许多操做进行矢量化(dotnet/runtime#33749随后也添加了ARM64特性):
private bool[] _array; [GlobalSetup] public void Setup() { var r = new Random(42); _array = Enumerable.Range(0, 1000).Select(_ => r.Next(0, 2) == 0).ToArray(); } [Benchmark] public BitArray Create() => new BitArray(_array);
Method | Runtime | Mean | Ratio |
---|---|---|---|
Create | .NET FW 4.8 | 1,140.91 ns | 1.00 |
Create | .NET Core 3.1 | 861.97 ns | 0.76 |
Create | .NET 5.0 | 49.08 ns | 0.04 |
在.NET Core以前的版本中,系统出现了大量的变更。Linq代码基,特别是提升性能。这个流程已经放缓了,可是.NET 5仍然能够看到LINQ的性能改进。
OrderBy有一个值得注意的改进。正如前面所讨论的,将coreclr的本地排序实现转换为托管代码有多种动机,其中一个就是可以轻松地将其做为基于spanc的排序方法的一部分进行重用。这样的api是公开的,而且经过dotnet/runtime#1888,咱们可以在System.Linq中利用基于spane的排序。这特别有好处,由于它支持利用基于Comparison的排序例程,这反过来又支持避免在每一个比较操做上的多层间接。
[GlobalSetup] public void Setup() { var r = new Random(42); _array = Enumerable.Range(0, 1_000).Select(_ => r.Next()).ToArray(); } private int[] _array; [Benchmark] public void Sort() { foreach (int i in _array.OrderBy(i => i)) { } }
Method | Runtime | Mean | Ratio |
---|---|---|---|
Sort | .NET FW 4.8 | 100.78 us | 1.00 |
Sort | .NET Core 3.1 | 101.03 us | 1.00 |
Sort | .NET 5.0 | 85.46 us | 0.85 |
对于一行更改来讲,这还不错。
另外一个改进是来自@timandy的dotnet/corefx#41342。PR可扩充的枚举。SkipLast到特殊状况IList以及内部IPartition接口(这是各类操做符相互之间进行优化的方式),以便在能够廉价肯定源长度时将SkipLast从新表示为Take操做。
private IEnumerable<int> data = Enumerable.Range(0, 100).ToList(); [Benchmark] public int SkipLast() => data.SkipLast(5).Sum();
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
SkipLast | .NET Core 3.1 | 1,641.0 ns | 1.00 | 248 B |
SkipLast | .NET 5.0 | 684.8 ns | 0.42 | 48 B |
最后一个例子,dotnet/corefx#40377是一个漫长的过程。这是一个有趣的例子。一段时间以来,我看到开发人员认为Enumerable.Any()比Enumerable.Count() != 0更有效;毕竟,Any()只须要肯定源中是否有东西,而Count()须要肯定源中有多少东西。所以,对于任何合理的集合,any()在最坏状况下应该是O(1),而Count()在最坏状况下多是O(N),那么any()不是老是更好的吗?甚至有Roslyn分析程序推荐这种转换。不幸的是,状况并不老是这样。在。net 5以前,Any()的实现基本以下:
using (IEnumerator<T> e = source.GetEnumerator) return e.MoveNext();
这意味着在一般状况下,即便多是O(1)操做,也会致使分配一个枚举器对象以及两个接口分派。相比之下,自从. net Framework 3.0中LINQ的初始版本发布以来,Count()已经优化了特殊状况下ICollection使用它的Count属性的代码路径,在这种状况下,它一般是O(1)和分配自由,只有一个接口分派。所以,对于很是常见的状况(好比源是List),使用Count() != 0实际上比使用Any()更有效。虽然添加接口检查会带来一些开销,但值得添加它以使Any()实现具备可预测性并与Count()保持一致,这样就能够更容易地对其进行推理,并使有关其成本的主流观点变得正确。
现在,网络是几乎全部应用程序的关键组件,而良好的网络性能相当重要。所以,.NET的每个版本都在提升网络性能上投入了大量的精力.NET 5也不例外。
让咱们先看看一些原语,而后继续往下看。系统。大多数应用程序都使用Uri来表示url,它的速度要快,这一点很重要。许多PRs已经开始在。.NET 5中使Uri更快。能够说,Uri最重要的操做是构造一个Uri,而dotnet/runtime#36915使全部Uri的构造速度更快,主要是经过关注开销和避免没必要要的开销:
[Benchmark] public Uri Ctor() => new Uri("https://github.com/dotnet/runtime/pull/36915");
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
Ctor | .NET FW 4.8 | 443.2 ns | 1.00 | 225 B |
Ctor | .NET Core 3.1 | 192.3 ns | 0.43 | 72 B |
Ctor | .NET 5.0 | 129.9 ns | 0.29 | 56 B |
在构造以后,应用程序常常访问Uri的各类组件,这一点也获得了改进。特别是,像HttpClient这样的类型一般有一个重复用于发出请求的Uri。HttpClient实现将访问Uri。属性的路径和查询,以发送做为HTTP请求的一部分(例如,GET /dotnet/runtime HTTP/1.1),在过去,这意味着为每一个请求从新建立Uri的部分字符串。感谢dotnet/runtime#36460,它如今被缓存(就像IdnHost同样):
private Uri _uri = new Uri("http://github.com/dotnet/runtime"); [Benchmark] public string PathAndQuery() => _uri.PathAndQuery;
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
PathAndQuery | .NET FW 4.8 | 17.936 ns | 1.00 | 56 B |
PathAndQuery | .NET Core 3.1 | 30.891 ns | 1.72 | 56 B |
PathAndQuery | .NET 5.0 | 2.854 ns | 0.16 | – |
除此以外,还有许多代码与uri交互的方式,其中许多都获得了改进。例如,dotnet/corefx#41772改进了Uri。EscapeDataString和Uri。EscapeUriString,它根据RFC 3986和RFC 3987对字符串进行转义。这两种方法都依赖于使用不安全代码的共享 helpers,经过char[]来回切换,而且在Unicode处理方面有不少复杂性。这个PR重写了这个 helpers来利用.NET的新特性,好比span和符文,以使escape操做既安全又快速。对于某些输入,增益不大,可是对于涉及Unicode的输入,甚至对于长ASCII输入,增益就很大了。
[Params(false, true)] public bool ASCII { get; set; } [GlobalSetup] public void Setup() { _input = ASCII ? new string('s', 20_000) : string.Concat(Enumerable.Repeat("\xD83D\xDE00", 10_000)); } private string _input; [Benchmark] public string Escape() => Uri.EscapeDataString(_input);
Method | Runtime | ASCII | Mean | Ratio | Allocated |
---|---|---|---|---|---|
Escape | .NET FW 4.8 | False | 6,162.59 us | 1.00 | 60616272 B |
Escape | .NET Core 3.1 | False | 6,483.85 us | 1.06 | 60612025 B |
Escape | .NET 5.0 | False | 243.09 us | 0.04 | 240045 B |
Escape | .NET FW 4.8 | True | 86.93 us | 1.00 | – |
Escape | .NET Core 3.1 | True | 122.06 us | 1.40 | – |
Escape | .NET 5.0 | True | 14.04 us | 0.16 | – |
为Uri.UnescapeDataString提供了相应的改进。这一改变包括使用已经向量化的IndexOf而不是手动的基于指针的循环,以肯定须要进行非转义的字符的第一个位置,而后避免一些没必要要的代码,并在可行的状况下使用堆栈分配而不是堆分配。虽然使全部操做更快,最大的收益是字符串unescape无关,这意味着EscapeDataString操做没有逃避,只是返回其输入(这种状况也随后帮助进一步dotnet/corefx#41684,使原来的字符串返回时不须要改变):
private string _value = string.Concat(Enumerable.Repeat("abcdefghijklmnopqrstuvwxyz", 20)); [Benchmark] public string Unescape() => Uri.UnescapeDataString(_value);
Method | Runtime | Mean | Ratio |
---|---|---|---|
Unescape | .NET FW 4.8 | 847.44 ns | 1.00 |
Unescape | .NET Core 3.1 | 846.84 ns | 1.00 |
Unescape | .NET 5.0 | 21.84 ns | 0.03 |
dotnet/runtime#36444和dotnet/runtime#32713使比较uri和执行相关操做(好比将它们放入字典)变得更快,尤为是相对uri。
private Uri[] _uris = Enumerable.Range(0, 1000).Select(i => new Uri($"/some/relative/path?ID={i}", UriKind.Relative)).ToArray(); [Benchmark] public int Sum() { int sum = 0; foreach (Uri uri in _uris) sum += uri.GetHashCode(); return sum; }
Method | Runtime | Mean | Ratio |
---|---|---|---|
Sum | .NET FW 4.8 | 330.25 us | 1.00 |
Sum | .NET Core 3.1 | 47.64 us | 0.14 |
Sum | .NET 5.0 | 18.87 us | 0.06 |
向上移动堆栈,让咱们看看System.Net.Sockets。自从.NET Core诞生以来,TechEmpower基准就被用做衡量进展的一种方式。之前咱们主要关注“明文”基准,很是低级的一组特定的性能特征,但对于这个版本,咱们但愿专一于改善两个基准,“JSON序列化”和“财富”(后者涉及数据库访问,尽管它的名字,前者的成本主要是因为网络速度很是小的JSON载荷有关)。咱们的工做主要集中在Linux上。当我说“咱们的”时,我不只仅是指那些在.NET团队工做的人;咱们经过一个超越核心团队的工做小组进行了富有成效的合做,例如红帽的@tmds和Illyriad Games的@benaadams的伟大想法和贡献。
在Linux上,socket实现是基于epoll的。为了实现对许多服务的巨大需求,咱们不能仅仅为每一个套接字分配一个线程,若是对套接字上的全部操做都使用阻塞I/O,咱们就会这样作。相反,使用非阻塞I/O,当操做系统尚未准备好来知足一个请求(例如当ReadAsync用于套接字但没有数据可供阅读,或使用非同步套接字可是没有可用空间在内核的发送缓冲区),epoll用于通知套接字实现的套接字状态的变化,这样操做能够再次尝试。epoll是一种使用一个线程有效地阻塞任何数量套接字的更改等待的方法,所以实现维护了一个专用的线程,等待更改的全部套接字注册的epoll。该实现维护了多个epoll线程,这些线程的数量一般等于系统中内核数量的一半。当多个套接字都复用到同一个epoll和epoll线程时,实现须要很是当心,不要在响应套接字通知时运行任意的工做;这样作会发生在epoll线程自己,所以epoll线程将没法处理进一步的通知,直到该工做完成。更糟糕的是,若是该工做被阻塞,等待与同一epoll关联的任何套接字上的另外一个通知,系统将死锁。所以,处理epoll的线程试图在响应套接字通知时作尽量少的工做,提取足够的信息将实际处理排队到线程池中。
事实证实,在这些epoll线程和线程池之间发生了一个有趣的反馈循环。来自epoll线程的工做项排队的开销恰好足够支持多个epoll线程,可是多个epoll线程会致使队列发生一些争用,以致于每一个额外的线程所增长的开销都超过了它的公平份额。最重要的是,排队的速度只是足够低,线程池将很难保持它的全部线程饱和的状况下会发生少许的工做在一个套接字操做(这是JSON序列化基准的状况);这将反过来致使线程池花费更多的时间来隔离和释放线程,从而使其变慢,从而建立一个反馈循环。长话短说,不理想的排队会致使较慢的处理速度和比实际须要更多的epoll线程。这被纠正与两个PRs, dotnet/runtime#35330和dotnet/runtime#35800。#35330改变了从epoll线程排队模型,而不是排队一个工做项/事件(当epoll醒来通知,可能会有多个通知全部的套接字注册它,和它将提供全部的通知在一批),它将整个批处理队列的一个工做项。处理它的池线程而后使用一个很是相似于并行的模型。For/ForEach已经工做多年,也就是说,排队的工做项能够为本身保留一个项,而后将本身的副本排队以帮助处理剩余的项。这改变了微积分,最合理大小的机器,它实际上成为有利于减小epoll线程而不是更多(并不是巧合的是,咱们但愿有更少的),那么# 35800 epoll线程的数量变化,一般使用最终只是一个(在机器与更大的核心方面,还会有更多)。咱们还经过经过DOTNET_SYSTEM_NET_SOCKETS_THREAD_COUNT epoll数可配置环境变量,能够设置为所需的计算以覆盖系统的默认值,若是开发人员想要实验与其余数量和提供反馈结果给定的工做负载。
做为一个实验,从@tmds dotnet/runtime#37974咱们还添加了一个实验模式(由DOTNET_SYSTEM_NET_SOCKETS_INLINE_COMPLETIONS环境变量设置为1在Linux上)咱们避免排队的工做线程池,而不是仅仅运行全部套接字延续(如工做()等待socket.ReadAsync ();工做()😉;在epoll线程上。嗝是我德拉古!若是套接字延续中止,则不会处理与该epoll线程关联的其余工做。更糟糕的是,若是延续实际上同步阻塞等待与该epoll关联的其余工做,系统将死锁。可是,在这种模式下,一个精心设计的程序可能会得到更好的性能,由于处理的位置能够更好,而且能够避免排队到线程池的开销。由于全部套接字工做都在epoll线程上运行,因此默认为1再也不有意义;默认状况下,它的线程数等于处理器数。再说一次,这是一个实验,咱们欢迎你看到任何积极或消极的结果。
这些改进都大规模地集中在Linux上的套接字性能上,这使得它们很难在单机上的微基准测试中进行演示。不过,还有其余更容易看到的改进dotnet/runtime#32271从套接字删除了几个分配。链接,插座。为了支持再也不相关的旧代码访问安全(CAS)检查,对某些状态进行了没必要要的复制:CAS检查在好久之前就被删除了,可是克隆仍然存在,因此这也只是清理了它们。dotnet/runtime#32275也从SafeSocketHandle的Windows实现中删除了一个分配。dotnet/runtime#787重构插座。ConnectAsync,以便它能够共享相同的内部SocketAsyncEventArgs实例,该实例最终被随后用于执行ReceiveAsync操做,从而避免额外的链接分配。dotnet /运行时# 34175利用.NET 5中引入的新的固定对象堆使用pre-pinned缓冲区SocketAsyncEventArgs实现的各部分在Windows上而不是用GCHandle销(在Linux上不须要把相应的功能,因此它是不习惯)。在dotnet/runtime#37583中,@tmds经过在适当的地方使用堆栈分配,减小了做为向生I/O SendAsync/ReceivedAsync实现的一部分的分配。
private Socket _listener, _client, _server; private byte[] _buffer = new byte[8]; private List<ArraySegment<byte>> _buffers = new List<ArraySegment<byte>>(); [GlobalSetup] public void Setup() { _listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); _listener.Listen(1); _client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _client.ConnectAsync(_listener.LocalEndPoint).GetAwaiter().GetResult(); _server = _listener.AcceptAsync().GetAwaiter().GetResult(); for (int i = 0; i < _buffer.Length; i++) _buffers.Add(new ArraySegment<byte>(_buffer, i, 1)); } [Benchmark] public async Task SendReceive() { await _client.SendAsync(_buffers, SocketFlags.None); int total = 0; while (total < _buffer.Length) total += await _server.ReceiveAsync(_buffers, SocketFlags.None); }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
SendReceive | .NET Core 3.1 | 5.924 us | 1.00 | 624 B |
SendReceive | .NET 5.0 | 5.230 us | 0.88 | 144 B |
在此之上,咱们来到System.Net.Http。SocketsHttpHandler在两个方面作了大量改进。第一个是头的处理,它表明了与类型相关的分配和处理的很大一部分。经过建立HttpHeaders, dotnet/corefx#41640启动了事情。TryAddWithoutValidation的名称为真:因为SocketsHttpHandler枚举请求头并将它们写入连线的方式,即便开发人员指定了“WithoutValidation”,它最终仍是会对头执行验证,PR修复了这个问题。多个PRs,包括dotnet/runtime#35003, dotnet/runtime#34922, dotnet/runtime#32989和dotnet/runtime#34974改进了在SocketHttpHandler的已知标头列表中的查找(当这些标头出现时,这有助于避免分配),并加强了该列表以更加全面。dotnet/runtime#34902更新内部各强类型集合类型使用头少分配集合,和dotnet/runtime#34724作了一些相关的分配头到手只有当他们实际上访问(以及特殊状况的日期和服务器响应标头以免为他们分配在最多见的状况下)。最终的结果是吞吐量获得了小的改善,但分配获得了显著的改善:
private static readonly Socket s_listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); private static readonly HttpClient s_client = new HttpClient(); private static Uri s_uri; [Benchmark] public async Task HttpGet() { var m = new HttpRequestMessage(HttpMethod.Get, s_uri); m.Headers.TryAddWithoutValidation("Authorization", "ANYTHING SOMEKEY"); m.Headers.TryAddWithoutValidation("Referer", "http://someuri.com"); m.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36"); m.Headers.TryAddWithoutValidation("Host", "www.somehost.com"); using (HttpResponseMessage r = await s_client.SendAsync(m, HttpCompletionOption.ResponseHeadersRead)) using (Stream s = await r.Content.ReadAsStreamAsync()) await s.CopyToAsync(Stream.Null); } [GlobalSetup] public void CreateSocketServer() { s_listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); s_listener.Listen(int.MaxValue); var ep = (IPEndPoint)s_listener.LocalEndPoint; s_uri = new Uri($"http://{ep.Address}:{ep.Port}/"); byte[] response = Encoding.UTF8.GetBytes("HTTP/1.1 200 OK\r\nDate: Sun, 05 Jul 2020 12:00:00 GMT \r\nServer: Example\r\nContent-Length: 5\r\n\r\nHello"); byte[] endSequence = new byte[] { (byte)'\r', (byte)'\n', (byte)'\r', (byte)'\n' }; Task.Run(async () => { while (true) { Socket s = await s_listener.AcceptAsync(); _ = Task.Run(() => { using (var ns = new NetworkStream(s, true)) { byte[] buffer = new byte[1024]; int totalRead = 0; while (true) { int read = ns.Read(buffer, totalRead, buffer.Length - totalRead); if (read == 0) return; totalRead += read; if (buffer.AsSpan(0, totalRead).IndexOf(endSequence) == -1) { if (totalRead == buffer.Length) Array.Resize(ref buffer, buffer.Length * 2); continue; } ns.Write(response, 0, response.Length); totalRead = 0; } } }); } }); }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
HttpGet | .NET FW 4.8 | 123.67 us | 1.00 | 98.48 KB |
HttpGet | .NET Core 3.1 | 68.57 us | 0.55 | 6.07 KB |
HttpGet | .NET 5.0 | 66.80 us | 0.54 | 2.86 KB |
其余一些与主管有关的PRs更为专业化。例如,dotnet/runtime#34860经过更仔细地考虑方法改进了日期头的解析。前面的实现使用的是DateTime。一长串可行格式的TryParseExact;这就使实现失去了它的快速路径,而且致使即便输入与列表中的第一种格式匹配时,解析它的速度也要慢得多。在今天的日期标题中,绝大多数标题将遵循RFC 1123中列出的格式,也就是“r”。因为以前版本的改进,DateTime对“r”格式的解析很是快,因此咱们能够先直接使用TryParseExact对单一格式进行解析,若是它失败了,就使用TryParseExact对其他格式进行解析。
[Benchmark] public DateTimeOffset? DatePreferred() { var m = new HttpResponseMessage(); m.Headers.TryAddWithoutValidation("Date", "Sun, 06 Nov 1994 08:49:37 GMT"); return m.Headers.Date; }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
DatePreferred | .NET FW 4.8 | 2,177.9 ns | 1.00 | 674 B |
DatePreferred | .NET Core 3.1 | 1,510.8 ns | 0.69 | 544 B |
DatePreferred | .NET 5.0 | 267.2 ns | 0.12 | 520 B |
然而,最大的改进来自于通常的HTTP/2。在.NET Core 3.1中,HTTP/2实现是功能性的,但没有进行特别的调优,因此在.NET5上作了一些努力,使HTTP/2实现更好,特别是更具备可伸缩性。dotnet/runtime#32406和dotnet/runtime#32624显著下降分配参与HTTP/2 GET请求经过使用一个自定义CopyToAsync覆盖在响应流用于HTTP/2响应,被更当心在如何访问请求头写请求的一部分(为了不迫使lazily-initialized状态存在的时候没有必要),和删除async-related分配。而dotnet/runtime#32557减小了HTTP/2中的分配,经过更好地处理取消和减小与异步操做相关的分配。之上,dotnet/runtime#35694包括一堆HTTP /两个相关的变化,包括减小锁的数量涉及(HTTP/2涉及更多的同步比HTTP/1.1 c#实现,由于在HTTP / 2多个请求多路复用到相同的套接字链接),减小工做的数量,而持有锁,一个关键的状况下改变使用的锁定机制,增长标题的标题优化,以及其余一些减小管理费用的调整。做为后续,dotnet/runtime#36246删除了一些因为取消和尾部标头(这在gRPC流量中很常见)而形成的分配。为了演示这一点,我建立了一个简单的ASP.NET Core localhost服务器(使用空模板,删除少许代码,本例不须要):
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; public class Program { public static void Main(string[] args) => Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(b => b.UseStartup<Startup>()).Build().Run(); } public class Startup { public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.MapGet("/", context => context.Response.WriteAsync("Hello")); endpoints.MapPost("/", context => context.Response.WriteAsync("Hello")); }); } }
而后我使用这个客户端基准:
private HttpMessageInvoker _client = new HttpMessageInvoker(new SocketsHttpHandler() { UseCookies = false, UseProxy = false, AllowAutoRedirect = false }); private HttpRequestMessage _get = new HttpRequestMessage(HttpMethod.Get, new Uri("https://localhost:5001/")) { Version = HttpVersion.Version20 }; private HttpRequestMessage _post = new HttpRequestMessage(HttpMethod.Post, new Uri("https://localhost:5001/")) { Version = HttpVersion.Version20, Content = new ByteArrayContent(Encoding.UTF8.GetBytes("Hello")) }; [Benchmark] public Task Get() => MakeRequest(_get); [Benchmark] public Task Post() => MakeRequest(_post); private Task MakeRequest(HttpRequestMessage request) => Task.WhenAll(Enumerable.Range(0, 100).Select(async _ => { for (int i = 0; i < 500; i++) { using (HttpResponseMessage r = await _client.SendAsync(request, default)) using (Stream s = await r.Content.ReadAsStreamAsync()) await s.CopyToAsync(Stream.Null); } }));
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
Get | .NET Core 3.1 | 1,267.4 ms | 1.00 | 122.76 MB |
Get | .NET 5.0 | 681.7 ms | 0.54 | 74.01 MB |
Post | .NET Core 3.1 | 1,464.7 ms | 1.00 | 280.51 MB |
Post | .NET 5.0 | 735.6 ms | 0.50 | 132.52 MB |
还要注意的是,对于.NET 5,在这方面还有不少工做要作。dotnet/runtime#38774改变了在HTTP/2实现中处理写的方式,预计将在已有改进的基础上带来实质性的可伸缩性提升,特别是针对基于grpc的工做负载。
其余网络组件也有显著的改进。例如,Dns类型上的XxAsync api是在相应的Begin/EndXx方法上实现的。对于.NET 5中的dotnet/corefx#41061,这是反向的,例如Begin/EndXx方法是在XxAsync方法的基础上实现的;这使得代码更简单、更快,同时对分配也有很好的影响(注意.NET Framework 4.8的结果稍微快一些,由于它实际上没有使用异步I/O,而只是一个排队的工做项到执行同步I/O的线程池;这样会减小一些开销,但也会减小可伸缩性):
private string _hostname = Dns.GetHostName(); [Benchmark] public Task<IPAddress[]> Lookup() => Dns.GetHostAddressesAsync(_hostname);
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
Lookup | .NET FW 4.8 | 178.6 us | 1.00 | 4146 B |
Lookup | .NET Core 3.1 | 211.5 us | 1.18 | 1664 B |
Lookup | .NET 5.0 | 209.7 us | 1.17 | 984 B |
虽然是一种不多有人(尽管它使用WCF), NegotiateStream也一样更新dotnet/runtime#36583,与全部XxAsync方法被使用异步/等待,而后在dotnet/runtime#37772复用缓冲区,而不是为每一个操做建立新的。最终结果是在典型的读/写使用中显著减小分配:
private byte[] _buffer = new byte[1]; private NegotiateStream _client, _server; [GlobalSetup] public void Setup() { using var listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); listener.Listen(1); var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); client.ConnectAsync(listener.LocalEndPoint).GetAwaiter().GetResult(); Socket server = listener.AcceptAsync().GetAwaiter().GetResult(); _client = new NegotiateStream(new NetworkStream(client, true)); _server = new NegotiateStream(new NetworkStream(server, true)); Task.WaitAll( _client.AuthenticateAsClientAsync(), _server.AuthenticateAsServerAsync()); } [Benchmark] public async Task WriteRead() { for (int i = 0; i < 100; i++) { await _client.WriteAsync(_buffer); await _server.ReadAsync(_buffer); } } [Benchmark] public async Task ReadWrite() { for (int i = 0; i < 100; i++) { var r = _server.ReadAsync(_buffer); await _client.WriteAsync(_buffer); await r; } }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
WriteRead | .NET Core 3.1 | 1.510 ms | 1.00 | 61600 B |
WriteRead | .NET 5.0 | 1.294 ms | 0.86 | – |
ReadWrite | .NET Core 3.1 | 3.502 ms | 1.00 | 76224 B |
ReadWrite | .NET 5.0 | 3.301 ms | 0.94 | 226 B |
这个系统有了显著的改进.NET 5的Json库,特别是JsonSerializer,可是不少这些改进实际上都被移植回了.NET Core 3.1,并做为服务修复的一部分发布(参见dotnet/corefx#41771)。即使如此,在.NET 5中也出现了一些不错的改进。
dotnet/runtime#2259重构了JsonSerializer中的转换器如何处理集合的模型,致使了可测量的改进,特别是对于更大的集合:
private MemoryStream _stream = new MemoryStream(); private DateTime[] _array = Enumerable.Range(0, 1000).Select(_ => DateTime.UtcNow).ToArray(); [Benchmark] public Task LargeArray() { _stream.Position = 0; return JsonSerializer.SerializeAsync(_stream, _array); }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
LargeArray | .NET FW 4.8 | 262.06 us | 1.00 | 24256 B |
LargeArray | .NET Core 3.1 | 191.34 us | 0.73 | 24184 B |
LargeArray | .NET 5.0 | 69.40 us | 0.26 | 152 B |
但即便是较小的,例如。
private MemoryStream _stream = new MemoryStream(); private JsonSerializerOptions _options = new JsonSerializerOptions(); private Dictionary<string, int> _instance = new Dictionary<string, int>() { { "One", 1 }, { "Two", 2 }, { "Three", 3 }, { "Four", 4 }, { "Five", 5 }, { "Six", 6 }, { "Seven", 7 }, { "Eight", 8 }, { "Nine", 9 }, { "Ten", 10 }, }; [Benchmark] public async Task Dictionary() { _stream.Position = 0; await JsonSerializer.SerializeAsync(_stream, _instance, _options); }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
Dictionary | .NET FW 4.8 | 2,141.7 ns | 1.00 | 209 B |
Dictionary | .NET Core 3.1 | 1,376.6 ns | 0.64 | 208 B |
Dictionary | .NET 5.0 | 726.1 ns | 0.34 | 152 B |
dotnet/runtime#37976还经过添加缓存层来帮助检索被序列化和反序列化的类型内部使用的元数据,从而帮助提升小型类型的性能。
private MemoryStream _stream = new MemoryStream(); private MyAwesomeType _instance = new MyAwesomeType() { SomeString = "Hello", SomeInt = 42, SomeByte = 1, SomeDouble = 1.234 }; [Benchmark] public Task SimpleType() { _stream.Position = 0; return JsonSerializer.SerializeAsync(_stream, _instance); } public struct MyAwesomeType { public string SomeString { get; set; } public int SomeInt { get; set; } public double SomeDouble { get; set; } public byte SomeByte { get; set; } }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
SimpleType | .NET FW 4.8 | 1,204.3 ns | 1.00 | 265 B |
SimpleType | .NET Core 3.1 | 617.2 ns | 0.51 | 192 B |
SimpleType | .NET 5.0 | 504.2 ns | 0.42 | 192 B |
在.NET Core 3.0以前,.NET Core主要关注服务器的工做负载,而ASP则主要关注服务器的工做负载。NET Core是该平台上卓越的应用模型。随着.NET Core 3.0的加入,Windows Forms和Windows Presentation Foundation (WPF)也随之加入,将. NET Core引入到了桌面应用中。随着.NET Core 3.2的发布,Blazor发布了对浏览器应用程序的支持,但它基于mono和mono堆栈中的库。在.NET 5中,Blazor使用.NET 5 mono运行时和全部其余应用模型共享的.NET 5库。这给性能带来了一个重要的变化:大小。在代码大小一直是一个重要的问题(和.NET本机应用程序)是很是重要的,一个成功的基于浏览器的部署所需的规模确实带来了最前沿,咱们须要担忧下载大小在某种程度上咱们尚未过去集中与.NET Core。
协助与应用程序的大小,.NET SDK包含一个连接器,可以清除的未使用部分应用,不只在汇编级,但也在会员级别,作静态分析来肯定什么是代码,不是使用和丢弃的部分不是。这带来了一组有趣的挑战:为了方便或简化API使用而采用的一些编码模式,对于连接器来讲,很难以容许它扔掉不少东西的方式进行分析。所以,在.NET 5中与性能相关的主要工做之一就是改进库的可剪裁。
这有两个方面:
第二种方法有不少例子,因此我将着重介绍其中一些,以展现所使用的各类技术:
在.NET Core 3.0性能后,我讲过“花生酱”,许多小的改进,单独不必定就会有巨大的差异,但处理成本,是整个代码,不然涂抹和修复这些集体能够产生可测量的变化。和之前的版本同样,在.NET 5中也有不少这样受欢迎的改进。这里有少数:
这篇文章强调了在.NET 5上运行的大量现有api会变得更好。此外,.NET 5中有许多新的api,其中一些专一于帮助开发人员编写更快的代码(更多的关注于让开发人员用更少的代码执行相同的操做,或者支持之前不容易完成的新功能)。如下是一些亮点,包括一些api已经被其余库内部使用以下降现有api成本的状况:
Decimal(ReadOnlySpan<int>)
/ Decimal.TryGetBits
/ Decimal.GetBits
(dotnet/runtime#32155):在之前的版本中添加了不少span-based方法有效地与原语交流,decimal并获得span-based TryFormat和{}尝试解析方法,但这些新方法在.NET 5使有效地构建一个十进制从跨度以及提取位decimal跨度。您能够看到,这种支持已经在SQLDecimal、BigInteger和System.Linq和System.Reflection.Metadata
中使用。Unsafe.SkipInit<T>
(dotnet/corefx#41995)。c#编译器明确的赋值规则要求在各类状况下为参数和局部变量赋值。在很是特定的状况下,这可能须要额外的赋值,而不是实际须要的,在计算每条指令和性能敏感代码中的内存写入时,这多是不可取的。该方法有效地使代码伪装已写入参数或本地,而实际上并无这样作。它被用于对Decimal的各类操做(dotnet/runtime#272377),在IntPtr和UIntPtr的一些新的api (dotnet/runtime#307来自@john-h-k),在Matrix4x4 (dotnet/runtime#36323来自@eanova),在Utf8Parser (dotnet/runtime#33507),和在UTF8Encoding (dotnet/runtime#31904)private Task _incomplete = new TaskCompletionSource<bool>().Task; [Benchmark] public Task OneAlreadyCompleted() => Task.WhenAny(Task.CompletedTask, _incomplete); [Benchmark] public Task AsyncCompletion() { AsyncTaskMethodBuilder atmb = default; Task result = Task.WhenAny(atmb.Task, _incomplete); atmb.SetResult(); return result; }
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
OneAlreadyCompleted | .NET FW 4.8 | 125.387 ns | 1.00 | 217 B |
OneAlreadyCompleted | .NET Core 3.1 | 89.040 ns | 0.71 | 200 B |
OneAlreadyCompleted | .NET 5.0 | 8.391 ns | 0.07 | 72 B |
AsyncCompletion | .NET FW 4.8 | 289.042 ns | 1.00 | 257 B |
AsyncCompletion | .NET Core 3.1 | 195.879 ns | 0.68 | 240 B |
AsyncCompletion | .NET 5.0 | 150.523 ns | 0.52 | 160 B |
还有太多System.Runtime.Intrinsics方法甚至开始提到!
c#“Roslyn”编译器有一个很是有用的扩展点,称为“analyzers”或“Roslyn analyzers”。分析器插入到编译器中,并被授予对编译器操做的全部源代码以及编译器对代码的解析和建模的彻底读访问权,这使得开发人员可以将他们本身的自定义分析插入到编译中。最重要的是,分析器不只能够做为构建的一部分运行,并且能够在开发人员编写代码时在IDE中运行,这使得分析器可以就开发人员如何改进代码提供建议、警告和错误。分析器开发人员还能够编写可在IDE中调用的“修复程序”,并将标记的代码自动替换为“修复的”替代品。全部这些组件均可以经过NuGet包分发,这使得开发人员很容易使用其余人编写的任意分析。
Roslyn分析程序回购包含一组定制分析程序,包括旧FxCop规则的端口。它还包含新的分析程序,对于.NET5, .NET SDK将自动包含大量这些分析程序,包括为这个发行版编写的全新分析程序。这些规则中有多个与性能相关,或者至少部分与性能相关。下面是一些例子:
检测意外分配,做为距离索引的一部分。c# 8引入了范围,这使得对集合进行切片变得很容易,例如someCollection[1..3]。这样的表达式能够转换为使用集合的索引器来获取一个范围,例如public MyCollection this[Range r] {get;},或者若是没有这样的索引器,则使用Slice(int start, int length)。根据惯例和设计准则,这样的索引器和切片方法应该返回它们所定义的相同类型,所以,例如,切片一个T[]将产生另外一个T[],而切片一个Span将产生一个Span。可是,这可能会致使隐式强制转换隐藏意外的分配。例如,T[]能够隐式转换为Span,但这也意味着T[]切片的结果能够隐式转换为Span,这意味着以下代码Span Span = _array[1..3];将很好地编译和运行,除了它将致使由_array[1..]产生的数组片的数组分配。3]索引范围。更有效的编写方法是Span Span = _array.AsSpan()[1..3]。这个分析器将检测几个这样的状况,并提供解决方案来消除分配。
[Benchmark(Baseline = true)] public ReadOnlySpan<char> Slice1() { ReadOnlySpan<char> span = "hello world"[1..3]; return span; } [Benchmark] public ReadOnlySpan<char> Slice2() { ReadOnlySpan<char> span = "hello world".AsSpan()[1..3]; return span; }
Method | Mean | Ratio | Allocated |
---|---|---|---|
Slice1 | 8.3337 ns | 1.00 | 32 B |
Slice2 | 0.4332 ns | 0.05 | – |
优先使用流的内存重载。.NET Core 2.1为流添加了新的重载。ReadAsync和流。分别对Memory和ReadOnlyMemory操做的WriteAsync。这使得这些方法能够处理来自其余来源的数据,而不是byte[],而且还能够进行优化,好比当{ReadOnly}内存是按照指定的方式建立的,它表示已经固定的或不可移动的数据时,能够避免进行固定。然而,新重载的引入也为选择这些方法的返回类型提供了新的机会,咱们分别选择了ValueTask和ValueTask,而不是Task和Task。这样作的好处是容许以更同步的方式完成调用来避免分配,甚至以更异步的方式完成调用来避免分配(尽管覆盖的开发人员须要付出更多的努力)。所以,倾向于使用新的重载而不是旧的重载一般是有益的,这个分析器将检测旧重载的使用并提供修复程序来自动切换到使用新重载,dotnet/runtime#35941有一些在发现的修复案例的例子。
private NetworkStream _client, _server; private byte[] _buffer = new byte[10]; [GlobalSetup] public void Setup() { using Socket listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); listener.Listen(); client.Connect(listener.LocalEndPoint); _client = new NetworkStream(client); _server = new NetworkStream(listener.Accept()); } [Benchmark(Baseline = true)] public async Task ReadWrite1() { byte[] buffer = _buffer; for (int i = 0; i < 1000; i++) { await _client.WriteAsync(buffer, 0, buffer.Length); await _server.ReadAsync(buffer, 0, buffer.Length); // may not read everything; just for demo purposes } } [Benchmark] public async Task ReadWrite2() { byte[] buffer = _buffer; for (int i = 0; i < 1000; i++) { await _client.WriteAsync(buffer); await _server.ReadAsync(buffer); // may not read everything; just for demo purposes } }
Method | Mean | Ratio | Allocated |
---|---|---|---|
ReadWrite1 | 7.604 ms | 1.00 | 72001 B |
ReadWrite2 | 7.549 ms | 0.99 | – |
最好在StringBuilder上使用类型重载。附加和StringBuilder.Insert有许多重载,不只用于追加字符串或对象,还用于追加各类基本类型,好比Int32。即使如此,仍是常常会看到像stringBuilder.Append(intValue.ToString())这样的代码。StringBuilder.Append(Int32)重载的效率更高,不须要分配字符串,所以应该首选重载。这个分析仪带有一个fixer来检测这种状况,并自动切换到使用更合适的过载。
[Benchmark(Baseline = true)] public void Append1() { _builder.Clear(); for (int i = 0; i < 1000; i++) _builder.Append(i.ToString()); } [Benchmark] public void Append2() { _builder.Clear(); for (int i = 0; i < 1000; i++) _builder.Append(i); }
Method | Mean | Ratio | Allocated |
---|---|---|---|
Append1 | 13.546 us | 1.00 | 31680 B |
Append2 | 9.841 us | 0.73 | – |
首选StringBuilder.Append(char),而不是StringBuilder.Append(string)。将单个字符附加到StringBuilder比附加长度为1的字符串更有效。可是,像private const string Separator = ":"
这样的代码仍是很常见的。…;若是const被更改成private const char Separator = ':';
会更好。分析器将标记许多这样的状况,并帮助修复它们。在dotnet/runtime中针对分析器修正的一些例子在dotnet/runtime#36097中。
[Benchmark(Baseline = true)] public void Append1() { _builder.Clear(); for (int i = 0; i < 1000; i++) _builder.Append(":"); } [Benchmark] public void Append2() { _builder.Clear(); for (int i = 0; i < 1000; i++) _builder.Append(':'); }
Method | Mean | Ratio |
---|---|---|
Append1 | 2.621 us | 1.00 |
Append2 | 1.968 us | 0.75 |
优先选择IsEmpty而不是Count。 与前面的LINQ Any() vs Count()类似,某些集合类型同时公开了IsEmpty属性和Count属性。 在某些状况下,例如像ConcurrentQueue 这样的并发集合,肯定集合中项目数的准确计数比仅肯定集合中是否有任何项目要昂贵得多。 在这种状况下,若是编写代码来执行相似if(collection.Count!= 0)的检查,则改成使用if(!collection.IsEmpty)会更有效。 该分析仪有助于发现并修复此类状况。
[Benchmark(Baseline = true)] public bool IsEmpty1() => _queue.Count == 0; [Benchmark] public bool IsEmpty2() => _queue.IsEmpty;
Method | Mean | Ratio |
---|---|---|
IsEmpty1 | 21.621 ns | 1.00 |
IsEmpty2 | 4.041 ns | 0.19 |
首选Environment.ProcessId。 dotnet/runtime#38908 添加了新的静态属性Environment.ProcessId,该属性返回当前进程的ID。 看到之前尝试使用Process.GetCurrentProcess()。Id执行相同操做的代码是很常见的。 可是,后者的效率明显较低,它没法轻松地支持内部缓存,所以在每次调用时分配一个可终结对象并进行系统调用。 这款新的分析仪有助于自动查找和替换此类用法。
[Benchmark(Baseline = true)] public int PGCPI() => Process.GetCurrentProcess().Id; [Benchmark] public int EPI() => Environment.ProcessId;
Method | Mean | Ratio | Allocated |
---|---|---|---|
PGCPI | 67.856 ns | 1.00 | 280 B |
EPI | 3.191 ns | 0.05 | – |
避免循环中的stackalloc。这个分析器并不能很大程度上帮助您使代码更快,可是当您使用了使代码更快的解决方案时,它能够帮助您使代码正确。具体来讲,它标记使用stackalloc从堆栈分配内存,但在循环中使用它的状况。从堆栈中分配的内存的一部分stackalloc可能不会被释放,直到方法返回,若是stackalloc是在一个循环中使用,它可能致使比开发人员分配更多的内存,并最终致使堆栈溢出,崩溃的过程。你能够在dotnet/runtime#34149中看到一些修复的例子。
根据.NET路线图,.NET 5计划在2020年11月发布,这离咱们还有几个月的时间。虽然这篇文章展现了大量的性能进步已经释放,我指望咱们将会看到大量的额外性能改进发如今.NET 5,若是没有其余缘由比目前PRs等待一群(除了前面提到的其余讨论),例如dotnet/runtime#34864和dotnet/runtime#32552进一步提升Uri, dotnet/runtime#402 vectorizes string.Compare ,dotnet/runtime#36252改善性能的Dictionary
最后,虽然咱们真的很努力地避免性能退化,可是任何版本都将不可避免地出现一些性能退化,而且咱们将花费时间调查咱们找到的性能退化。这样的回归与一个已知的类特性使得在.NET5: ICU .NET Framework和之前版本的.NET Core 在Windows上使用国家语言支持(NLS) api全球化在Windows上,而net核心在Unix上使用国际Unicode (ICU).NET 5组件切换到使用默认ICU在全部操做系统若是是可用的(Windows 10包括截至2019年5月更新),使更好的行为一致性操做系统。可是,因为这两种技术具备不一样的性能概要,所以某些操做(特别是识别区域性的字符串操做)在某些状况下可能会变得更慢。虽然咱们但愿减小其中的大部分(这也将有助于提升Linux和macOS上的性能),可是若是保留下来的任何更改均可能对您的应用程序可有可无,那么若是这些更改对您的特定应用程序产生了负面影响,您能够选择继续使用NLS。
有了.NET 的预览和每晚的构建版本,我鼓励您下载最新的版本,并在您的应用程序中试用它们。若是你发现你认为能够和应该改进的东西,咱们欢迎你的PRs到dotnet/runtime!
编码快乐!
因为文章较长真的是用了很长时间,中间机翻加纠正了一些地方,不过结局仍是好的最后仍是整理完成。但愿能对你们有帮助,谢谢!
参考:https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-5/