【翻译】.NET 5中的性能改进
在.NET Core之前的版本中,其实已经在博客中介绍了在该版本中发现的重大性能改进。 从.NET Core 2.0到.NET Core 2.1到.NET Core 3.0的每一篇文章,发现
谈论越来越多的东西。 然而有趣的是,每次都想知道下一次是否有足够的意义的改进以保证再发表一篇文章。 .NET 5已经实现了许多性能改进,尽管直到今年秋天才计划发布最终版本,并且到那时很有可能会有更多的改进,但是还要强调一下,现在已提供的改进。 在这篇文章中,重点介绍约250个PR,这些请求为整个.NET 5的性能提升做出了巨大贡献。
安装
Benchmark.NET现在是衡量.NET代码性能的规范工具,可轻松分析代码段的吞吐量和分配。 因此,本文中大部分示例都是使用使用该工具编写的微基准来衡量的。首先创建了一个目录,然后使用dotnet工具对其进行了扩展:
mkdir Benchmarks
cd Benchmarks
dotnet new console
生成的Benchmarks.csproj的内容扩展为如下所示:
<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文件更新到同一文件夹中,如下所示:
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"
的位置。
为了运行基准测试,然后做:
dotnet run -c Release -f net48 --runtimes net48 netcoreapp31 netcoreapp50 --filter ** --join
这告诉Benchmark.NET:
- 使用.NET Framework 4.8 来建立基准。
- 针对.NET Framework 4.8,.NET Core 3.1和.NET 5分别运行基准测试。
- 在程序集中包含所有基准测试(不要过滤掉任何基准测试)。
- 将所有基准测试的输出结果合并在一起,并在运行结束时显示(而不是贯穿整个过程)。
在某些情况下,针对特定目标的API并不存在,我只是省略了命令行的这一部分。
最后,请注意以下几点:
- 从运行时和核心库的角度来看,它与几个月前发布的前身相比没有多少改进。 但是,还进行了一些改进,在某些情况下,目前已经将.NET 5的改进移植回了.NET Core 3.1,在这些改进中,这些更改被认为具有足够的影响力,可以保证可以添加到长期支持中(LTS)版本。 因此,我在这里所做的所有比较都是针对最新的.NET Core 3.1服务版本(3.1.5),而不是针对.NET Core 3.0。
- 由于比较是关于.NET 5与.NET Core 3.1的,而且.NET Core 3.1不包括mono运行时,因此不讨论对mono所做的改进,也没有专门针对“Blazor”。 因此,当指的是“runtime”时,指的是coreclr,即使从.NET 5开始,它也包含多个运行时,并且所有这些都已得到改进。
- 大多数示例都在Windows上运行,因为也希望能够与.NET Framework 4.8进行比较。 但是,除非另有说明,否则所有显示的示例均适用于Windows,Linux和macOS。
- 需要注意的是: 这里的所有测量数据都是在的台式机上进行的,测量结果可能会有所不同。微基准测试对许多因素都非常敏感,包括处理器数量、处理器架构、内存和缓存速度等等。但是,一般来说,我关注的是性能改进,并包含了通常能够承受此类差异的示例。
让我们开始吧…
GC
对于所有对.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[]数组进行排序:
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暂停。 。暂停。
JIT
.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 ,它优化了双重否定。
Intrinsics
在.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。
Runtime helpers
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 |
Text Processing
基于文本的处理是许多应用程序的基础,并且在每个版本中都花费了大量的精力来改进基础构建块,其他所有内容都构建在这些基础构建块之上。这些变化从 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 _);
Regular Expressions
一种非常特殊但非常常见的解析形式是通过正则表达式。早在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(@"^.*bloveb.*$", 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%以上。
Threading and Async
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。
Collections
多年来,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 |
LINQ
在.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()保持一致,这样就可以更容易地对其进行推理,并使有关其成本的主流观点变得正确。
Networking
如今,网络是几乎所有应用程序的关键组件,而良好的网络性能至关重要。因此,.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("xD83DxDE00", 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 ();工作()
文章来源: 博客园
- 还没有人评论,欢迎说说您的想法!