原文链接:https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-5/
Stephen
2020年7月13日
在.NET Core早期发布中,我曾写过关于团队如何实现了明显的性能改善。每篇博文,从.NET Core 2.0到.NET Core 2.1再到.NET Core 3.0,我发现我谈论的越来越多。说实话我不知道在后面的博文中是否还能提及更多足够有意义的改善。现在.NET 5已经发布了预览, 我可以确定地说答案是,有一次,“是的”。.NET 5已经被发现有很多方面的性能提高,尽管如此它还没有发布最终版本,直到今年晚些时候看起来还会有很多性能改善的,现在我愿意在这里提及一些已经可用的。 此篇中我将主要讲解接近250个PR达成的.NET 5性能改进。
配置
Benchmark.NET现在是测量 .NET 代码性能的规范工具,因此分析代码段的吞吐量和分配变得简单。因此,本文中的大多数示例都是使用使用该工具编写的微基准进行测量的。为了便于在家里跟进(实际上,现在我们中的许多人),我一开始创建一个目录,并使用 dotnet 工具来构建它:
mkdir Benchmarks
cd Benchmarks
dotnet new console
我增加了生成的基准.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 框架 4.8、.NET Core 3.1 和 .NET 5 执行基准测试(我目前为预览版 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 转到此处"
。
要运行基准测试,我随后会做:
dotnet run -c Release -f net48 --runtimes net48 netcoreapp31 netcoreapp50 --filter ** --join
这Benchmark.NET:
- 使用 .NET Framework 4.8 表面积构建基准(这是所有三个目标的最低公分母,因此适用于所有这些目标)。
- 针对 .NET 框架 4.8、.NET 核心 3.1 和 .NET 5 的每个基准运行基准。
- 在装配体中包括所有基准(不要过滤掉任何基准)。
- 将来自所有基准的所有结果的输出连接在一起,并在运行结束时显示输出(而不是在整个过程中穿插)。
在某些情况下,如果特定目标不存在 API,则我只需离开命令行的这一部分。
最后,需要注意一些:
- 我上次的基准帖子是关于 .NET Core 3.0 的。我没有写一个有关 .NET Core 3.1 的,因为从运行时和核心库的角度来看,它比几个月前发布的前身相比几乎没有改进。但是,还有一些改进,其中,在某些情况下,我们已经将 .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 不包括单声道运行时,因此我没有涵盖对单声道以及专门侧重于"Blazor"的核心库改进的改进。因此,当我提到"运行时"时,我指的是 coreclr,尽管截至 .NET 5,其保护伞下有多个运行时,并且所有这些运行时都已得到改进。
- 我的示例大多数是在 Windows 上运行的,因为我想能够与 .NET Framework 4.8 进行比较。但是,除非另有提及,否则显示的所有示例都同样累积到 Windows、Linux 和 macOS。
- 标准警告:此处的所有测量都在我的台式计算机上,您的里程可能会有所不同。微台标可以非常敏感于许多因素,包括处理器计数、处理器体系结构、内存和缓存速度,以及打开和打开。但是,一般来说,我专注于性能改进,并包括通常应承受任何此类差异的示例。
让我们开始...
GC
对于任何对 .NET 和性能感兴趣的人,垃圾回收通常是头等心。减少分配需要付出很多努力,不是因为分配行为本身特别昂贵,而是因为通过垃圾回收器 (GC) 进行这些分配后清理的后续成本。然而,无论在减少分配方面需要做多少工作,绝大多数工作负载都会产生这些工作量,因此,不断突破 GC 能够完成的任务以及速度的边界非常重要。
此版本在改进 GC 方面付出了很多努力。例如,dotnet/coreclr#25986 为 GC 的"标记"阶段实现了一种工作窃取形式。.NET GC 是一个"跟踪"收集器,这意味着(在非常高的级别)运行时,它从一组"根"(已知可访问的位置,如静态字段)开始,然后从对象遍历到对象,"标记"每个对象为可访问对象;在所有这些遍历之后,任何未标记的对象都无法访问,可以收集。此标记表示执行集合所花费的很大一部分时间,此 PR 通过更好地平衡集合中涉及的每个线程执行的工作来提高标记性能。使用"Server GC"运行时,每个内核的线程都涉及集合,当线程完成其分配的标记工作部分时,它们现在可以从其他线程"窃取"撤消的工作,以帮助更快地完成整个集合。
另一个示例是 dotnet/runtime#35896 优化了"临时"段上的取消提交(gen0 和 gen1 称为"临时",因为它们的对象预计仅持续很短的时间)。取消提交是在段上的最后一个实时对象之后将内存页放回段末尾的操作系统。GC 的问题就变成了,何时应该进行此类取消承诺,以及它应该在任何时间点取消承诺多少,因为它最终可能需要在近期的某种时间点分配额外的页面以进行额外分配。
或者采取 dotnet/runtime#32795,通过减少 GC 静态扫描中涉及的锁争用,提高了 GC 在具有更高内核计数的计算机上的可伸缩性。或 dotnet/runtime#37894,它避免了代价高昂的内存重置(基本上告诉操作系统相关内存不再有趣),除非 GC 看到它处于内存不足的情况。或 dotnet/runtime#37159,它(虽然尚未合并,预期为 .NET 5)基于 @damageboy 的工作,以矢量化 GC 中采用的排序。或 dotnet/coreclr#27729,这减少了 GC 挂起线程的时间,这是它获得稳定视图以便准确确定正在使用哪些线程所必需的。
这只是为改进 GC 本身而所做的部分更改列表,但最后一个要点让我想到一个特别让我着迷的话题,因为它说明了我们近年来在 .NET 中所做的许多工作。在此版本中,我们继续甚至加快了从 C/C++ 在核心clr运行时移植本机实现的过程,而不是系统.private.Corelib 中的普通 C# 托管代码。这种移动具有许多好处,包括让我们更轻松地跨多个运行时(如 coreclr 和 mono)共享单个实现,甚至使我们更轻松地发展 API 表面积,例如重用相同的逻辑来处理数组和范围。但有一件事让一些人感到意外,就是这种好处还包括性能,在多种方式。这样一种方式可以追溯到使用托管运行时的原始动机之一:安全。默认情况下,用 C# 编写的代码是"安全的",因为运行时可确保检查所有内存访问边界,并且只有代码中可见的显式操作(例如,使用unsafe
关键字、Marshal
类、unsafe
类等)才能删除此类验证的开发人员。因此,作为开源项目的维护者,当以托管代码形式提供捐款时,我们运送安全系统的工作变得容易得多:虽然此类代码当然可以包含可能通过代码评审和自动测试的 Bug,但我们知道此类 Bug 引入安全问题的机会大大降低,因此我们可以在晚上睡得更好。这反过来又意味着我们更有可能接受对托管代码的改进,并且速度更高,贡献者提供的速度更快,帮助我们进行验证。我们还发现,当性能改进以 C# 而不是 C 的形式出现时,更多的贡献者对探索性能改进感兴趣。更多的人以更快的速度进行更多的实验,可以产生更好的性能。
但是,我们从此类移植中看到了更直接的性能改进形式。托管代码调用运行时所需的开销相对较小,但当以高频方式进行此类调用时,这种开销会增加。考虑 dotnet/coreclr#27700,它将基元类型的数组排序的实现从核心 cclr 中的本机代码中移出,并在 Corelib 中移动到 C# 中。除了该代码之外,还为新的公共 API 提供排序范围,这还使得对较小的数组进行排序的成本更低,因为这样做的成本是由托管代码的过渡所主导的。我们可以用一个小的基准来查看这一点,该基准只是使用 Array.Sort
对 int[]
、double[]
和string[]
数组进行排序,这些数组包含 10 个项:
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 |
这本身是移动的一个很好的好处,事实上,在 .NET 5 通过 dotnet/runtime#37630 中,我们还添加了 System.Half
,一个新的 16 位浮点基元,并且正在托管代码中,这种排序实现优化几乎立即应用于它,而以前的本机实现需要大量的额外工作,没有 C++ 标准类型的half
。但是,这里可以说是更具影响力的性能优势,它让我们回到我开始讨论的地方:GC。
GC 的有趣指标之一是"暂停时间",这实际上意味着 GC 必须暂停运行时间以执行其工作的时间。较长的暂停时间对延迟有直接影响,而延迟可能是所有工作负载的关键指标。如前面提到,GC 可能需要挂起线程,以便获得一致的世界视图,并确保它可以安全地移动对象,但如果线程当前在运行时执行 C/C++ 代码,则 GC 可能需要等到该调用完成之后才能挂起线程。因此,在托管代码而不是本机代码中,我们可以做的工作越多,我们对于 GC 暂停时间处理得越好。我们可以使用相同的 Array.Sort
示例来查看此项。考虑此程序:
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,每个在它们之间有大约 15 毫秒。因此,我们预计该循环需要超过 150 毫秒。但是,当我在 .NET Core 3.1 上运行此时,我得到的秒数是这样的:
6.6419048
5.5663149
5.7430339
6.032052
7.8892468
GC 很难中断执行排序的线程,导致 GC 暂停时间远远高于所需的时间。谢天谢地, 当我在 .NET 5 上运行此时, 我得到这样的数字:
0.159311
0.159453
0.1594669
0.1593328
0.1586566
这正是我们预测应该得到的。通过将 Array.Sort 实现移动到托管代码中,运行时可以更轻松地在它想要时挂起实现,我们使 GC 能够更好地完成其工作。
当然, 这不仅限于 Array.Sort
。一群 RS 执行了此类移植,例如 dotnet/runtime#32722 将 stdelemref
和 ldelmaref
JIT 帮助程序移动到 C#, dotnet/runtime#32353 将unbox
帮助器的部分移动到 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
中的改进来实现各种string
和 Array
方法的额外增益。
作为这组更改的最后思考,需要注意的另一个有趣的事情是,在一个版本中所做的微优化如何基于后来无效的假设,并且当采用这种微优化时,需要做好准备并愿意适应。在我的 .NET Core 3.0 博客文章中,我叫出"花生酱"更改,如 dotnet/coreclr#21756,它将大量调用站点从使用 Array.Copy(source, destination, length)
改为使用 Array.Copy(source, sourceOffset, destination, destinationOffset, length)
,因为前者的开销获取源和目标数组的下限是可测量的。但是,由于上述一组将数组处理代码移动到 C# 的更改,更简单的重载开销消失了,使得这些操作的选择更简单、更快。因此,对于 .NET 5 RS dotnet/coreclr#27641 和 dotnet/corefx#42343,切换了所有这些调用站点,更多切换为使用更简单的重载。dotnet/runtime#36304 是另一个由于更改而撤消先前优化的示例,这些更改使它们过时或实际上有害。您始终能够将单个字符传递给 String.Split
,例如version.Split('.')
。但是,问题是,这可以绑定到的 Split
的唯一重载是Split(params char[] separator)
,这意味着每次此类调用都导致 C# 编译器生成 char[]
分配。为了消除这种情况,以前的版本添加了缓存,提前分配了数组,并将其存储到静态中,然后由 Split
调用使用静态字符以避免每次调用 char[]
。现在.NET 中存在Split(char separator, StringSplitOptions options = StringSplitOptions.None)
重载,我们不再需要数组了。
作为最后一个示例,我演示了将代码从运行时移动到托管代码如何有助于解决 GC 暂停问题,但运行时中剩余的代码当然还有其他方法可以帮助实现此功能。dotnet/runtime#36179 减少了由于异常处理而暂停的 GC 暂停,通过确保运行时围绕代码(如获取"Watson"存储桶参数)处于先发制人模式(preemptive mode)(基本上,一组唯一标识此特定异常的数据和用于报告目的调用堆栈)。
JIT
.NET 5 也是实时 (JIT) 编译器的令人兴奋的版本,其各种改进都融入了版本。与任何编译器一样,对 JIT 的改进可以产生广泛的影响。通常,单个更改对单个代码段的影响很小,但这些更改随后会因应用的地点数量而放大。
有几乎无限的优化数可以添加到 JIT,并且给定无限时间运行此类优化,JIT 可以为任何给定方案创建最佳代码。但是 JIT 没有无限的时间。JIT 的"实时"性质意味着它在应用运行时执行编译:当调用尚未编译的方法时,JIT 需要按需提供程序集代码。这意味着线程在编译完成之前无法取得进展,这反过来又意味着 JIT 需要在应用哪些优化以及选择如何使用其有限时间预算方面具有战略性。各种技术都用于给 JIT 更多的时间,例如在应用的某些部分使用"提前"编译 (AOT) 在应用执行之前尽可能多地执行编译工作(例如,核心库都是使用名为"ReadyToRun"的技术编译的,您可能会听到称为"R2R"甚至"交叉"的技术,这是生成这些图像的工具),或者使用"分层编译",它允许 JIT 最初编译一个应用了很少到没有优化的方法,因此这样做非常快,并且只有在被认为有价值时,即当该方法被显示为重复使用时,只会花更多的时间重新编译它。但是,更一般来说,为 JIT 做出贡献的开发人员只需选择使用分配的时间预算进行优化,因为开发人员正在编写代码,并且他们正在使用代码模式,这些优化被证明是有价值的。这意味着,随着 .NET 的发展和获得新功能、新的语言功能和新的库功能,JIT 也随着适合编写新代码样式的优化而发展。
这方面的一个很好的例子是使用来自用户网络的 dotnet/runtime#32538 @benaadams。Span<T>
一直渗透着 .NET 堆栈的所有层,因为处理运行时、核心库、ASP.NET Core 和超酷的开发人员在编写安全高效的代码时认识到其功能,这些代码也统一了对字符串、托管数组、本机分配的内存和其他形式的数据的处理。同样,值类型(结构)被更普遍地用作一种通过堆栈分配避免对象分配开销的方法。但是,这种对此类类型的严重依赖也会给运行时带来额外的麻烦。coreclr 运行时使用"精确"垃圾回收器,这意味着GC能够以 100% 的准确性跟踪哪些值指的是托管对象,哪些值不引用托管对象;这样做的好处,但它也有成本(相反,单声道运行时使用"保守"的垃圾回收器,这有一些性能优势,但也意味着它可能将堆栈上的任意值解释为与托管对象的地址相同的任意值,作为对该对象的实时引用)。其中一个成本是,JIT需要帮助的GC,保证任何本地可以解释为对象引用是零之前,GC注意它;否则,GC 可能会最终看到尚未设置的本地中的垃圾值,并假定它引用一个有效的对象,此时"坏事"可能发生。引用的局部变量越多,需要做的清理工作也更多。如果你只是清除一些当地人, 它可能不明显。但是,随着数量的增加,清除这些局部变量所花费的时间可能会增加,尤其是在非常热门的代码路径中使用的小方法中。这种情况在跨距和结构中已变得更加常见,其中编码模式通常会导致更多引用(Span<T>
包含引用),这些引用需要为零。上述 PR 通过更新执行此零的 prolog 块的 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
s 输出的 Guid
s。这是因为 C# 编译器正在为编译的Example
方法向 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
s 。从 dotnet/runtime#37541 起,.NET5 中的核心库现在都使用此属性禁用 .locals init
(在以前的版本中,.locals init
在构建核心库时采用的编译后步骤中剥离)。请注意,C# 编译器只允许在unsafe
上下文中使用 SkipLocalsInit
,因为它很容易导致代码损坏,而代码尚未经过适当验证才能使用(因此,如果 /应用它,请仔细考虑)。
除了加快零点之外,还进行了完全删除零的更改。例如,dotnet/runtime#31960、dotnet/runtime#36918、dotnet/runtime#37786 和 dotnet/runtime#38314 都有助于在 JIT 可以证明它是重复时删除零。
这种零是托管代码产生的税的示例,运行时需要它来保证其模型和上面语言的要求。另一种此类税项是界限检查。使用托管代码的一大优点是,默认情况下,一类潜在安全漏洞变得无关紧要。运行时可确保对数组、字符串和范围中的索引进行边界检查,这意味着运行时将注入检查,以确保请求的索引在要索引的数据的范围内(即大于或等于零,小于数据的长度)。下面是一个简单的示例:
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.NET 的一个方便功能生成的:将[DisassemblyDiagnoser]
添加到包含基准的类中,然后吐出拆解的程序集代码。我们可以看到,程序集采用字符串(通过 rcx
寄存器传递)并将字符串的长度(存储 8 个字节到对象中,因此 [rcx[8]
),与 edx
寄存器中传递的 i
进行比较,如果与无符号比较(未签名,使任何负值环绕大于长度),i
大于或等于长度,跳至引发异常COREINFO_HELP_RNGCHKFAIL
的帮助器。只是几个说明,但某些类型的代码可能会花费大量的周期索引,因此,当 JIT 可以消除尽可能多的边界检查,因为它可以证明是不必要的, 这是有帮助的。
JIT 已能够在各种情况下删除边界检查。例如,编写循环时:
int[] arr = ...;
for (int i = 0; i < arr.Length; i++)
Use(arr[i]);
JIT 可以证明我永远不会超出数组的界限,因此它可以逃避否则会生成边界检查。在 .NET 5 中,它可以删除在更多位置检查的绑定。例如,请考虑将整数字节作为字符写入范围以下函数:
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<byte>
(它也识别sbyte
和 bool
,但由于内维性问题,它不超过字节)。由于数组性质随后被范围完全隐藏,C# 编译器通过实际将字节存储在程序集的数据部分中来发出该数据,并且通过将其环绕到静态数据和长度的指针周围来创建 span:
IL_000c: ldsflda valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=16' '<PrivateImplementationDetails>'::'2125B2C332B1113AAE9BFC5E9F7E3B4C91D828CB942C2DF1EEB02502ECCAE9E9'IL_0011: ldc.i4.s 16IL_0013: newobj instance void valuetype [System.Runtime]System.ReadOnlySpan'1<uint8>::.ctor(void*, int32)
这对于此 JIT 讨论非常重要,因为上面的 ldc.i4.s 16
。这是 IL 加载长度 16 用于创建跨度,JIT 可以看到这一点。它知道范围的长度为 16,这意味着如果它可以证明访问始终为大于或等于 0 和小于 16 的值,则不需要边界检查该访问。dotnet/runtime#1644 正是这样做的,识别array[index % const]
等模式,并在 const
小于或等于长度时回避边界检查。在上一个 TryToHex
示例中,JIT 可以看到map
范围的长度为 16,并且可以看到,它的所有索引都使用 & 0xF
完成,这意味着所有值最终都将在范围内,因此它可以消除map
上的所有边界检查。结合这一事实,它已经可以看到不需要在写入span
(因为它可以看到长度检查之前的方法中,守卫到span
的所有索引),这整个方法是无边界检查在 .NET 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 |
请注意,.NET 5 的运行速度不仅比 .NET Core 3.1 运行快 15%,我们可以看到其程序集代码大小小 22%(额外的"代码大小"列来自我向基准类添加了 [DisassemblyDiagnoser]
)。
另一个不错的边界检查删除来自@nathan-moore
dotnet/runtime#36263。我提到过 JIT 已经能够删除边界,检查从 0 到数组、字符串或范围长度的非常常见的模式,但存在一些变化,这些差异也比较常见,但以前无法识别。例如,请考虑此微台标,它调用一个检测整数跨度是否排序的方法:
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 逃避边界检查。不再是了.NET 5 在我的计算机上能够执行此速度快 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 异常(例如此处)。但有时,指令只对空检查是必需的,而不是同时完成其他必要的功能,并且只要由于某些指令而发生所需的空检查,就可以删除不必要的重复指令。请考虑以下代码:
private (int i, int j) _value;
[Benchmark]
public int NullCheck() => _value.j++;
作为可运行的基准,这对于使用Benchmark.NET进行精确测量的工作太少,但它是查看生成程序集代码的一个很好的方法。使用 .NET Core 3.1 时,此方法将产生此程序集:
; 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
作为计算 j 地址的一部分,该 cmp [rcx],ecx
指令对this
执行空检查。然后,mov eax,[rcx]
指令执行另一个空检查,作为取消引用 j
位置的一部分。因此,第一次空检查实际上没有必要,指令不提供任何其他好处。因此,由于像 dotnet/runtime#1735 和 dotnet/runtime#32641 这样的 PR,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 中的数组是"协方差",这意味着我可以将数组DerivedType[]
作为 BaseType[]
传递,其中DerivedType
派生自 BaseType
。这意味着在此示例中,arr
可以构造为 new A[1]
或new object[1]
或 new B[1]
。此代码应使用前两个代码运行正常,但如果 arr
实际上是 B[]
,则尝试将 A
实例存储到其中时必须失败;如果 arr
实际上是 B[]
,则尝试将 A
实例存储到其中时,必须失败。否则,将数组用作 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<T>
解决了一堆问题,但也引入了新的模式,然后推动系统其他领域的改进;这同样适合 Span<T>
本身的实现。Span<T>
的构造函数执行协方差检查,要求 T[]
实际上是 T[]
而不是 U[]
从 T
派生,例如此程序:
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.
该异常源于 Span<T>
的构造中的此检查:
if (!typeof(T).IsValueType && array.GetType() != typeof(T[]))
ThrowHelper.ThrowArrayTypeMismatchException();
PR dotnet/runtime#32790 优化了这样一个数组。array.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 |
在查看生成的程序集时,即使不完全精通程序集代码,差异的解释也很明显。以下是在 .NET Core 3.1 上生成的 [DisassemblyDiagnoser]
显示的内容:
; 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
以下是它为. net 5 显示的:
; 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。当我们第一次将本机数组排序实现移动到托管时,我们无意中产生了浮点值的回归,@nietras 帮助发现了这一回归,随后在 dotnet/runtime#37941 中修复了回归。回归是由于本机实现使用我们在托管端口中缺少的特殊优化(对于浮点数组,将所有 NaN 值移动到数组的开头,以便后续的比较操作可以忽略 NaN 的可能性),我们成功地将它带过来。但是,问题在于以一种不会导致大量代码重复的方式表达这种情况:本机实现使用模板,而托管实现使用泛型,但使用泛型的内联限制使得为避免大量代码重复而引入的帮助器导致排序中使用的每个比较上出现非内联方法调用。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);}
Smallest
方法是比较三个提供的值并返回最小值的索引。它是泛型类型上的方法,它调用同一类型的另一个方法,这反过来又对泛型类型参数的实例上的方法进行调用。由于基准使用 C
作为泛型类型,并且 C
是引用类型,因此 JIT 不会专门针对 C
为此方法专门处理代码,而是使用它生成的用于所有引用类型的"共享"实现。为了使Compare
方法然后调用比较的正确接口实现CompareTo
,共享泛型实现使用字典,映射从泛型类型到正确的目标。在 .NET 的早期版本中,包含这些通用字典查找的方法不是内联的,这意味着此Smallest
方法无法内联它为Compare
而调用的三个调用,即使Compare
被归为MethodImplOptions.AggressiveInlining
。上述 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 ThrowHelpers(){
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<T>
执行参数验证,当 T
是值类型时,会导致 ThrowHelper
类上的方法存在两个调用站点, 对输入数组进行失败的 null 检查,在偏移量和计数范围不一时抛出的异常方法(ThrowHelper
包含不可联系的方法,如 ThrowArgumentNullException
,它包含实际 throw
并避免每个调用站点的关联代码大小;JIT 当前无法"概述",与"内联"相反,因此在重要的情况下需要手动执行)。在上面的示例中,我们创建六个 span,这意味着对 Span<T>
构造函数的六次调用,所有这些调用都将内线。JIT 可以看到数组是非空的,因此它可以从内线代码中消除 null 检查和 ThrowArgumentNullException
,但它不知道偏移量和计数是否在范围内,因此它需要保留 ThrowHelper.ThrowArgumentOutRangeException
方法的范围检查和调用站点。在 .NET Core 3.1 中,这会导致为此 ThrowHelpers
方法生成如下所示的代码:
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 能够识别此重复,而不是所有六个调用站点,它最终会将它们合并为一个:
M00_L00:
call System.ThrowHelper.ThrowArgumentOutOfRangeException()
int 3
所有失败的检查都跳到此共享位置,而不是每个检查都有其自己的副本。
Method | Runtime | Code Size |
---|---|---|
ThrowHelpers | .NET FW 4.8 | 424 B |
ThrowHelpers | .NET Core 3.1 | 252 B |
ThrowHelpers | .NET 5.0 | 222 B |
这些只是 .NET 5 中 JIT 中大量改进的一部分。还有更多。dotnet/runtime#32368 使 JIT 将数组的长度视为未签名,因此它能够对对长度执行的一些数学运算(例如除法)使用更好的指令。dotnet/coreclr#25458 使 JIT 能够对某些未签名的整数操作使用更快的 0 个比较,例如,当开发人员实际编写a >= 1
时,使用等效的 a != 0
。dotnet/runtime#1378 允许 JIT 识别"恒定字符串"。长度作为常量值。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中也有一些重大更改,这些更改默认情况下是禁用的,目的是获取有关它们的实际反馈并默认在.NET 5之后启用它们。例如,dotnet/runtime#32969提供了“堆栈替换”(OSR)的初始实现。我在前面提到了分层编译,它使JIT能够首先为一种方法生成最小优化的代码,然后在证明该方法很重要时再对该方法进行重新编译,并进行更多优化。通过允许代码更快地运行并仅在事情运行后才升级有影响力的方法,从而缩短了启动时间。但是,分层编译依赖于能够替换实现,并且下次调用该实现时,将调用新的实现。但是长期运行的方法呢?默认情况下,对于包含循环的方法(或更具体地说,向后分支),将禁用分层编译,因为它们可能会长时间运行,从而导致无法及时使用替换。 OSR使方法可以在代码执行时和“堆栈上”时进行更新; PR中包含的设计文档中有很多重要的细节(也与分层编译有关,dotnet/runtime#1457改进了调用计数机制,通过该机制,分层编译可决定何时重新编译哪些方法)。您可以通过将COMPlus_TC_QuickJitForLoops
和COMPlus_TC_OnStackReplacement
环境变量都设置为1
来进行OSR实验。作为另一个示例,dotnet/runtime#1180改进了try块内代码的生成代码质量,使JIT可以将值保留在以前无法保存的寄存器中。 t。您可以通过将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,也可以成为const)。或dotnet / runtime#1224,它可以改善Bmi2.MultiplyNoFlags
内部函数的代码生成。或dotnet/runtime#37836,它将BitOperations.PopCount
转换为内在函数,使JIT能够识别使用常量参数调用的时间,并用预先计算的常量替换整个操作。或dotnet/runtime#37254,它删除使用const字符串时发出的空检查。或@damageboy的dotnet/runtime#32000,它可以优化双重否定。
本征
在.NET Core 3.0中,JIT添加并认可了上千种新的硬件内在方法,以使C#代码可以直接针对SSE4和AVX2之类的指令集(请参阅文档)。然后,这些功能在核心库中的大量API中得到了极大的利用。但是,内在函数仅限于x86 / x64体系结构。在.NET 5中,由于有多个贡献者,尤其是Arm Holdings的@TamarChristinaArm,在添加成千上万的特定于ARM64上的工作已经投入了大量精力。与它们的x86 / x64版本一样,这些内在函数已在核心库功能中得到很好的利用。例如,以前对BitOperations.PopCount()
方法进行了优化,以使用x86 POPCNT固有函数,对于.NET 5,dotnet/runtime#35636对其进行了增强,使其还能够使用ARM VCNT或ARM64 CNT等效项。类似地,dotnet/runtime#34486修改了BitOperations.LeadingZeroCount
,TrailingZeroCount
和Log2
以利用相应的指令。在更高层次上,@Gnbrkm41的dotnet/runtime#33749扩展了BitArray
中的多种方法,以使用ARM64内在函数与先前添加的对SSE2和AVX2的支持一起使用。确保Vector
API在ARM64上也能正常工作的工作量很大,例如dotnet/runtime#37139和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都会调用预先存在的帮助程序功能,而运行时会提供这些帮助程序,对这些帮助程序的改进可能会对程序产生有意义的影响。 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 |
文本处理
基于文本的处理是许多应用程序的头等大事,每个发行版中都花了很多心血来改进基本的构建基块,在此基础上构建所有其他内容。 此类更改从帮助程序的微优化一直扩展到整个文本处理库的全面检查。
System.Char
在.NET 5中获得了一些不错的改进。例如,dotnet/coreclr#26848通过调整实现以减少指令和分支的数量,提高了char.IsWhiteSpace的性能。 然后对char.IsWhiteSpace
的改进体现在其他依赖它的其他方法中,例如string.IsEmptyOrWhiteSpace
和Trim
:
[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通过改善各种方法的可内联性,简化了从公共API到核心功能的调用路径并进一步调整实现以确保char.ToUpperInvariant
和char.ToLowerInvariant
的性能。 JIT正在生成最佳代码。
[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 |
对strings
的操作也进行了许多改进,例如dotnet/coreclr#26621和dotnet/coreclr#26962,在某些情况下,它们显着提高了可识别文化的Linux上StartsWith
和EndsWith
操作的性能。
当然,低级处理是一件好事,但是如今,应用程序花费大量时间进行高级操作,例如以特定格式(例如UTF8)对数据进行编码。 以前的.NET Core版本对Encoding.UTF8
进行了优化,但是在.NET 5中,它仍在进一步改进。 dotnet/runtime#27268通过更好地利用堆栈分配和JIT非虚拟化方面的改进(尤其是对于较小的输入),对其进行了更多的优化(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”)(现在已通过dotnet/runtime#37550公开为Encoding.Latin1
)也非常重要,特别是对于网络 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.Web
中的编码器,其中@gfoidl的PR dotnet/corefx#42073和dotnet/runtime#284改进了各种TextEncoder
类型。 其中包括使用SSSE3指令对JavaScriptEncoder.Default
实现中的FindFirstCharacterToEncodeUtf8
和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 _);
Method | Runtime | Mean | Ratio |
---|---|---|---|
Encode | .NET Core 3.1 | 102.52 ns | 1.00 |
Encode | .NET 5.0 | 33.39 ns | 0.33 |
正则表达式
一种非常特定但极为常见的解析形式是通过正则表达式。早在4月初,我分享了一篇详细的博客文章,内容涉及.NET 5中System.Text.RegularExpressions所进行的许多性能改进。我不会在这里重新介绍所有内容,但我鼓励您阅读尚未阅读的所有内容,因为它代表了图书馆的重大进步。但是,我在那篇文章中还指出,我们将继续改进Regex
,特别是对特殊但常见的情况增加了支持。
这样的改进之一是在指定RegexOptions.Multiline
时在换行符处理中,它更改了^
和$
锚的含义以匹配任何行的开头和结尾,而不仅仅是整个输入字符串的开头和结尾。我们以前没有对行首锚进行任何特殊处理(指定Multiline
时为^
),这意味着作为FindFirstChar
操作的一部分(有关所指内容的背景信息,请参见前面的博客文章),我们不会不会像我们以前那样多跳。 dotnet/runtime#34566教FindFirstChar
如何使用向量化的IndexOf
跳转到下一个相关位置。在此基准测试中强调了这种影响,该基准测试处理的是从古腾堡计划(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(@"^.*love.*$", 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 | RatioIs |
---|---|---|---|
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
的虚拟调用次数,从而缓存了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中进行了进一步的增强)。 该更改认识到,对于以原子循环(一个显式编写的或更经常通过自动分析表达式升级为原子的正则表达式)开头的正则表达式,我们可以更新扫描循环中的下一个起始位置(同样,请参见博客) 有关详细信息,请参见循环结束的位置,而不是循环的起始位置。 对于许多输入,这可以大大减少开销。 使用来自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.Compiled
,它在运行时使用Reflection Emit生成IL,而后者又需要进行JIT编译。根据使用的表达式,Regex
可能会吐出大量的IL,这可能需要大量的JIT处理才能生成汇编代码。 dotnet/runtime#35352改进了JIT本身以解决这种情况,修复了正则表达式生成的IL触发的一些潜在的二次执行时代码路径。然后dotnet/runtime#35321调整了Regex
引擎使用的IL操作,以采用与C#编译器发出的模式更接近的模式,这很重要,因为JIT更需要优化这些模式以使其优化。在一些具有数百个复杂正则表达式的现实工作负载中,这些工作负载相结合,可以将JIT表达式花费的时间减少多达20%。
线程和异步
实际上,默认情况下未启用.NET 5中围绕异步进行的最大更改之一,但这是获取反馈的另一项实验。 博客文章.NET 5中的Async ValueTask Pooling对此进行了更详细的解释,但从本质上说,dotnet/coreclr#26310引入了async ValueTask
和async ValueTask<T>
的功能,以隐式缓存和重用创建的对象以表示异步完成的操作。 ,使此类方法的开销无需分摊摊销。 该优化当前处于启用状态,这意味着您需要将DOTNET_SYSTEM_THREADING_POOLASYNCVALUETASKS
环境变量设置为1
才能启用它。 启用此功能的困难之一是代码可能正在做一些事情,而不仅仅是等待SomeValueTaskReturningMethod()
,因为ValueTasks
比Tasks
具有更多关于如何使用它们的约束。 为了解决这个问题,发布了一个新的UseValueTasksCorrectly分析器,该分析器将标记大多数此类滥用情况。
[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中的异步方法带来了更多好处(因为.NET 5中的核心库是使用较新的编译器进行编译的)。每个异步方法都有一个“生成器”,负责生成并完成返回的任务,而C#编译器会生成代码,并将其作为异步方法的一部分来使用。 @benaadams的dotnet/roslyn#41253避免了该代码的一部分生成的结构副本,这可以帮助减少开销,尤其是对于构建器相对较大(并随着T增长而增长)的async ValueTask<T>
方法而言。 @benaadams的dotnet/roslyn#45262也对相同的生成代码进行了调整,以便与前面讨论的JIT的归零改进一起更好地发挥作用。
特定的API也有一些改进。 dotnet/runtime#35575源自Task.ContinueWith
的某些特定用法,其中延续仅用于继续记录“先行”Task
中的异常。这里的常见情况是Task
没有错,并且此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为目标时,在JIT时,volatile
本质上会蒸发掉。 ARM / ARM64并非如此,后者的内存模型较弱,并且易失性导致JIT发出volatile
结果。 dotnet/runtime#36697删除了排队到ThreadPool
的每个工作项的多个易失性访问,从而使ThreadPool
在ARM上更快。 dotnet/runtime#34225中断了ConcurrentDictionary
中的易失性访问,从而反过来将ARM上ConcurrentDictionary
上某些成员的吞吐量提高了30%。 dotnet/runtime#36976完全从另一个ConcurrentDictionary
字段中删除了volatile
。
集合
多年来,C#获得了许多有价值的功能。这些功能中的许多功能集中于开发人员能够更简洁地编写代码,而语言/编译器负责所有样板,例如C#9中的记录。但是,一些功能集中于生产力,而更多地关注性能。 ,这些功能对于核心库是一个极大的福音,这些库经常可以使用它们来提高每个人的程序的效率。 @benaadams的dotnet/runtime#27195是一个很好的例子。 PR利用C#7中引入的ref return和ref locals改进了Dictionary <TKey,TValue>
。Dictionary <TKey,TValue>
的实现由字典中的条目数组支持,并且字典具有一个核心例程,用于在其entry数组中查找键的索引;然后可以从多个函数(例如索引器,TryGetValue
,ContainsKey
等)中使用该例程。但是,这种共享需要付出一定的代价:通过将索引交还给调用者以根据需要从该插槽中获取数据,调用者将需要重新索引到数组中,从而进行第二次边界检查。使用ref返回,该共享例程可以将ref传递回插槽而不是原始索引,从而使调用者可以避免第二次边界检查,同时还可以避免复制整个条目。 PR还包括对生成的程序集的一些低级调整,重组字段以及用于更新这些字段的操作,使JIT能够更好地调整生成的程序集。
通过另外几个PR,Dictionary <TKey,TValue>
的性能得到了进一步提高。像许多哈希表一样,Dictionary <TKey,TValue>
划分为“存储桶”,每个存储桶本质上是一个链接的条目列表(存储在数组中,而不是每个项目具有单独的节点对象)。对于给定的密钥,使用哈希函数(TKey
的GetHashCode
或提供的IComparer <T>
的GetHashCode
)来计算提供的密钥的哈希码,然后将该哈希码确定性地映射到存储桶;一旦找到存储桶,该实现便会遍历该存储桶中的条目链,以寻找目标密钥。该实现尝试使每个存储桶中的条目数保持较小,并根据需要增加和重新平衡以维持该条件。这样,查找成本的很大一部分是计算哈希码到存储桶的映射。为了帮助在存储桶之间保持良好的分布,特别是当所提供的TKey
或比较器使用了不理想的哈希码生成器时,该词典使用存储桶的质数,并且存储桶映射由hashcode % numBuckets
完成。但是以此处重要的速度,%运算符采用的除法相对昂贵。以Daniel Lemire的工作为基础,@benaadams的dotnet/coreclr#27299,然后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<T>
与Dictionary<TKey,TValue>
非常相似。 尽管它公开了一组不同的操作(无双关),但仅存储键而不是键和值,但其数据结构在本质上是相同的……或者至少是以前的样子。 多年来,鉴于使用的Dictionary <TKey,TValue>
比HashSet <T>
多得多,我们在优化Dictionary <TKey,TValue>
的实现上投入了更多的精力,而这两种实现已经发生了变化。 @JeffreyZhao的dotnet/corefx#40106将一些改进从字典移植到了哈希集,然后dotnet/runtime#37180通过将其与字典重新同步来有效重写了HashSet<T>
的实现(以及将其在字典中的下移位置)。 堆栈,以便可以适当地替换正在用于集合的字典的某些位置)。 最终结果是HashSet<T>
最终会获得相似的收益(甚至更是如此,因为它是从更糟糕的地方开始的)。
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 |
未完待续......