.NET 9中的性能提升
Stephen Toub - MSFT
合作伙伴软件工程师
目录
- 基准测试设置
- 即时编译(JIT)
- 性能优化编译(PGO)
- 层级0
- 循环
- 边界检查
- Arm64
- ARM SVE
- AVX1.0
- AVX512
- 向量化
- 分支
- 写屏障
- 对象栈分配
- 内联
- 垃圾回收(GC)
- 虚拟机(VM)
- Mono
- 本地AOT编译
- 多线程
- 反射
- 数值计算
- 基本类型
- BigInteger
- TensorPrimitives
- 字符串、数组、Spans
- IndexOf
- 正则表达式(Regex)
- 编码(Encoding)
- Span、Span及更多Span
- 集合
- LINQ
- 核心集合
- 压缩
- 加密
- 网络
- JSON
- 诊断
- 花生酱
- 接下来是什么?
[收起](javascript:)
下一章阅读
2024年9月12日
Android 资产包适用于 .NET 和 .NET MAUI Android 应用
Dean Ellis
2024年9月18日
为C#开发者提升GitHub Copilot在Visual Studio中的完成效果](https://devblogs.microsoft.com/dotnet/improving-github-copilot-completions-in-visual-studio-for-csharp-developers/)
Mika Dumont
每年夏天,我都会满怀敬畏和激动地来撰写关于即将发布的.NET版本的性能提升。说“敬畏”,因为这些文章,涵盖 .NET 8, .NET 7, .NET 6, .NET 5, .NET Core 3.0, .NET Core 2.1 和 .NET Core 2.0,都已经积累了一定的声誉,我希望下一个迭代能够名副其实。而说“激动”,是因为由于下一个.NET版本中包含的众多优点,我总是感到难以迅速地将它们全部记录下来。
因此,每年我开头都会说,下一个版本的.NET是迄今为止最快、最好的版本。对于.NET 9来说,这个说法同样是正确的,但说.NET 9是迄今为止最快的.NET版本,现在听起来有点……陈词滥调。所以,让我们来点不一样的。比如,来一首俳句吧?
隼飞翔天际,
.NET 9将喜悦带给开发者之心。
或者,也许一首轻快的诗行会更合适:
编程界有颗星,
.NET 9遥遥领先。
速度无与伦比,
每个编码者的梦想,
将开发推向新高度。
这有点卖弄吗?也许一首更传统的诗,比如十四行诗会更合适:
在智慧代码领域,辉煌熠熠生辉,
.NET 9以其独特风采照耀天地。
它的速度与优雅,令人惊叹不已,
将任务变为珍宝,迅速而勇敢。
开发者们满怀喜悦,拥抱它的力量,
项目翱翔,效率显著提升。
不再受限于往昔的束缚,
.NET 9让他们的梦想永存不朽。
它的库,如一曲美妙的交响乐,
将复杂化为简单,黑暗变为光明。
每一行代码,都是一件杰作,
.NET 9让开发者重获自由。
哦,奇妙的.NET 9,你照亮了道路,
在你的怀抱中,我们的未来光明无限。
好吧,我或许应该专注于写软件,而不是诗歌(我大学的诗歌教授可能也这么认为)。尽管如此,感情依然存在:.NET 9是一个非常令人激动的版本。在过去一年中,有超过7,500个合并请求(PR)进入 dotnet/runtime ,其中相当大的一部分涉及性能的某个方面。在这篇文章中,我们将浏览超过350个PR,它们共同为.NET 9注入了丰富的性能提升。请拿起你最喜欢的大杯热饮,坐下来,放松,享受吧。
性能测试环境搭建
在这篇文章中,我包含了一些微基准测试来展示各种性能改进。这些基准测试的大部分都是使用BenchmarkDotNet v0.14.0实现的,除非另有说明,每个测试都采用了一个简单的设置。
为了跟随本文的演示,首先请确保已经安装了 .NET 8 和 .NET 9。我分享的数字是在使用 .NET 9 发布候选版时收集的。
一旦安装了所需的前提条件,请在新创建的基准测试目录中创建一个新的C#项目:
dotnet new console -o benchmarks
cd benchmarks
创建的目录将包含两个文件:benchmarks.csproj
,这是一个包含应用程序编译信息的项目文件,以及Program.cs
,其中包含了应用程序的代码。将benchmarks.csproj
的整个内容替换为以下内容:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net9.0;net8.0</TargetFrameworks>
<LangVersion>Preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
</ItemGroup>
</Project>
前面的项目文件告诉构建系统我们要:
- 构建一个可执行的应用程序,而不是库。
- 能够在.NET 8和.NET 9上运行,这样BenchmarkDotNet就可以为每个目标运行时构建多个应用程序版本,以便比较结果。
- 能够使用C#语言的最新功能,即使C# 13尚未正式发布。
- 自动导入常用的命名空间。
- 能够在代码中使用可空引用类型注解。
- 能够在代码中使用
unsafe
关键字。 - 配置垃圾回收器(GC)为“服务器”配置,这会影响GC在内存消耗和吞吐量之间的权衡。这虽然不是必需的,但大多数服务都是这样配置的。
- 从NuGet中拉取
BenchmarkDotNet
v0.14.0,以便我们能够在Program.cs
中使用这个库。
对于每个基准测试,我都包含了完整的Program.cs
源代码;要测试它,只需将Program.cs
中的整个内容替换为显示的基准测试。每个测试可能与其他测试略有不同,以突出展示的关键方面。例如,一些测试包含[MemoryDiagnoser(false)]
属性,这告诉BenchmarkDotNet不要跟踪与分配相关的指标,或者包含[DisassemblyDiagnoser]
属性,这告诉BenchmarkDotNet找到并共享测试的汇编代码,或者包含[HideColumns]
属性,这移除了BenchmarkDotNet可能默认输出的某些列,这些列对我们的文章需求来说是不必要的。
运行基准测试很简单。每个测试的顶部都有一个注释,用于指定dotnet
命令以运行基准测试。通常是这样的:
dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
这个命令:
- 以发布构建方式构建基准测试。编译为发布构建很重要,因为C#编译器和JIT编译器都有优化,这些优化在调试模式下是禁用的。幸运的是,BenchmarkDotNet会在意外使用调试模式时发出警告:
// 验证基准测试:
// * 集合 Benchmarks 定义的基准是非优化的
基准测试构建时未启用优化(最可能是调试配置)。请以发布配置构建。
如果您想调试基准测试,请参阅 https://benchmarkdotnet.org/articles/guides/troubleshooting.html#debugging-benchmarks。
- 以.NET 8为目标运行主机项目。这里涉及到多个构建:您使用上述命令运行的“主机”应用程序,它使用BenchmarkDotNet,进而为每个目标运行时生成和构建一个应用程序。因为基准测试的代码被编译到所有这些应用程序中,所以通常希望主机项目以您将要测试的最旧的运行时为目标,这样构建主机应用程序时,如果尝试使用在所有目标运行时不可用的API,构建将会失败。
- 运行整个程序中的所有基准测试。如果您不指定
--filter
参数,BenchmarkDotNet会提示您选择要运行的基准测试。通过指定“*”,我们表示“不要提示,直接运行”。您也可以指定一个表达式来筛选要调用的测试子集。 - 在.NET 8和.NET 9上运行测试。
在整篇文章中,我展示了多个基准测试和运行它们得到的结果。除非另有说明(例如,因为我正在展示一个与操作系统相关的改进),基准测试的结果都是在我使用Linux(Ubuntu 22.04)在x64处理器上运行的。
BenchmarkDotNet v0.14.0,Ubuntu 22.04.3 LTS(Jammy Jellyfish)WSL
第11代英特尔酷睿i9-11950H 2.60GHz,1个CPU,16个逻辑核心和8个物理核心
.NET SDK 9.0.100-rc.1.24452.12
[主机] : .NET 9.0.0(9.0.24.43107),X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
我的标准免责声明:这些是微基准测试,通常测量的是非常短的时间内的操作,但那些时间的改进在连续执行时会产生显著的影响。不同的硬件、不同的操作系统、您机器上可能运行的其他进程、您今天早餐吃了什么以及行星的排列都会影响您得到的数字。简而言之,您看到的数字可能不会与我在这里分享的数字完全匹配;然而,我已经选择了应该可以广泛重复的基准测试。
在说清楚所有这些之后,让我们开始吧!
JIT
.NET在各层级上的改进都展现了出来。一些变更导致了一个特定领域的显著提升,而其他变更则使许多事物得到了小幅改善。当谈到广泛的影响,很少有.NET领域的变更能比那些对即时编译器(JIT)所做的变更带来更广泛的影响。代码生成改进有助于使一切变得更好,这就是我们旅程的开始之处。
PGO
在.NET 8中的性能改进中,我提到了启用动态配置指导优化(PGO)是我最喜欢的特性,因此PGO似乎是.NET 9的一个很好的开始点。
作为简要的复习,动态PGO是一种功能,它使JIT能够分析代码,并使用从分析中学习到的知识来生成更高效的代码,基于应用程序的确切使用模式。JIT利用分层编译,允许代码被编译并可能多次重新编译,每次编译时都会产生一些新的东西。例如,一个典型的方法可能从“层0”开始,在那里JIT应用很少的优化,目标是尽可能快地生成可执行的汇编代码。这有助于提高启动性能,因为优化是编译器执行的最昂贵的操作之一。然后运行时跟踪方法被调用的次数,如果调用的次数超过特定的阈值,那么性能实际上可能很重要,JIT将重新生成它的代码,仍然在“层0”,但这次在方法中注入了大量额外的检测代码,跟踪所有可能帮助JIT更好优化的东西,例如,对于特定的虚拟分发,调用最常见的是哪种类型。然后当收集到足够的数据后,JIT可以再次编译该方法,这次是在“层1”,完全优化,并合并了所有从分析数据中学到的知识。这个流程同样适用于已经用ReadyToRun(R2R)预编译的代码,只不过在注入“层0”代码时,JIT会在生成重新优化的实现时生成优化并注入检测的代码。
在.NET 8中,JIT特别关注PGO数据中涉及的虚拟、接口和委托分发中的类型和方法。在.NET 9中,它也能够使用PGO数据来优化类型转换。多亏了dotnet/runtime#90594、dotnet/runtime#90735、dotnet/runtime#96597、dotnet/runtime#96731和dotnet/runtime#97773,动态PGO现在能够跟踪类型转换操作中最常见的输入类型(castclass
/isinst
,例如从执行像(T)obj
或obj is T
这样的操作获得的类型),然后在生成优化代码时,发出特殊的检查,为最常见类型添加快速路径。例如,在下面的基准测试中,我们有一个类型为A
的域,初始化为同时从B
和A
派生的类型C
。然后基准测试会检查存储在该A
域中的实例,看它是否是B
或B
的任何派生类型。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser(maxDepth: 0)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private A _obj = new C();
[Benchmark]
public bool IsInstanceOf() => _obj is B;
public class A { }
public class B : A { }
public class C : B { }
}
IsInstanceOf
基准测试在.NET 8上产生以下反汇编结果:
; Tests.IsInstanceOf()
push rax
mov rsi,[rdi+8]
mov rdi,offset MT_Tests+B
call qword ptr [7F3D91524360]; System.Runtime.CompilerServices.CastHelpers.IsInstanceOfClass(Void*, System.Object)
test rax,rax
setne al
movzx eax,al
add rsp,8
ret
; Total bytes of code 35
但现在在.NET 9上,它产生了以下结果:
; Tests.IsInstanceOf()
push rbp
mov rbp,rsp
mov rsi,[rdi+8]
mov rcx,rsi
test rcx,rcx
je short M00_L00
mov rax,offset MT_Tests+C
cmp [rcx],rax
jne short M00_L01
M00_L00:
test rcx,rcx
setne al
movzx eax,al
pop rbp
ret
M00_L01:
mov rdi,offset MT_Tests+B
call System.Runtime.CompilerServices.CastHelpers.IsInstanceOfClass(Void*, System.Object)
mov rcx,rax
jmp short M00_L00
; Total bytes of code 62
在.NET 8中,它加载对象的引用和B
的期望方法令牌,并调用JIT的CastHelpers.IsInstanceOfClass
JIT助手进行类型检查。在.NET 9中,它加载了在分析过程中看到的最常见的类型C
的方法令牌,并将其与实际对象的方
Tier 0
Tier 0的重点是快速实现功能代码,因此大多数优化都被禁用了。然而,有时在Tier 0中做更多的优化也是有理由的,当这样做的益处超过了弊端时。在.NET 9中就发生了几个这样的例子。
dotnet/runtime#104815 是一个简单的例子。现在,ArgumentNullException.ThrowIfNull
方法被用于成千上万的地方进行参数验证。它是一个非泛型方法,接受一个 object
参数并检查它是否为 null
。这种非泛型性在使用值类型时会给人们带来一些困扰。直接用值类型调用 ThrowIfNull
的情况很少(可能除了与 Nullable<T>
一起使用外),实际上,如果有人这样做,由于 @CollinAlpert 的 dotnet/roslyn-analyzers,现在有一个 CA2264 分析器会警告这样做是无意义的:
相反,最常见的场景是验证的参数是一个未约束的泛型。在这种情况下,如果泛型参数最终是一个值类型,它将在调用 ThrowIfNull
时被装箱。在Tier 1中,由于 ThrowIfNull
调用被内联,JIT可以在调用站点看到装箱是不必要的,因此这种装箱分配会被移除。但是,由于在Tier 0中不会进行内联,这种装箱一直存在于Tier 0中。由于这个API非常普遍,这导致开发人员担心发生了什么,并引起了足够的困扰,以至于JIT现在为 ArgumentNullException.ThrowIfNull
特殊处理并避免在Tier 0中进行装箱。这可以通过一个小测试控制台应用程序轻松地看到:
// dotnet run -c Release -f net8.0 --filter "*"
// dotnet run -c Release -f net9.0 --filter "*"
using System.Runtime.CompilerServices;
while (true)
{
Test();
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void Test()
{
long gc = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 100; i++)
{
ThrowIfNull(i);
}
gc = GC.GetAllocatedBytesForCurrentThread() - gc;
Console.WriteLine(gc);
Thread.Sleep(1000);
}
static void ThrowIfNull<T>(T value) => ArgumentNullException.ThrowIfNull(value);
当我在.NET 8上运行这个程序时,我会得到这样的结果:
2400
2400
2400
0
0
0
前几个迭代会以Tier 0的方式调用 Test()
,因此每次调用 ArgumentNullException.ThrowIfNull
都会将输入的 int
装箱。然后当方法在Tier 1中被重新编译时,装箱会被省略,我们最终稳定在零分配。现在在.NET 9上,我得到这样的结果:
0
0
0
0
0
0
通过这些对Tier 0的调整,装箱也在Tier 0中被省略,因此一开始就没有任何分配。
另一个Tier 0的装箱例子是 dotnet/runtime#90496。async
/await
机制中有一个热点路径方法:AsyncTaskMethodBuilder<TResult>.AwaitUnsafeOnCompleted
(详见 C#中异步/await的实际工作原理)。这个方法需要得到很好的优化,但它执行了会导致Tier 0中装箱的各种类型检查。在之前的版本中,这种装箱对在应用程序生命周期早期调用的 async
方法启动影响太大,因此使用了 [MethodImpl(MethodImplOptions.AggressiveOptimization)]
来将方法排除在分层之外,以便从一开始就进行优化。但是,这本身也有缺点,因为如果它跳过了分层,它也将跳过动态PGO,因此优化的代码可能不是最好的。所以,这个PR专门解决了那些会导致装箱的类型检查模式,移除了Tier 0中的装箱,从而允许移除 AwaitUnsafeOnCompleted
中的 AggressiveOptimization
,并因此使得对它的代码生成进行更好的优化。
在Tier 0中避免优化是因为它们可能会减慢编译速度。但是,如果有一些真的非常便宜的优化,并且它们可以产生有意义的影响,那么启用它们也是值得的。特别是如果这些优化实际上可以帮助加快编译和启动速度,比如通过最小化调用可能加锁、触发某些类型的加载等的辅助器,那么这尤其正确。
另一个类似的案例是 @MichalPetryka 的 dotnet/runtime#91403,它允许在Tier 0中启用 RuntimeHelpers.CreateSpan
的优化。如果没有这个,运行时可能会最终分配许多字段占位符,这些占位符本身会增加启动路径的开销。
循环
应用程序在循环中花费大量时间,并且找到减少循环开销的方法一直是.NET 9 的一个关键关注点。在这方面,它也取得了相当的成功。
dotnet/runtime#102261 和 dotnet/runtime#103181 通过将向上计数的循环转换为向下计数的循环,帮助移除了甚至最紧密循环中的某些指令。考虑以下一个循环示例:
// dotnet run -c Release -f net8.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
[Benchmark]
public int UpwardCounting()
{
int count = 0;
for (int i = 0; i < 100; i++)
{
count++;
}
return count;
}
}
以下是该核心循环在.NET 8 上生成的汇编代码:
M00_L00:
inc eax
inc ecx
cmp ecx,64
jl short M00_L00
这里正在增加 eax
,它存储 count
。同时,它也在增加 ecx
,它存储 i
。然后比较 ecx
与 100(0x64)以查看是否到达循环的末尾,如果没有,就返回到循环的开始。
现在让我们手动重写这个循环以进行向下计数:
// dotnet run -c Release -f net8.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
[Benchmark]
public int DownwardCounting()
{
int count = 0;
for (int i = 99; i >= 0; i--)
{
count++;
}
return count;
}
}
以下是该核心循环在.NET 9 上生成的汇编代码:
M00_L00:
inc eax
dec ecx
jns short M00_L00
关键观察点是,通过向下计数,我们可以将一个 cmp
/jl
的比较操作替换为仅仅是一个 jns
跳转,如果值不是负数就跳转。因此,我们从只有四个指令的紧密循环中移除了一个指令。
借助上述 PR,JIT 现在可以在适用且被认为有价值的情况下自动执行这种转换,因此 UpwardCounting
方法中的循环在.NET 9 上的结果与 DownwardCounting
方法的循环在.NET 9 上的结果相同。
方法 | 运行时 | 平均值 | 比率 |
---|---|---|---|
UpwardCounting | .NET 8.0 | 30.27 纳秒 | 1.00 |
UpwardCounting | .NET 9.0 | 26.52 纳秒 | 0.88 |
然而,JIT 只能在迭代变量(i
)在循环体中不被使用的情况下执行这种转换,并且显然有许多循环中的迭代变量是被使用的,例如在遍历数组时进行索引。幸运的是,.NET 9 中的其他优化能够减少对迭代变量的实际依赖,这样这个优化现在就经常被触发。
一种这样的优化是循环中的强度降低。在编译器中,“强度降低”是一个相对昂贵的操作被替换为更便宜的操作的简单想法。在循环的上下文中,这通常意味着引入更多的“归纳变量”(每次迭代其值都会按照可预测模式变化的变量,例如每次迭代增加一个常数)。例如,考虑一个简单的循环,用于计算数组中所有元素的总和:
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private int[] _array = Enumerable.Range(0, 1000).ToArray();
[Benchmark]
public int Sum()
{
int[] array = _array;
int sum = 0;
for (int i = 0; i < array.Length; i++)
{
sum += array[i];
}
return sum;
}
}
我们在.NET 8 上得到以下汇编代码:
; Tests.Sum()
push rbp
mov rbp,rsp
mov rax,[rdi+8]
xor ecx,ecx
xor edx,edx
mov edi,[rax+8]
test edi,edi
jle short M00_L01
M00_L00:
mov esi,edx
add ecx,[rax+rsi*4+10]
inc edx
cmp edi,edx
jg short M00_L00
M00_L01:
mov eax,ecx
pop rbp
ret
; Total bytes of code 35
有趣的部分是从 M00_L00
开始的循环。i
存储在 edx
中(尽管它被复制到 esi
),在将数组的下一个元素添加到 sum
(存储在 ecx
中)的过程中,我们从地址 rax+rsi*4+10
加载下一个值。从强度降低的角度来看,这可以说“而不是在每个迭代中重新计算地址,我们可以引入另一个归纳变量,并在每次迭代中将其增加 4”。这个关键好处是,它从循环内部移除了对 i
的依赖,这意味着迭代变量不再在循环中使用,从而触发了上述向下计数优化。这是.NET 9 上生成的汇编代码:
; Tests.Sum()
push rbp
mov rbp,rsp
mov rax,[rdi+8]
xor ecx,ecx
mov edx,[rax+8]
test edx,edx
jle short M00_L01
add rax,10
M00_L00:
add ecx,[rax]
add rax,4
dec edx
jne short M00_L00
M00_L01:
mov eax,ecx
pop rbp
ret
; Total bytes of code 35
注意 M00_L00
的循环:现在它是向下计数的,从数组中读取下一个值只是简单地从 rax
地址中解引用,而 rax
地址在每个迭代中增加 4。
为了实现这种强度降低,进行了大量工作,包括提供基本实现 (dotnet/runtime#104243),默认启用 (dotnet/runtime#105131),寻找更多应用机会 (dotnet/runtime#105169),以及使用它来启用后索引寻址 (dotnet/runtime#105181 和 dotnet/runtime#105185),这是一个 Arm 寻址模式,其中基址寄存器中存储的地址被使用,但随后该寄存器被更新以指向下一个目标内存位置。JIT 还添加了一个新阶段以帮助优化此类归纳变量 (dotnet/runtime#97865),特别是执行归纳变量拓宽,将 32 位归纳变量(想想你写过的每个以 for (int i = ...)
开头的循环)拓宽为 64 位归纳变量。这种拓宽可以帮助避免在每次循环迭代时可能发生的零扩展。
这些优化都是新的,但当然 JIT 编译器中已经有许多循环优化,包括循环展开、循环克隆和循环提升。为了应用这些循环优化,JIT 首先需要识别循环,这有时比看起来更具挑战性 (dotnet/runtime#43713 描述了 JIT 在识别循环时失败的一个案例)。历史上,JIT 的循环识别基于相对简单的词法分析。在.NET 8 中,作为改进动态 PGO 的工作的一部分,添加了一个更强大的基于图的循环分析器,能够识别更多的循环。对于.NET 9,随着 dotnet/runtime#95251 的实现,那个分析器被提取出来,以便进行通用的循环推理。然后,随着 PRs 如 dotnet/runtime#96756(循环对齐)、dotnet/runtime#96754 和 dotnet/runtime#96553(循环克隆)、dotnet/runtime#96752(循环展开)、dotnet/runtime#96751(循环规范化)和 dotnet/runtime#96753(循环提升)的引入,许多循环相关的优化已经移到了更好的方案中。所有这些意味着更多的循环得到了优化。
边界检查
.NET代码默认是“内存安全的”。与C语言不同,在C语言中你可以遍历一个数组并轻松地越界,默认情况下,对数组、字符串和span的访问是“边界检查”的,以确保你不会越界或超出数组开头。当然,这样的边界检查会增加开销,因此,无论JIT能够证明添加此类检查是否必要,它都会省略边界检查,知道受保护的访问不可能有问题。这个经典的例子是遍历从0
到array.Length
的数组。
让我们看一下我们刚刚看过的同一个基准测试,计算整数数组所有元素的和:
// dotnet run -c Release -f net8.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser(maxDepth: 0)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private int[] _array = new int[1000];
[Benchmark]
public int Test()
{
int[] array = _array;
int sum = 0;
for (int i = 0; i < array.Length; i++)
{
sum += array[i];
}
return sum;
}
}
在.NET 8中,这个Test
基准测试会生成以下汇编代码:
; Tests.Test()
push rbp
mov rbp,rsp
mov rax,[rdi+8]
xor ecx,ecx
xor edx,edx
mov edi,[rax+8]
test edi,edi
jle short M00_L01
M00_L00:
mov esi,edx
add ecx,[rax+rsi*4+10]
inc edx
cmp edi,edx
jg short M00_L00
M00_L01:
mov eax,ecx
pop rbp
ret
; Total bytes of code 35
需要注意的关键部分是M00_L00
处的循环,它唯一的分支是对比edx
(跟踪i
)和edi
(之前被初始化为数组长度的 [rax+8]
),作为知道何时结束迭代的依据。这里不需要额外的检查来保证安全,因为JIT知道循环从0
开始(因此没有越过数组开头)并且JIT知道迭代结束在数组长度处,JIT已经在检查这个条件,所以可以安全地访问数组而无需额外检查。
现在,让我们稍微调整一下基准测试。在上面的例子中,我将_array
字段复制到了局部变量array
中,然后对它进行所有访问;这是关键的,因为没有其他东西可能会在循环过程中改变这个局部变量。但是,如果我们改写代码直接引用字段:
// dotnet run -c Release -f net8.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser(maxDepth: 0)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private int[] _array = new int[1000];
[Benchmark]
public int Test()
{
int sum = 0;
for (int i = 0; i < _array.Length; i++)
{
sum += _array[i];
}
return sum;
}
}
现在我们在.NET 8中得到以下结果:
; Tests.Test()
push rbp
mov rbp,rsp
xor eax,eax
xor ecx,ecx
mov rdx,[rdi+8]
cmp dword ptr [rdx+8],0
jle short M00_L01
nop dword ptr [rax]
nop dword ptr [rax]
M00_L00:
mov rdi,rdx
cmp ecx,[rdi+8]
jae short M00_L02
mov esi,ecx
add eax,[rdi+rsi*4+10]
inc ecx
cmp [rdx+8],ecx
jg short M00_L00
M00_L01:
pop rbp
ret
M00_L02:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 61
这糟糕得多。请注意,循环从M00_L00
开始,与之前的例子相比,增长了很多,特别是中间多了一个cmp
/jae
对,就在访问数组元素之前。由于代码在每次访问时都从字段读取,JIT需要考虑两次访问之间引用可能会改变的情况;因此,尽管JIT在循环边界检查中已经对比了 _array.Length
,但它还需要确保接下来的 _array[i]
访问仍然在边界内。这就是“边界检查”,从紧接着cmp
之后的条件跳转到无条件调用 CORINFO_HELP_RNGCHKFAIL
的代码可以看出,这是一个在尝试越界时抛出 IndexOutOfRangeException
的辅助函数。
每次发布时,JIT都会在可以证明它们是多余的时移除更多的边界检查。在.NET 9中,我最喜欢的这类改进之一就在我的收藏夹里,因为过去我总是期望优化“自然而然”地发生,但由于各种原因它并没有,现在它确实发生了(它也出现在大量真实代码中,这就是为什么我会遇到它)。
在这个基准测试中,函数接收一个偏移量和一段span,它的任务是计算从偏移量到span末尾的所有数字的和。
// dotnet run -c Release -f net8.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
[Benchmark]
[Arguments(3)]
public int Test() => M(0, "1234567890abcdefghijklmnopqrstuvwxyz");
[MethodImpl(MethodImplOptions.NoInlining)]
public static int M(int i, ReadOnlySpan<char> src)
{
int sum = 0;
while (true)
{
if ((uint)i >= src.Length)
{
break;
}
sum += src[i++];
}
return sum;
}
}
通过将i
强制转换为uint
作为与src.Length
的比较的一部分,JIT知道在用i
索引src
时它是在范围内的,因为如果i
是负数,强制转换为uint
会使它大于int.MaxValue
,从而也大于src.Length
(src.Length
不可能大于int.MaxValue
)。.NET 8的汇编代码显示了边界检查已被省略(注意没有 CORINFO_HELP_RNGCHKFAIL
):
; Tests.M(Int32, System.ReadOnlySpan`1<Char>)
push rbp
mov rbp,rsp
xor eax,eax
M01_L00:
cmp edi,edx
jae short M01_L01
lea ecx,[rdi+1]
mov edi,edi
movzx edi,word ptr [rsi+rdi*2]
add eax,edi
mov edi,ecx
jmp short M01_L00
M01_L01:
pop rbp
ret
; Total bytes of code 27
但这是一种相当笨拙的写法,更自然的方式是将这个检查作为循环条件的一部分:
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
[Benchmark]
[Arguments(3)]
public int Test() => M(0, "1234567890abcdefghijklmnopqrstuvwxyz");
[MethodImpl(MethodImplOptions.NoInlining)]
public static int M(int i, ReadOnlySpan<char> src)
{
int sum = 0;
for (; (uint)i < src.Length; i++)
{
sum += src[i];
}
return sum;
}
}
不幸的是,由于我在这里对代码进行了清理,使其更加规范,JIT在.NET 8中无法看到边界检查可以省略……注意结尾的 CORINFO_HELP_RNGCHKFAIL
:
; Tests.M(Int32, System.ReadOnlySpan`1<Char>)
push rbp
mov rbp,rsp
xor eax,eax
cmp edi,edx
jae short M01_L01
M01_L00:
cmp edi,edx
jae short M01_L02
mov ecx,edi
movzx ecx,word ptr [rsi+rcx*2]
add eax,ecx
inc edi
cmp edi,edx
jb short M01_L00
M01_L01:
pop rbp
ret
M01_L02:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 36
但在.NET 9中,得益于 dotnet/runtime#100777,JIT能够更好地跟踪循环条件所做出的保证,从而在这个变体上也成功省略了边界检查。
; Tests.M(Int32, System.ReadOnlySpan`1<Char>)
push rbp
mov rbp,rsp
xor eax,eax
cmp edi,edx
jae short M01_L01
mov ecx,edi
M01_L00:
movzx edi,word ptr [rsi+rcx*2]
add eax,edi
inc ecx
cmp ecx,edx
jb short M01_L00
M01_L01:
pop rbp
ret
; Total
### Arm64
使.NET在Arm上变得出色且快速是一项关键的多年投资。您可以在[.NET 5中的Arm64性能改进](https://devblogs.microsoft.com/dotnet/Arm64-performance-in-net-5/)、[.NET 7中的Arm64性能改进](https://devblogs.microsoft.com/dotnet/Arm64-performance-improvements-in-dotnet-7/)以及[.NET 8中的Arm64性能改进](https://devblogs.microsoft.com/dotnet/this-Arm64-performance-in-dotnet-8/)中了解更多相关信息。在.NET 9中,这些改进还在持续进行。以下是一些例子:
+ **更好的屏障**。[dotnet/runtime#91553](https://github.com/dotnet/runtime/pull/91553)通过使用`stlur`(存储-释放寄存器)指令来实现volatile写入,而不是`dmb`(数据内存屏障)/`str`(存储)指令对(`stlur`通常更便宜)。类似地,[dotnet/runtime#101359](https://github.com/dotnet/runtime/pull/101359)在处理`float`类型的volatile读取和写入时消除了完整的内存屏障。例如,之前可能产生`ldr`(寄存器加载)/`dmb`对的操作,现在可能产生`ldar`(加载-获取寄存器)/`fmov`(浮点数移动)对。
+ **更好的开关**。根据`switch`语句的形状,C#编译器可能会生成多种IL模式,其中之一是使用`switch` IL指令。通常,对于`switch` IL指令,JIT会生成跳转表,但对于某些形式,它有一个优化,可以改用位测试。到目前为止,这个优化仅存在于x86/64上,使用`bt`(位测试)指令。现在,随着[dotnet/runtime#91811](https://github.com/dotnet/runtime/pull/91811),它也适用于Arm,使用`tbz`(测试位并零分支)指令。
+ **更好的条件判断**。Arm有包含分支的条件的指令,但不需要实际的分支,例如[.NET 8中的性能改进](https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/)中提到的`csel`(条件选择)指令,它“根据条件从两个寄存器中的一个选择一个值”。另一个这样的指令是`csinc`(条件选择增量),它根据条件从两个寄存器中的一个选择值或另一个寄存器的值加一。[dotnet/runtime#91262](https://github.com/dotnet/runtime/pull/91262)由[@c272](https://github.com/c272)实现,使得JIT可以利用`csinc`,因此像`x = condition ? x + 1 : y;`这样的语句可以编译成`csinc`而不是分支结构。[dotnet/runtime#92810](https://github.com/dotnet/runtime/pull/92810)还改进了JIT为某些`SequenceEqual`操作(例如`"hello, there"u8.SequenceEqual(spanOfBytes)`)生成的自定义比较操作,使其能够使用`ccmp`(条件比较)。
+ **更好的乘法**。Arm有单条指令代表执行乘法后跟加法、减法或取反。[@c272](https://github.com/c272)的[dotnet/runtime#91886](https://github.com/dotnet/runtime/pull/91886)找到这样的连续乘法后跟一个操作的序列,并将其合并到使用单个组合指令。
+ **更好的加载**。Arm有将值从内存加载到单个寄存器的指令,但它也有将多个值加载到多个寄存器的指令。当JIT生成自定义内存复制(例如`byteArray.AsSpan(0, 32).SequenceEqual(otherByteArray)`)时,它可能发出多个`ldr`指令来加载值到寄存器。[dotnet/runtime#92704](https://github.com/dotnet/runtime/pull/92704)允许将这些指令对合并到`ldp`(寄存器对加载)指令中,以便将两个值加载到两个寄存器。
### ARM SVE
推出一个新的指令集是一项重大且艰巨的任务。我以前提到过,为撰写这类“.NET X性能提升”的文章,我有一套准备流程,包括在整个年份中,我会维护一个可能要讨论的PR(Pull Request,拉取请求)清单。仅就“SVE”而言,我就有超过200个链接。我不会用这样的清单来烦扰你;如果你感兴趣,可以搜索[SVE PRs](https://github.com/dotnet/runtime/pulls?q=is%3Apr+SVE+merged%3A2023-10-01..2024-08-31+),其中包括[@a74nh](https://github.com/a74nh)的PR、[@ebepho](https://github.com/ebepho)的PR、[@mikabl-arm](https://github.com/mikabl-arm)的PR、[@snickolls-arm](https://github.com/snickolls-arm)的PR和[@SwapnilGaikwad](https://github.com/SwapnilGaikwad)的PR。但是,我们仍然可以简单讨论一下SVE是什么,以及它对.NET意味着什么。
单指令多数据(SIMD)是一种并行处理方式,其中一条指令同时执行多个数据项的相同操作,而不是仅对单个数据项进行操作。例如,x86/64上的`add`指令可以同时相加一对32位整数,而Intel的SSE2指令集中的`paddd`(添加打包双字整数)指令则操作于每个可以存储四个32位整数值的`xmm`寄存器。多年来,许多这样的指令被添加到不同的硬件平台上,这些指令集合被称为指令集架构(ISA),其中ISA定义了指令是什么,它们与哪些寄存器交互,如何访问内存等等。即使你对这些内容不太熟悉,你也可能听说过这些ISA的名称,比如Intel的SSE(单指令多数据扩展)和AVX(高级向量扩展),或者Arm的Advanced SIMD(也称为Neon)。一般来说,这些ISA中的指令都操作于固定数量的固定大小的值,例如前面提到的`paddd`每次仅操作128位,不多也不少。存在不同的指令用于每次操作256位或512位。
SVE,即“可伸缩向量扩展”,是Arm的一个不同类型的ISA。SVE的指令不操作于固定大小。相反,规范允许它们操作于从128位到2048位的各种大小,而具体的硬件可以选择使用哪种大小(允许的大小是128的倍数,并且SVE 2进一步限制为2的幂)。因此,相同的汇编代码在使用这些指令时,可能在一个硬件上每次操作128位,而在另一个硬件上每次操作256位。
这样的ISA对.NET,尤其是JIT(即时编译器)有多方面的影响。JIT需要能够与ISA协同工作,理解相关的寄存器并进行寄存器分配,还需要学会编码和发出指令等等。JIT需要知道何时何地适合使用这些指令,这样在将IL(中间语言)编译成汇编语言时,如果运行在支持SVE的机器上,JIT可能会选择SVE指令用于生成的汇编代码。JIT还需要学会如何用用户代码表示这些数据,即这些向量。所有这些都需要大量的工作,尤其是考虑到有数千个操作需要表示。而硬件内嵌函数使这项工作更加繁重。
[硬件内嵌函数](https://devblogs.microsoft.com/dotnet/hardware-intrinsics-in-net-core/)是.NET的一个特性,其中每个这样的指令都显示为.NET方法,例如`Sse2.Add`,JIT会将使用该方法的调用转换为底层对应的指令。如果你查看[dotnet/runtime](https://github.com/dotnet/runtime/blob/30eaaf2415b8facf0ef3180c005e27132e334611/src/libraries/System.Private.CoreLib/src/System/Runtime/Intrinsics/Arm/Sve.cs)中的[Sve.cs](https://github.com/dotnet/runtime/blob/30eaaf2415b8facf0ef3180c005e27132e334611/src/libraries/System.Private.CoreLib/src/System/Runtime/Intrinsics/Arm/Sve.cs),你会看到`System.Runtime.Intrinsics.Arm.Sve`类型,它已经公开了超过1400个方法(这个数字不是笔误)。
如果你打开这个文件,有两个有趣的地方值得注意(除了它的长度之外):
1. **`Vector<T>`的使用。** .NET对SIMD的探索始于2014年,当时引入了`Vector<T>`类型。[《JIT终于提议了JIT和SIMD的婚姻》](https://devblogs.microsoft.com/dotnet/the-jit-finally-proposed-jit-and-simd-are-getting-married/)一文中提到。`Vector<T>`表示一个单一的`T`数值类型的向量(列表)。为了提供一个跨平台的表示,由于不同的平台支持不同的向量宽度,`Vector<T>`被定义为可变的宽度,例如在支持AVX2的x86/x64硬件上,`Vector<T>`可能是256位宽,而在支持Neon的Arm机器上,`Vector<T>`可能是128位宽。如果硬件同时支持128位和256位,`Vector<T>`会映射到更大的。自从`Vector<T>`的引入以来,已经引入了各种固定宽度的向量类型,如`Vector64<T>`、`Vector128<T>`、`Vector256<T>`和`Vector512<T>`,而大多数其他ISA的硬件内嵌函数都是用这些固定宽度的向量类型来表示的,因为这些指令本身是固定宽度的。但是SVE不是;它的指令可能在这里是128位,在那里是512位,因此无法在`Sve`定义中使用这些相同的固定宽度向量类型……但使用可变的`Vector<T>`是非常有意义的。旧的就是新的。
2. **`Sve`类被标记为 `[Experimental]`。** .NET 8和C# 12引入了`[Experimental]`属性,目的是用来指示一个在稳定程序集之外的某些功能还不稳定,未来可能会发生变化。如果代码尝试使用这样的成员,默认情况下C#编译器会发出错误,告诉开发者他们正在使用可能会破坏的东西。但只要开发者愿意接受这种破坏性的变化风险,他们就可以抑制这个错误。设计和启用SVE支持是一项巨大的、跨多年的努力,尽管支持是功能性的,并且鼓励人们尝试,但它还没有足够成熟,让我们完全有信心它的形状不会需要进化(对于.NET 9,它也限制在具有128位向量宽度的硬件上,但随后的版本将取消这个限制)。因此,标记为 `[Experimental]`。
### AVX10.1
尽管SVE(Scalable Vector Extension)的工作量很大,但它并不是.NET 9中唯一可用的新指令集架构(ISA)。这主要归功于[@Ruihan-Yin](https://github.com/Ruihan-Yin)的[dotnet/runtime#99784](https://github.com/dotnet/runtime/pull/99784)和[@khushal1996](https://github.com/khushal1996)的[dotnet/runtime#101938](https://github.com/dotnet/runtime/pull/101938)。.NET 9现在也支持AVX10.1(AVX10版本1)。AVX10.1提供了AVX512所提供的一切,包括所有基础支持、更新的编码方式、支持嵌入式广播、掩码等功能,但它仅需要硬件支持256位(而AVX512需要512位支持,其中512位是可选的),并且在更小的增量上进行实现(AVX512有多个指令集,如“F”、“DQ”、“Vbmi”等)。这一点在.NET API中也得到了体现,您可以通过检查`Avx10v1.IsSupported`以及`Avx10v1.V512.IsSupported`来了解情况,这两个属性都控制着超过500个可供使用的新API。(请注意,在撰写本文时,实际上市场上还没有支持AVX10.1的芯片,但预计在可预见的未来将会有所出现。)
### AVX512
关于指令集架构(ISA),值得提及的是AVX512。.NET 8增加了对AVX512的广泛支持,包括在JIT编译器和库中对其的使用。这两者在.NET 9中进一步得到改进。稍后我们将详细讨论在库中更有效使用AVX512的场合。现在,这里有一些JIT特定的改进。
JIT需要生成代码来完成的一项任务就是零初始化,例如,默认情况下,方法中的所有局部变量需要被设置为0,即使使用了 `[SkipLocalsInit]` 属性,引用变量仍然需要被零初始化(否则,当垃圾收集器遍历所有局部变量以查找对对象的引用来决定哪些不再被引用时,它可能会将这些引用看作是内存中随机存在的垃圾数据,从而导致错误的决策)。这样的局部变量零初始化是每次方法调用都会发生的开销,因此显然使其尽可能高效是非常有价值的。而不是用单条指令来逐字零初始化,如果当前硬件支持适当的SIMD指令,JIT可以发出使用这些指令的代码,从而一次可以零初始化更多的数据。通过[dotnet/runtime#91166](https://github.com/dotnet/runtime/pull/91166),JIT现在可以在可用的情况下使用AVX512指令来每次指令零初始化512位,而不是仅使用其他ISA时“仅仅”256位或128位。
下面是一个需要零初始化256字节的基准测试示例:
```csharp
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public unsafe class Tests
{
[Benchmark]
public void Sum()
{
Bytes values;
Nop(&values);
}
[SkipLocalsInit]
[Benchmark]
public void SumSkipLocalsInit()
{
Bytes values;
Nop(&values);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Nop(Bytes* value) { }
[StructLayout(LayoutKind.Sequential, Size = 256)]
private struct Bytes { }
}
在.NET 8上的Sum
方法的汇编代码如下:
; Tests.Sum()
sub rsp,108
xor eax,eax
mov [rsp+8],rax
vxorps xmm8,xmm8,xmm8
mov rax,0FFFFFFFFFFFFFF10
M00_L00:
vmovdqa xmmword ptr [rsp+rax+100],xmm8
vmovdqa xmmword ptr [rsp+rax+110],xmm8
vmovdqa xmmword ptr [rsp+rax+120],xmm8
add rax,30
jne short M00_L00
mov [rsp+100],rax
lea rdi,[rsp+8]
call qword ptr [7F6B56B85CB0]; Tests.Nop(Bytes*)
nop
add rsp,108
ret
; Total bytes of code 90
这是一个支持AVX512硬件的机器上的汇编代码,我们可以看到零初始化是通过一个循环(M00_L00
通过到jne
跳转回它)来完成的,由于仅使用256位指令,JIT的启发式认为这太大而不能完全展开。现在,这里是.NET 9的代码:
; Tests.Sum()
sub rsp,108
xor eax,eax
mov [rsp+8],rax
vxorps xmm8,xmm8,xmm8
vmovdqu32 [rsp+10],zmm8
vmovdqu32 [rsp+50],zmm8
vmovdqu32 [rsp+90],zmm8
vmovdqa xmmword ptr [rsp+0D0],xmm8
vmovdqa xmmword ptr [rsp+0E0],xmm8
vmovdqa xmmword ptr [rsp+0F0],xmm8
mov [rsp+100],rax
lea rdi,[rsp+8]
call qword ptr [7F4D3D3A44C8]; Tests.Nop(Bytes*)
nop
add rsp,108
ret
; Total bytes of code 107
现在没有循环了,因为vmovdqu32
(移动无序打包的双字整数值)可以每次操作零初始化两倍的数据量(64字节),因此零初始化可以在较少的指令内完成,这仍然被认为是一个合理的数量。
零初始化还出现在其他地方,例如在初始化结构体时。这些场合之前也适当地使用了SIMD指令,例如:
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
[Benchmark]
public MyStruct Init() => new();
public struct MyStruct
{
public Int128 A, B, C, D;
}
}
在.NET 8上生成的汇编代码如下:
; Tests.Init()
vzeroupper
vxorps ymm0,ymm0,ymm0
vmovdqu32 [rsi],zmm0
mov rax,rsi
ret
; Total bytes of code 17
但是,如果我们将MyStruct
调整为一个包含引用类型字段的任何位置(例如,在结构体的开头添加public string Oops;
),初始化就会离开这条优化的路径,在.NET 8上我们会得到如下初始化代码:
; Tests.Init()
xor eax,eax
mov [rsi],rax
mov [rsi+8],rax
mov [rsi+10],rax
mov [rsi+18],rax
mov [rsi+20],rax
mov [rsi+28],rax
mov [rsi+30],rax
mov [rsi+38],rax
mov [rsi+40],rax
mov [rsi+48],rax
mov rax,rsi
ret
; Total bytes of code 45
这是由于对齐要求提供了必要的原子性保证。但是,而不是完全放弃,dotnet/runtime#102132 允许SIMD零初始化用于不包含GC引用的连续部分,因此现在在.NET 9上我们得到如下代码:
; Tests.Init()
xor eax,eax
mov [rsi],rax
vxorps xmm0,xmm0,xmm0
vmovdqu32 [rsi+8],zmm0
mov [rsi+48],rax
mov rax,rsi
ret
; Total bytes of code 27
这种优化并不是专门针对AVX512的,但它包括了当可用时使用AVX512指令的能力。(dotnet/runtime#99140 为Arm64提供了类似的支持。)
其他优化改进了JIT在生成代码时选择AVX512指令的能力。一个很好的例子是Ruihan-Yin 的dotnet/runtime#91227,它利用了酷炫的vpternlog
(位三态逻辑)指令。想象一下你有三个bool
(a
、b
和c
),并且你想要对它们执行一系列布尔操作,例如a ? (b ^ c) : (b & c)
。如果你天真地编译这个表达式,你可能会得到分支。我们可以通过将a
分配到两边来实现无分支,例如(a & (b ^ c)) | (!a & (b & c))
,但是现在我们从一个分支和一个布尔操作变成了六个布尔操作。如果我们可以用一个单条指令并且对向量中的所有通道同时应用这个操作,那不是很好吗?那样的话,它就可以在一次SIMD操作中将多个值应用上。那不是酷毙了吗?vpternlog
指令就允许这样做。试试这个:
// dotnet run -c Release -f net9.0
internal class Program
{
private static bool Exp(bool a, bool b, bool c) => (a & (b ^ c)) | (!a & b & c);
private static void Main()
{
Console.WriteLine("a b c result");
Console.WriteLine("------------");
int control = 0;
foreach (var (a, b, c, result) in from a in new[] { true, false }
from b in new[] { true, false }
from c in new[] { true, false }
select (a, b, c, Exp(a, b, c)))
{
Console.WriteLine($"{Convert.ToInt32(a)} {Convert.ToInt32(b)} {Convert.ToInt32(c)} {Convert.ToInt32(result)}");
control = control << 1 | Convert.ToInt32(result);
}
Console.WriteLine("------------");
Console.WriteLine($"Control: {control:b8} == 0x{control:X2}");
}
}
这里我们将布尔操作放入了一个Exp
函数中,然后对这个函数的所有8种可能的输入(每个三个bool
各有两个可能的值)进行调用。然后我们打印出“真值表”,它详细说明了每个可能的输入的布尔输出。对于这个特定的布尔表达式,它会输出如下“真值表”:
a b c result
------------
1 1 1 0
1 1 0 1
1 0 1 1
1 0 0 0
0 1 1 1
0 1 0 0
0 0 1 0
0 0 0 0
------------
然后我们将最后一列的结果作为一个二进制数处理:
Control: 01101000 == 0x68
所以值是0 1 1 0 1 0 0 0
,我们将其读作二进制0b01101000
,即0x68
。这个字节被用作“控制码”传递给vpternlog
指令,以编码选择哪个可能的256个真值表。当然,JIT不会像上面那样枚举;实际上,有一个更有效的方法来计算控制码,即对特定的字节值执行相同的序列操作,例如这个:
// dotnet run -c Release -f net9.0
Console.WriteLine($"0x{Exp(0xF0, 0xCC, 0xAA):X2}");
static int Exp(int a, int b, int c)
### 向量化改进
除了教导JIT关于全新架构的改进外,还有一些大量改进只是帮助JIT更好地一般性地使用SIMD。
我最喜欢的改进之一是[dotnet/runtime#92852](https://github.com/dotnet/runtime/pull/92852),它将连续的存储操作合并为一个单独的操作。考虑要实现一个类似`bool.TryFormat`的方法:
```csharp
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private bool _value;
private char[] _destination = new char[10];
[Benchmark]
public bool TryFormat() => TryFormat(_destination, out _);
private bool TryFormat(char[] destination, out int charsWritten)
{
if (_value)
{
if (destination.Length >= 4)
{
destination[0] = 'T';
destination[1] = 'r';
destination[2] = 'u';
destination[3] = 'e';
charsWritten = 4;
return true;
}
}
else
{
if (destination.Length >= 5)
{
destination[0] = 'F';
destination[1] = 'a';
destination[2] = 'l';
destination[3] = 's';
destination[4] = 'e';
charsWritten = 5;
return true;
}
}
charsWritten = 0;
return false;
}
}
这个示例很简单:我们正在逐个写出每个值。这有点遗憾,因为我们天真地使用了多个mov
来逐个写入每个字符,而实际上我们可以将这些值打包到单个值中以进行写入。实际上,这就是bool.TryFormat
的实际做法。以下是它今天对true
情况的处理:
if (destination.Length > 3)
{
ulong true_val = BitConverter.IsLittleEndian ? 0x65007500720054ul : 0x54007200750065ul; // "True"
MemoryMarshal.Write(MemoryMarshal.AsBytes(destination), in true_val);
charsWritten = 4;
return true;
}
开发者手动完成了合并写入的工作,例如:
ulong true_val = (((ulong)'e' << 48) | ((ulong)'u' << 32) | ((ulong)'r' << 16) | (ulong)'T')
Assert.Equal(0x65007500720054ul, true_val);
以便能够执行单个写入而不是四个单独的写入。对于这个特殊情况,现在在.NET 9中,JIT可以自动执行这个合并,因此开发者不需要这样做。开发者只需编写自然编写的代码,JIT就会优化其输出(注意下面的mov rax, 65007500720054
指令,加载我们上面手动计算出的相同值)。
// .NET 8
; Tests.TryFormat(Char[], Int32 ByRef)
push rbp
mov rbp,rsp
cmp byte ptr [rdi+10],0
jne short M01_L01
mov ecx,[rsi+8]
cmp ecx,5
jl short M01_L00
mov word ptr [rsi+10],46
mov word ptr [rsi+12],61
mov word ptr [rsi+14],6C
mov word ptr [rsi+16],73
mov word ptr [rsi+18],65
mov dword ptr [rdx],5
mov eax,1
pop rbp
ret
M01_L00:
xor eax,eax
mov [rdx],eax
pop rbp
ret
M01_L01:
mov ecx,[rsi+8]
cmp ecx,4
jl short M01_L00
mov rax,65007500720054
mov [rsi+10],rax
mov dword ptr [rdx],4
mov eax,1
pop rbp
ret
; Total bytes of code 112
// .NET 9
; Tests.TryFormat(Char[], Int32 ByRef)
push rbp
mov rbp,rsp
cmp byte ptr [rdi+10],0
jne short M01_L00
mov ecx,[rsi+8]
cmp ecx,5
jl short M01_L01
mov rax,73006C00610046
mov [rsi+10],rax
mov word ptr [rsi+18],65
mov dword ptr [rdx],5
mov eax,1
pop rbp
ret
M01_L00:
mov ecx,[rsi+8]
cmp ecx,4
jl short M01_L01
mov rax,65007500720054
mov [rsi+10],rax
mov dword ptr [rdx],4
mov eax,1
pop rbp
ret
M01_L01:
xor eax,eax
mov [rdx],eax
pop rbp
ret
; Total bytes of code 92
dotnet/runtime#92939进一步改进了这一点,通过启用更长的序列使用SIMD指令进行合并。
当然,你可能会想,为什么bool.TryFormat
没有恢复使用更简单的代码?不幸的答案是,这个优化目前只适用于数组目标,而不是span目标。这是因为执行这类写入存在对齐要求,而JIT可以对数组的对齐进行某些假设,但不能对span进行同样的假设,因为span可能在未对齐的边界表示其他东西的切片。这就是为什么在这个特定情况下,数组比span更好;通常span与数组一样好,或者更好。但我希望未来会得到改进。
另一个不错的改进是dotnet/runtime#86811由@BladeWise提供,它为byte
和sbyte
的两个向量的乘法添加了SIMD支持。之前这会导致退回到一个非常慢的软件实现,与真正的SIMD操作相比。现在,代码要快得多,且更紧凑。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.Intrinsics;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private Vector128<byte> _v1 = Vector128.Create((byte)0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15);
[Benchmark]
public Vector128<byte> Square() => _v1 * _v1;
}
方法 | 运行时 | 平均值 | 比率 |
---|---|---|---|
平方 | .NET 8.0 | 15.4731 纳秒 | 1.000 |
平方 | .NET 9.0 | 0.0284 纳秒 | 0.002 |
dotnet/runtime#103555(x64,当AVX512不可用时)和dotnet/runtime#104177(Arm64)也改进了向量乘法,这次是针对long
/ulong
。这可以通过一个简单的微基准测试看到(因为我在支持AVX512的机器上运行,所以基准测试明确禁用了它):
// dotnet run -c Release -f net8.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Running;
using System.Runtime.Intrinsics;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, DefaultConfig.Instance
.AddJob(Job.Default.WithId(".NET 8").WithRuntime(CoreRuntime.Core80).WithEnvironmentVariable("DOTNET_EnableAVX512F", "0").AsBaseline())
.AddJob(Job.Default.WithId(".NET 9").WithRuntime(CoreRuntime.Core90).WithEnvironmentVariable("DOTNET_EnableAVX512F", "0")));
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private Vector256<long> _a = Vector256.Create(1, 2, 3, 4);
private Vector256<long> _b = Vector256.Create(5, 6, 7, 8);
[Benchmark]
public Vector256<long> Multiply() => Vector256.Multiply(_a, _b);
}
方法 | 运行时 | 平均值 | 比率 |
---|---|---|---|
乘法 | .NET 8.0 | 9.5448 纳秒 | 1.00 |
乘法 | .NET 9.0 | 0.3868 纳秒 | 0.04 |
在更高级的基准测试中,这也显而易见,例如在这个对XxHash128
的基准测试中,这是一个大量使用向量乘法的实现。
// 在csproj中添加一个<PackageReference Include="System.IO.Hashing" Version="8.0.0" />。
// dotnet run -c Release -f net8.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Environments;
using System.IO.Hashing;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, DefaultConfig.Instance
.AddJob(Job.Default.WithId(".NET 8").WithRuntime(CoreRuntime.Core80).WithEnvironmentVariable("DOTNET_EnableAVX512F", "0").AsBaseline())
.AddJob(Job.Default.WithId(".NET 9").WithRuntime(CoreRuntime.Core90).WithEnvironmentVariable("DOTNET_EnableAVX512F", "0")));
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private byte[] _data;
[GlobalSetup]
public void Setup()
{
_data = new byte[1024 * 1024];
new Random(42).NextBytes(_data);
}
[Benchmark]
public UInt128 Hash() => XxHash128.HashToUInt128(_data);
}
这个基准测试引用了System.IO.Hashing nuget包。注意,我们显式添加了对8.0.0版本的引用;这意味着即使在运行.NET 9时,我们也使用.NET 8版本的哈希代码,但它仍然显著更快,因为这些运行时改进。
方法 | 运行时 | 平均值 | 比率 |
---|---|---|---|
哈希 | .NET 8.0 | 40.49 微秒 | 1.00 |
哈希 | .NET 9.0 | 26.40 微秒 | 0.65 |
还有一些其他值得注意的例子:
- 改进的SIMD比较。 dotnet/runtime#104944和dotnet/runtime#104215改进了如何处理向量比较。
- 改进的ConditionalSelect。 dotnet/runtime#104092由@ezhevita改进了当条件是一个常量集合时生成的代码。
- 更好的常量处理。 某些操作仅在其中一个参数是常量时才得到优化,否则会退回到一个更慢的软件模拟实现。dotnet/runtime#102827使这类指令(如用于洗牌的指令)能够在非常量参数成为其他优化(如内联)的一部分时继续被视为优化操作。
- 解除其他优化的限制。 一些更改本身并不引入优化,但通过进行微调使其他优化能够更好地发挥作用。dotnet/runtime#104517分解了一些位运算(例如,用“与”和“非”替换统一的“异或非”操作),这反过来使得其他现有的优化(如公共子表达式消除(CSE))能够更频繁地发挥作用。dotnet/runtime#104214规范化了各种取反模式,这也同样使得其他优化能够在更多的地方应用。
分支优化
就像JIT尝试省略冗余的边界检查一样,当它能证明边界检查是不必要的时候,它也会对分支进行类似的优化。在.NET 9中,处理分支之间关系的能力得到了提升。考虑以下基准测试:
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
[Benchmark]
[Arguments(50)]
public void Test(int x)
{
if (x > 100)
{
Helper(x);
}
}
private void Helper(int x)
{
if (x > 10)
{
Console.WriteLine("Hello!");
}
}
}
Helper
函数很简单,足以被内联,在.NET 8中,我们得到的汇编代码如下:
; Tests.Test(Int32)
push rbp
mov rbp,rsp
cmp esi,64
jg short M00_L01
M00_L00:
pop rbp
ret
M00_L01:
cmp esi,0A
jle short M00_L00
mov rdi,7F35E44C7E18
pop rbp
jmp qword ptr [7F35E914C7C8]
; Total bytes of code 33
在原始代码中,内联的Helper
函数中的分支完全是多余的:只有当x
大于100时,我们才会进入这个分支,所以它肯定大于10,但在汇编代码中,我们还是看到了两个比较(注意两个cmp
指令)。在.NET 9中,多亏了dotnet/runtime#95234,它增强了JIT在处理两个范围之间的关系以及一个是否被另一个暗示的能力,我们得到了这样的代码:
; Tests.Test(Int32)
cmp esi,64
jg short M00_L00
ret
M00_L00:
mov rdi,7F81C120EE20
jmp qword ptr [7F8148626628]
; Total bytes of code 22
现在只需要一个外层的cmp
。
对于否定情况也是如此:如果我们把x > 10
改为x < 10
,我们得到这样的代码:
// .NET 8
; Tests.Test(Int32)
push rbp
mov rbp,rsp
cmp esi,64
jg short M00_L01
M00_L00:
pop rbp
ret
M00_L01:
cmp esi,0A
jge short M00_L00
mov rdi,7F6138428DE0
pop rbp
jmp qword ptr [7FA1DDD4C7C8]
; Total bytes of code 33
// .NET 9
; Tests.Test(Int32)
ret
; Total bytes of code 1
与x > 10
的情况类似,在.NET 8中,JIT仍然保留了两个分支。但在.NET 9中,它认识到不仅内部的条件是多余的,而且它是以一种总会导致假的方式多余的,这允许它删除那个if
语句的主体,使整个方法成为一个空操作。dotnet/runtime#94689通过启用JIT对“跨块局部断言传播”的支持,实现了这种类型的信息流。
另一个消除了一些冗余分支的PR是dotnet/runtime#94563,它将值编号(一种用于消除冗余表达式的技术,通过为每个独特的表达式分配唯一的标识符)的信息引入到PHI(JIT代码中间表示中的节点类型,有助于根据控制流确定变量的值)的构建过程中。考虑以下基准测试:
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public unsafe class Tests
{
[Benchmark]
[Arguments(50)]
public void Test(int x)
{
byte[] data = new byte[128];
fixed (byte* ptr = data)
{
Nop(ptr);
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Nop(byte* ptr) { }
}
这是一个分配byte[]
并使用指针来与一个需要指针的方法交互的例子。C#对数组使用fixed
的关键字的规定是:“如果数组表达式为null
或者数组元素个数为0,初始化器会计算出一个等于零的地址”,因此,如果你查看这段代码的IL,你会看到它检查了长度并将指针设置为0。你也可以在Span<T>
的GetPinnableReference
实现中看到这种相同的行为:
public ref T GetPinnableReference()
{
ref T ret = ref Unsafe.NullRef<T>();
if (_length != 0) ret = ref _reference;
return ref ret;
}
因此,在Tests.Test
测试中实际上存在一个额外的分支,但在这种特定情况下,这个分支也是多余的,因为我们非常清楚地知道数组的长度不为0。在.NET 8中,我们仍然会看到这个分支:
; Tests.Test(Int32)
push rbp
sub rsp,10
lea rbp,[rsp+10]
xor eax,eax
mov [rbp-8],rax
mov rdi,offset MT_System.Byte[]
mov esi,80
call CORINFO_HELP_NEWARR_1_VC
mov [rbp-8],rax
mov rdi,[rbp-8]
cmp dword ptr [rdi+8],0
je short M00_L01
mov rdi,[rbp-8]
cmp dword ptr [rdi+8],0
jbe short M00_L02
mov rdi,[rbp-8]
add rdi,10
M00_L00:
call qword ptr [7F3F99B45C98]; Tests.Nop(Byte*)
xor eax,eax
mov [rbp-8],rax
add rsp,10
pop rbp
ret
M00_L01:
xor edi,edi
jmp short M00_L00
M00_L02:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 96
但在.NET 9中,这个分支(实际上,多个冗余分支)被移除了:
; Tests.Test(Int32)
push rax
xor eax,eax
mov [rsp],rax
mov rdi,offset MT_System.Byte[]
mov esi,80
call CORINFO_HELP_NEWARR_1_VC
mov [rsp],rax
add rax,10
mov rdi,rax
call qword ptr [7F22DAC844C8]; Tests.Nop(Byte*)
xor eax,eax
mov [rsp],rax
add rsp,8
ret
; Total bytes of code 55
dotnet/runtime#87656是JIT优化库中的另一个很好的例子和新增功能。正如之前讨论的,分支有与之相关的成本。硬件的分支预测器通常能很好地减轻这些成本的大部分,但仍然有一些,即使它能在常见情况下完全缓解,分支预测失败也可能相对非常昂贵。因此,最小化分支可以非常有帮助,如果什么都不做,将基于分支的操作转换为无分支操作会导致更一致和可预测的吞吐量,因为它不再那么依赖于被处理的数据的性质。考虑以下用于确定一个字符是否是特定空格字符子集的函数:
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
[Benchmark]
[Arguments('s')]
public bool IsJsonWhitespace(int c)
{
if (c == ' ' || c == '\t' || c == '\r' || c == '\n')
{
return true;
}
return false;
}
}
在.NET 8中,我们得到你可能预期的结果,一系列cmp
指令后跟着每个字符的分支:
; Tests.IsJsonWhitespace(Int32)
push rbp
mov rbp,rsp
cmp esi,20
je short M00_L00
cmp esi,9
je short M00_L00
cmp esi,0D
je short M00_L00
cmp esi,0A
je short M00_L00
xor eax,eax
pop rbp
ret
M00_L00:
mov eax,1
pop rbp
ret
; Total bytes of code 35
但在.NET 9中,我们得到了这样的代码:
; Tests.IsJsonWhitespace(Int32)
push rbp
mov rbp,rsp
cmp esi,20
ja short M00_L00
mov eax,0FFFFD9FF
bt rax,rsi
jae short M00_L01
M00_L00:
xor eax,eax
pop rbp
ret
M00_L01:
mov eax,1
pop rbp
ret
; Total bytes of code 31
现在使用了一个bt
指令(位测试)针对一个为每个要测试的字符设置一个位的模式,将大多数分支合并为仅此一个。
不幸的是,这也凸显了这样的优化可能会偏离其最佳路径,此时优化将不会生效。在这种情况下,有几种方式可以使它偏离最佳路径。最明显的是如果值太多或者分布太散,以至于无法适应32位或64位的位掩码。更有趣的是,如果你改为使用C#的模式匹配(例如c is ' ' or '\t' or '\r' or '\n'
),这个优化也不会触发。为什么?因为C#编译器本身也在尝试优化,它生成的IL代码与这种优化所期望的不同。我预计未来这会变得更好,但这是一个很好的提醒,这些类型的优化在它们使任意代码变得更好时很有用,但如果你正在针对优化的具体性质编写代码并依赖它发生,你确实需要密切关注。
在dotnet/runtime#93521中添加了一个相关的优化。考虑以下函数,它用于检查一个字符是否是十六进制小写字母:
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
[Benchmark]
[Arguments('s')]
public bool IsHexLower(char c)
{
if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))
{
return true;
}
return false;
}
}
在.NET 8中,我们得到一系列的比较,每个字符一个分支:
; Tests.IsHexLower(Char)
push rbp
mov rbp,rsp
movzx eax,si
cmp eax,30
jl short M00_L00
cmp eax,39
jle short M00_L02
M00_L00:
cmp eax,61
jl short M00_L01
cmp eax,66
jle short M00_L02
M00_L01:
xor eax,eax
pop rbp
ret
M00_L02:
mov eax,1
pop rbp
ret
; Total bytes of code 38
但在.NET 9中,我们得到了这样的代码:
; Tests.IsHexLower(Char)
push rbp
mov rbp,rsp
movzx eax,si
mov ecx,eax
sub ecx,30
cmp ecx,9
jbe short M00_L00
sub eax,61
cmp eax,5
jbe short M00_L00
xor eax,eax
pop rbp
ret
M00_L00:
mov eax,1
pop rbp
ret
; Total bytes of code 36
JIT实际上将条件重写成了如果我这样写:
(((uint)c - '0') <= ('9' - '0')) || (((uint)c - 'a') <= ('f' - 'a'))
这样做很好,因为它将两个条件分支替换成了两个(更便宜)的减法操作。
写入屏障
.NET垃圾收集器(GC)是一种代际收集器。这意味着它逻辑上将堆分成不同的对象年龄组,其中“代0”(或“gen0”)是存在时间不长的最新对象,“gen2”是存在时间较长的对象,“gen1”位于中间。这种做法基于一个理论(这个理论在实践中通常也适用),即大多数对象最终都会非常短暂地存在,为了某个任务被创建然后很快被丢弃;相反,如果一个对象存在了很长时间,那么它很可能还会继续存在一段时间。通过这样划分对象,GC可以在扫描要收集的对象时减少需要完成的工作量。它可以仅针对gen0对象进行扫描,从而忽略gen1或gen2中的任何内容,使扫描速度更快。至少,目标是这样。但如果它只扫描gen0对象,那么它很容易会误认为gen0对象没有被引用,因为它无法从其他gen0对象中找到对它的引用……但可能有gen1或gen2对象引用了它。这将是个大问题。GC如何解决这个问题,既想要蛋糕又想要吃掉它呢?它与其他运行时协作来跟踪任何可能违反其代际假设的情况。GC维护了一张表(称为“卡片表”),指出较高代际的对象中是否可能包含对较低代际对象的引用,并且每当引用写入可能导致较高代际对较低代际有引用时,这张表就会被更新。然后当GC进行扫描时,它只需要检查表中相关位被设置的高代际对象(该表不跟踪单个对象,而是跟踪对象范围,所以它与Bloom过滤器类似,其中位的缺失意味着肯定没有引用,而位的出现仅意味着可能有引用)。
执行引用写入跟踪和可能更新卡片表的代码被称为GC写入屏障。显然,如果每次写入对象引用时都会执行这段代码,那么你真的、真的、真的希望这段代码是高效的。实际上,存在多种不同的GC写入屏障形式,它们分别针对稍微不同的目的。
标准的GC写入屏障是CORINFO_HELP_ASSIGN_REF
。然而,还有一种名为CORINFO_HELP_CHECKED_ASSIGN_REF
的写入屏障需要做更多的工作。JIT决定使用哪个,当目标可能不在堆上时,它会使用后者,在这种情况下,屏障需要做更多工作来确定这一点。
dotnet/runtime#98166 在某种特定情况下帮助JIT做得更好。如果你有一个值类型的静态字段:
static SomeStruct s_someField;
...
struct SomeStruct
{
public object Obj;
}
运行时通过为该字段关联一个箱来处理这种情况,用于存储该结构体。这样的静态箱始终在堆上,所以如果你执行以下操作:
static void Store(object o) => s_someField.Obj = o;
JIT可以证明可以使用更便宜的未检查写入屏障,并且这个PR教会了它这一点。之前有时JIT可以自己弄清楚,但这个PR确保了这一点。
另一个类似改进来自 dotnet/runtime#97953。这里有一个基于 ConcurrentQueue<T>
的示例,它维护元素数组,每个元素都是一个实际项,带有用于实现正确性的序列号。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private Slot<object>[] _arr = new Slot<object>[1];
private object _obj = new object();
[Benchmark]
public void Test() => Store(_arr, _obj);
private static void Store<T>(Slot<T>[] arr, T o)
{
arr[0].Item = o;
arr[0].SequenceNumber = 1;
}
private struct Slot<T>
{
public T Item;
public int SequenceNumber;
}
}
在这里,我们可以看到在.NET 8中使用了更昂贵的已检查写入屏障,但在.NET 9中,JIT已经认识到可以使用更便宜的未检查写入屏障:
; .NET 8
; Tests.Test()
push rbx
mov rbx,[rdi+8]
mov rsi,[rdi+10]
cmp dword ptr [rbx+8],0
jbe short M00_L00
add rbx,10
mov rdi,rbx
call CORINFO_HELP_CHECKED_ASSIGN_REF
mov dword ptr [rbx+8],1
pop rbx
ret
M00_L00:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 42
; .NET 9
; Tests.Test()
push rbx
mov rbx,[rdi+8]
mov rsi,[rdi+10]
cmp dword ptr [rbx+8],0
jbe short M00_L00
add rbx,10
mov rdi,rbx
call CORINFO_HELP_ASSIGN_REF
mov dword ptr [rbx+8],1
pop rbx
ret
M00_L00:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 42
dotnet/runtime#101761 实际上引入了一种新的写入屏障形式。考虑以下情况:
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private MyStruct _value;
private Wrapper _wrapper = new();
[Benchmark]
public void Store() => _wrapper.Value = _value;
private record struct MyStruct(string a1, string a2, string a3, string a4);
private class Wrapper
{
public MyStruct Value;
}
}
在.NET 8中,每次复制该结构体时,每个字段(由 a1
到 a4
表示)都会分别产生一个写入屏障:
; Tests.Store()
push rax
mov [rsp],rdi
mov rax,[rdi+8]
lea rdi,[rax+8]
mov rsi,[rsp]
add rsi,10
call CORINFO_HELP_ASSIGN_BYREF
call CORINFO_HELP_ASSIGN_BYREF
call CORINFO_HELP_ASSIGN_BYREF
call CORINFO_HELP_ASSIGN_BYREF
nop
add rsp,8
ret
; Total bytes of code 47
现在在.NET 9中,这个PR添加了一个新的批量写入屏障,可以更高效地执行操作。
; Tests.Store()
push rax
mov rsi,[rdi+8]
add rsi,8
cmp [rsi],sil
add rdi,10
mov [rsp],rdi
cmp [rdi],dil
mov rdi,rsi
mov rsi,[rsp]
mov edx,20
call qword ptr [7F5831BC5740]; System.Buffer.BulkMoveWithWriteBarrier(Byte ByRef, Byte ByRef, UIntPtr)
nop
add rsp,8
ret
; Total bytes of code 47
使GC写入屏障更快是好的;毕竟,它们被使用得非常多。然而,从已检查写入屏障切换到非检查写入屏障是一个非常微小的优化;已检查变体的额外开销通常只是几个比较操作。更好的优化是避免完全需要屏障!dotnet/runtime#103503 认识到 ref struct
不能可能在GC堆上,因此,在写入 ref struct
的字段时,可以完全省略写入屏障。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
[Benchmark]
public void Store()
{
MyRefStruct s = default;
Test(ref s, new object(), new object());
}
[MethodImpl(MethodImplOptions.NoInlining)]
private void Test(ref MyRefStruct s, object o1, object o2)
{
s.Obj1 = o1;
s.Obj2 = o2;
}
private ref struct MyRefStruct
{
public object Obj1;
public object Obj2;
}
}
在.NET 8中,我们有两个屏障;在.NET 9中,零个:
// .NET 8
; Tests.Test(MyRefStruct ByRef, System.Object, System.Object)
push r15
push rbx
mov rbx,rsi
mov r15,rcx
mov rdi,rbx
mov rsi,rdx
call CORINFO_HELP_CHECKED_ASSIGN_REF
lea rdi,[rbx+8]
mov rsi,r15
call CORINFO_HELP_CHECKED_ASSIGN_REF
nop
pop rbx
pop r15
ret
; Total bytes of code 37
// .NET 9
; Tests.Test(MyRefStruct ByRef, System.Object, System.Object)
mov [rsi],rdx
mov [rsi+8],rcx
ret
; Total bytes of code 8
类似地,dotnet/runtime#102084 能够在 Arm64 上移除 ref struct
复制中的某些屏障。
对象栈分配
多年来,.NET一直在探索栈分配托管对象的可能性。这与其他像Java这样的托管语言已经能够做到的事情类似,但在Java中这更为关键,因为Java缺乏值类型的等效物(例如,如果你想要一个整数的列表,那很可能是一个List<Integer>
,这会将添加到列表中的每个整数装箱,类似于在.NET中使用List<object>
的情况)。在.NET 9中,对象栈分配开始实施。在您过于兴奋之前,目前它的范围是有限的,但未来它很可能会进一步扩展。
栈分配对象最难的部分是确保其安全性。如果对象的引用逸出并最终存储在超出包含栈分配对象的栈帧的地方,那就非常糟糕;当方法返回时,这些未解决的引用将指向垃圾。因此,JIT需要执行逃逸分析来确保这种情况永远不会发生,而做好这一点极具挑战性。对于.NET 9,支持是在dotnet/runtime#103361(并在dotnet/runtime#104411中引入了原生AOT)中引入的,并且它不执行间程序分析,这意味着它仅限于处理它可以轻松证明对象引用不会离开当前帧的情况。即便如此,有许多情况这将有助于消除分配,并且我预计它将在未来处理越来越多的案例。当JIT选择在栈上分配对象时,它实际上将提升对象的字段为栈帧中的独立变量。
下面是一个非常简单的示例,展示了该机制的运作:
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(false)]
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
[Benchmark]
public int GetValue() => new MyObj(42).Value;
private class MyObj
{
public MyObj(int value) => Value = value;
public int Value { get; }
}
}
在.NET 8中,GetValue
生成的代码如下:
; Tests.GetValue()
push rax
mov rdi,offset MT_Tests+MyObj
call CORINFO_HELP_NEWSFAST
mov dword ptr [rax+8],2A
mov eax,[rax+8]
add rsp,8
ret
; 总代码字节数 31
生成的代码会分配一个新的对象,设置该对象的Value
字段,然后读取该Value
作为要返回的值。在.NET 9中,我们得到了这个简洁的视图:
; Tests.GetValue()
mov eax,2A
ret
; 总代码字节数 6
JIT内联了构造函数,内联了对Value
属性的访问,将支持该属性的字段提升为变量,实际上将整个操作优化为return 42;
。
方法 | 运行时 | 平均值 | 比率 | 代码大小 | 分配 | 分配比率 |
---|---|---|---|---|---|---|
GetValue | .NET 8.0 | 3.6037纳秒 | 1.00 | 31字节 | 24字节 | 1.00 |
GetValue | .NET 9.0 | 0.0519纳秒 | 0.01 | 6字节 | – | 0.00 |
以下是另一个更有影响力的例子。当性能优化自然而然地发生时,这真的很令人满意;否则,开发者需要了解执行某种操作这种方式与那种方式的细微差别。每种编程语言和平台都有大量这类的事情,但我们都希望将这些数量降到最低。.NET的一个有趣案例与结构体和类型转换有关。考虑这两个Dispose1
和Dispose2
方法:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
[Benchmark]
public void Test()
{
Dispose1<MyStruct>(default);
Dispose2<MyStruct>(default);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private bool Dispose1<T>(T o)
{
bool disposed = false;
if (o is IDisposable disposable)
{
disposable.Dispose();
disposed = true;
}
return disposed;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private bool Dispose2<T>(T o)
{
bool disposed = false;
if (o is IDisposable)
{
((IDisposable)o).Dispose();
disposed = true;
}
return disposed;
}
private struct MyStruct : IDisposable
{
public void Dispose() { }
}
}
理想情况下,如果你用值类型T
调用它们,就不会有分配,但遗憾的是,在Dispose1
中,由于这里的设置,JIT最终需要装箱o
以生成IDisposable
。有趣的是,由于几年前的一些优化,Dispose2
中的JIT实际上能够省略装箱。在.NET 8中,我们得到以下结果:
; Tests.Dispose1[[Tests+MyStruct, benchmarks]](MyStruct)
push rbx
mov rdi,offset MT_Tests+MyStruct
call CORINFO_HELP_NEWSFAST
add rax,8
mov ebx,[rsp+10]
mov [rax],bl
mov eax,1
pop rbx
ret
; 总代码字节数 33
; Tests.Dispose2[[Tests+MyStruct, benchmarks]](MyStruct)
mov eax,1
ret
; 总代码字节数 6
这是开发者需要“仅仅知道”的事情之一,并且还需要与IDE0038之类的工具作斗争,这些工具推动开发者编写第一种版本中的代码,而对于结构体来说,后者最终更有效率。这项关于栈分配的工作使得这种差异消失,因为第一个版本中的装箱正是编译器现在能够栈分配的分配的典型例子。在.NET 9中,我们现在得到以下结果:
; Tests.Dispose1[[Tests+MyStruct, benchmarks]](MyStruct)
mov eax,1
ret
; 总代码字节数 6
; Tests.Dispose2[[Tests+MyStruct, benchmarks]](MyStruct)
mov eax,1
ret
; 总代码字节数 6
方法 | 运行时 | 平均值 | 比率 | 代码大小 | 分配 | 分配比率 |
---|---|---|---|---|---|---|
Test | .NET 8.0 | 5.726纳秒 | 1.00 | 94字节 | 24字节 | 1.00 |
Test | .NET 9.0 | 2.095纳秒 | 0.37 | 45字节 | – | 0.00 |
内联优化
内联优化是之前版本中的一个主要关注点,未来可能再次成为主要关注点。对于.NET 9,虽然变化不是很多,但有一个特别有影响力的改进。
为了说明这个问题,我们再次考虑 ArgumentNullException.ThrowIfNull
。它是这样定义的:
public static void ThrowIfNull(object? arg, [CallerArgumentExpression(nameof(arg))] string? paramName = null);
值得注意的是,它不是泛型的,这是我们经常被问到的问题。我们选择不将其泛型化的原因有三个:
- 将其泛化的主要好处是避免对结构体进行装箱,但JIT已经在 tier 1 中消除了这种装箱,如本文前面所强调的,它现在甚至可以在 tier 0 中消除(现在确实可以)。
- 每个泛型实例化(使用不同类型的泛型)都会增加运行时开销。我们不想因为支持在生产环境中很少失败或从未失败的参数验证而使进程膨胀,仅为了支持这种额外的元数据和运行时数据结构。
- 当与引用类型(这是其存在的目的)一起使用时,它不会很好地与内联配合,但此类“抛出辅助器”的内联对于性能至关重要。在 coreclr 和 Native AOT 中,泛型方法有两种工作方式。对于值类型,每次使用不同值类型的泛型时,都会为该参数类型创建整个泛型方法的副本并对其进行专门化;这就像您编写了一个非泛型且专门针对该类型的专用版本一样。对于引用类型,只有一个代码副本,然后在运行时根据实际使用的类型进行参数化。当访问此类共享泛型时,运行时会查找字典中的泛型参数信息,并使用找到的信息来通知方法的其他部分。历史上,这并不利于内联。
因此,ThrowIfNull
不是泛型的。但是,还有其他的抛出辅助器,其中许多是泛型的。这是因为:a) 它们主要预期与值类型一起使用,b) 由于方法性质,我们没有其他选择。例如,ArgumentOutOfRangeException.ThrowIfEqual
是基于 T
的泛型,接受两个 T
的值进行比较并抛出异常。如果 T
是引用类型,在 .NET 8 中,如果调用者是共享泛型,它可能无法成功内联。以下代码示例:
// dotnet run -c Release -f net8.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
namespace Benchmarks;
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public unsafe class Tests
{
private static void Main(string[] args) =>
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[Benchmark]
public void Test() => ThrowOrDispose(new Version(1, 0), new Version(1, 1));
[MethodImpl(MethodImplOptions.NoInlining)]
private static void ThrowOrDispose<T>(T value, T invalid) where T : IEquatable<T>
{
ArgumentOutOfRangeException.ThrowIfEqual(value, invalid);
if (value is IDisposable disposable)
{
disposable.Dispose();
}
}
}
在 .NET 8 中,ThrowOrDispose
方法是这样输出的(这个示例基准与之前的示例略有不同,这个输出是由于一些原因而来自 Windows):
; Benchmarks.Tests.ThrowOrDispose[[System.__Canon, System.Private.CoreLib]](System.__Canon, System.__Canon)
push rsi
push rbx
sub rsp,28
mov [rsp+20],rcx
mov rbx,rdx
mov rsi,r8
mov rdx,[rcx+10]
mov rax,[rdx+10]
test rax,rax
je short M01_L00
mov rcx,rax
jmp short M01_L01
M01_L00:
mov rdx,7FF996A8B170
call CORINFO_HELP_RUNTIMEHANDLE_METHOD
mov rcx,rax
M01_L01:
mov rdx,rbx
mov r8,rsi
mov r9,1DB81B20390
call qword ptr [7FF996AC5BC0]; System.ArgumentOutOfRangeException.ThrowIfEqual[[System.__Canon, System.Private.CoreLib]](System.__Canon, System.__Canon, System.String)
mov rdx,rbx
mov rcx,offset MT_System.IDisposable
call qword ptr [7FF996664348]; System.Runtime.CompilerServices.CastHelpers.IsInstanceOfInterface(Void*, System.Object)
test rax,rax
jne short M01_L03
M01_L02:
add rsp,28
pop rbx
pop rsi
ret
M01_L03:
mov rcx,rax
mov r11,7FF9965204F8
call qword ptr [r11]
jmp short M01_L02
; Total bytes of code 124
这里有两个特别需要注意的点。首先,我们看到有一个 call
到 CORINFO_HELP_RUNTIMEHANDLE_METHOD
;这是用于获取实际类型 T
信息的辅助器。其次,ThrowIfEqual
没有被内联;如果它被内联,这里就不会看到 ThrowIfEqual
的 call
,而是会看到 ThrowIfEqual
的实际代码。我们可以通过另一个 BenchmarkDotNet 诊断器确认为什么它没有被内联:[InliningDiagnoser]
。JIT 能够为其大部分活动生成事件,包括报告任何成功或失败的内联操作,[InliningDiagnoser]
会监听这些事件并将它们作为基准测试结果的一部分进行报告。这个诊断器位于单独的 BenchmarkDotNet.Diagnostics.Windows
包中,并且仅在 Windows 上运行,因为它依赖于 ETW,这就是为什么我之前的基准测试也是 Windows 的原因。当我将:
[InliningDiagnoser(allowedNamespaces: ["Benchmarks"])]
添加到我的 Tests
类中,并运行 .NET 8 的基准测试时,我看到输出中出现了以下内容:
Inliner: Benchmarks.Tests.ThrowOrDispose - generic void (!!0,!!0)
Inlinee: System.ArgumentOutOfRangeException.ThrowIfEqual - generic void (!!0,!!0,class System.String)
Fail Reason: runtime dictionary lookup
换句话说,ThrowOrDispose
调用了 ThrowIfEqual
,但由于 ThrowIfEqual
包含了“运行时字典查找”,因此无法内联。
现在,在 .NET 9 中,得益于 dotnet/runtime#99265,它可以被内联了!生成的汇编代码太长了,无法在此展示,但我们可以从基准测试结果中看到其影响:
方法 | 运行时 | 平均时间 | 比率 |
---|---|---|---|
Test | .NET 8.0 | 17.54 ns | 1.00 |
Test | .NET 9.0 | 12.76 ns | 0.73 |
我们还可以在内联报告中看到它成功地内联了。
垃圾回收(GC)
在内存管理方面,应用程序的需求各不相同。您是否愿意投入更多内存以最大化吞吐量,还是您更关心最小化工作集?未使用的内存被积极返还给系统的重要性如何?您的预期工作负载是恒定的还是波动的?垃圾回收(GC)长期以来提供了许多调节行为的功能,基于这些问题,但没有哪个选择比选择“工作站GC”还是“服务器GC”更为明显。
默认情况下,应用程序使用工作站GC,尽管某些环境(如ASP.NET)会自动选择使用服务器GC。您可以通过多种方式显式选择使用服务器GC,包括在项目文件中添加 <ServerGarbageCollection>true</ServerGarbageCollection>
(正如我们在本文的基准测试设置部分所做的那样)。工作站GC优化以减少内存消耗,而服务器GC优化以实现最大吞吐量。历史上,工作站使用单一堆,而服务器使用每个核心一个堆。这通常代表在内存消耗和堆访问开销(如分配成本)之间的权衡。如果许多线程同时尝试分配内存,使用服务器GC时,它们很可能会访问不同的堆,从而减少竞争;而使用工作站GC时,它们都会争夺访问权。反过来,更多的堆通常意味着更大的内存消耗(即使每个堆可能比单一的堆小),特别是在系统负载较低的时候,尽管系统可能没有完全加载,但仍然为这些额外的堆支付工作集的代价。
对于选择使用哪种GC,并不总是那么明确。特别是在容器环境中,您通常仍然关心良好的吞吐量,但也不希望无谓地消耗内存。这时,“动态适应应用程序大小的GC”(DATAS,或“Dynamically Adapting To Application Sizes”)就派上用场了。DATAS 在 .NET 8 中引入,旨在缩小工作站GC和服务器GC之间的差距,使服务器GC在内存消耗上更接近工作站。DATAS能够动态调整服务器GC消耗的内存量,在负载较低时使用更少的内存。虽然DATAS在.NET 8中发布,但默认情况下仅对基于原生AOT的项目启用,并且即使在这种情况下,也存在一些需要解决的问题。这些问题现在已经解决(例如:dotnet/runtime#98743、dotnet/runtime#100390、dotnet/runtime#102368 和 dotnet/runtime#105545),因此,在.NET 9中,根据dotnet/runtime#103374,DATAS现在默认情况下对服务器GC启用。
如果您的工作负载对绝对最佳的吞吐量至关重要,并且您愿意为这一目标接受额外的内存消耗,您可以自由地禁用DATAS,例如,通过在项目文件中添加以下内容:
<GarbageCollectionAdaptationMode>0</GarbageCollectionAdaptationMode>
尽管DATAS默认启用对.NET 9来说是一个非常有影响力的改进,但在此次发布中还有其他与GC相关的改进。例如,在压缩堆时,GC可能会根据地址对对象进行排序。对于大量对象,这种排序操作可能是相对昂贵的,GC需要并行化排序操作。为了这个目的,几个版本之前,GC集成了名为vxsort的并行排序算法,它实际上是一个带有并行化分区步骤的快速排序。然而,它最初仅针对Windows(且仅限于x64架构)启用。在.NET 9中,根据dotnet/runtime#98712,它也被扩展到Linux,这有助于减少GC暂停时间。
虚拟机(VM)
.NET 运行时为托管代码提供了许多服务。当然,其中包括垃圾回收器(GC)和即时编译器(JIT),然后还有一大堆关于汇编和类型加载、异常处理、配置管理、虚拟调度、互操作性基础设施、存根管理等方面的功能。所有这些功能通常被称为核心clr虚拟机(VM)的一部分。
在这个领域,许多性能变化很难展示,但它们仍然值得提及。dotnet/runtime#101580 通过延迟分配与方法入口点相关的一些信息,实现了更小的堆大小和启动时的工作量减少。dotnet/runtime#96857 移除了一些与方法周围的数据结构相关的非必要分配。dotnet/runtime#96703 减少了构建方法表的一些关键函数的算法复杂性,而 dotnet/runtime#96466 则优化了对这些表的访问,最小化了涉及的间接引用数量。
另一组更改旨在改进托管代码对VM的调用。当托管代码需要调用运行时,它可以采用几种机制。一种称为“QCALL”,实际上就是P/Invoke或DllImport
到运行时中声明的函数。另一种是“FCALL”,这是一种更专业且复杂的机制,用于调用能够访问托管对象的运行时代码。FCALL曾经是主导机制,但每个版本都有越来越多的此类调用被转换为QCALL,这有助于提高正确性(FCALLs可能难以“正确实现”)以及在某些情况下性能(一些FCALLs需要辅助方法帧,这通常使它们比QCALLs更昂贵)。dotnet/runtime#96860 转换了一些Marshal
成员,dotnet/runtime#96916 为Interlocked
做了同样的事情,dotnet/runtime#96926 处理了更多与线程相关的成员,dotnet/runtime#97432 转换了一些内置的序列化支持,dotnet/runtime#97469 和 dotnet/runtime#100939 处理了GC
和反射中的方法,@AustinWise 的 dotnet/runtime#103211 转换了GC.ReRegisterForFinalize
,而 dotnet/runtime#105584 转换了Delegate.GetMulticastInvoke
(这在Delegate.Combine
和Delegate.Remove
等API中使用)。dotnet/runtime#97590 同样处理了ValueType.GetHashCode
的慢路径,同时也将快路径转换为托管以避免整个转换过程。
但可能在这个领域对.NET 9影响最大的更改是关于异常的。异常成本很高,在性能重要时应当避免。但是,仅仅因为它们成本高,并不意味着让它们更便宜没有价值。实际上,在某些情况下,让它们更便宜是非常有价值的。我们在野外偶尔观察到的一些现象是“异常风暴”。一些故障发生,导致另一个故障,进而导致另一个故障。每个故障都会产生异常。随着处理这些异常的 overhead 增加,CPU 使用率开始飙升。现在其他事情开始超时,因为它们正在被饥饿,于是它们抛出异常,这又导致了更多的故障。你明白这个情况。
在《.NET 8性能改进》(Performance Improvements in .NET 8) 中,我强调了在我看来,该版本最重要的性能改进是一个字符的改变,使动态PGO默认启用。现在在.NET 9中,dotnet/runtime#98570 是一个极其微小且简单的PR,它掩盖了在此之前的大量工作。早期,dotnet/runtime#88034 将本地AOT异常处理实现迁移到了coreclr,但由于还需要烘烤时间,所以默认是禁用的。现在它已经经过了烘烤时间,新的实现现在在.NET 9中默认启用,并且速度更快。随着 dotnet/runtime#103076 的出现,它移除了处理异常时涉及的全球自旋锁。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
[Benchmark]
public async Task ExceptionThrowCatch()
{
for (int i = 0; i < 1000; i++)
{
try { await Recur(10); } catch { }
}
}
private async Task Recur(int depth)
{
if (depth <= 0)
{
await Task.Yield();
throw new Exception();
}
await Recur(depth - 1);
}
}
方法 | 运行时 | 平均值 | 比率 |
---|---|---|---|
ExceptionThrowCatch | .NET 8.0 | 123.03 毫秒 | 1.00 |
ExceptionThrowCatch | .NET 9.0 | 54.68 毫秒 | 0.44 |
单体(Mono)
我们经常提到“运行时”,但实际上在.NET中目前存在多个运行时的实现。“coreclr”是迄今为止被提及的运行时,它是Windows、Linux和macOS上默认使用的运行时,适用于服务和桌面应用程序,但还有一个“mono”,主要在目标应用程序的运行时需要较小的情况下使用:默认情况下,它是今天构建Android和iOS移动应用程序以及Blazor WASM应用程序所使用的运行时。mono在.NET 9中也看到了众多性能的提升:
- 配置文件的保存/恢复。 mono提供的一个功能是解释器,它使得.NET代码可以在JIT编译不被允许的环境中执行,同时也能实现快速启动。特别是当针对WASM时,解释器具有一种PGO形式,在方法被调用一定次数并被认为很重要后,它会即时生成WASM以优化这些方法。在.NET 9中,通过dotnet/runtime#92981改进了这一分层机制,使得可以跟踪哪些方法被分层,如果代码在浏览器中运行,将此信息存储在浏览器的缓存中供后续运行使用。当代码再次运行时,它可以结合之前的经验更好地和更快地进行分层。
- 基于SSA的优化。 生成WASM的编译器主要在基本块级别应用优化。通过dotnet/runtime#96315对实现进行了彻底改造,采用静态单赋值(Static Single Assignment,SSA)形式,这是优化编译器常用的形式,并确保每个变量只在一个地方赋值。这种形式简化了许多后续分析,从而有助于更好地优化代码。
- 向量改进。 核心库越来越多地使用向量化,利用硬件内嵌和不同的
Vector
类型。为了使这些库代码在mono上良好执行,各种mono后端需要高效地处理这些操作。其中一个最具影响的变化是dotnet/runtime#105299,它更新了mono以加速Shuffle
对于除了byte
和sbyte
以外的类型(这些类型已经得到处理)。这对核心库中的功能有很大影响,许多核心库使用Shuffle
作为核心算法的一部分,如IndexOfAny
、十六进制编码和解码、Base64编码和解码、Guid
等。dotnet/runtime#92714和dotnet/runtime#98037也改进了向量构造,例如通过使mono JIT利用Arm64ins
(插入)指令从另一个值创建一个float
或double
向量。 - 更多内嵌函数。 dotnet/runtime#98077、dotnet/runtime#98514和dotnet/runtime#98710实现了各种
AdvSimd.Load*
和AdvSimd.Store*
API。dotnet/runtime#99115和dotnet/runtime#101622将Span<T>.Clear/Fill
后端的几个清除和填充方法内嵌化。而dotnet/runtime#105150和dotnet/runtime#104698优化了各种Unsafe
方法,如BitCast
。dotnet/runtime#91813也在多种CPU上显著改善了未对齐访问,通过不让实现强制走慢路径,如果CPU能够处理这样的读取和写入。 - 启动速度。dotnet/runtime#100146是一个有趣的变化,因为它对mono启动产生了意外的积极影响。这个变化更新了dotnet/runtime的配置,以启用更多的静态分析,特别是强制执行CA1865、CA1866和CA1867规则,而我们尚未为仓库启用这些规则。这个变化包括修复所有规则的违规情况,这主要意味着修复了像
IndexOf("!")
(接受单个字符字符串的IndexOf
)这样的调用站点,并将其替换为IndexOf('!')
。规则的本意是这样做会稍微快一些,调用站点也会变得稍微整洁一些。但是IndexOf(string)
是文化感知的,这意味着使用它可能会强制全球化库ICU的加载和初始化。事实上,一些这些使用在mono的启动路径上,并强制ICU的加载,但实际上并不需要。修复这些意味着加载可以延迟,从而提高了启动性能。dotnet/runtime#101312通过添加代码中的vtable设置缓存来改善了使用解释器的启动。这个缓存使用在dotnet/runtime#100386中添加的自定义哈希表实现,该实现随后也在其他地方使用,例如在dotnet/runtime#101460和dotnet/runtime#102476中。这个哈希表本身也很有趣,因为它的查找在x64、Arm和WASM上进行了向量化,并且通常优化了缓存局部性。 - 去除方差检查。当将对象存储到数组中时,这个操作需要验证以确保存储的类型与数组的具体类型兼容。给定一个基类型
B
和两个派生类型D1 : B
和D2 : B
,你可以有一个数组B[] array = new D1[42];
,然后代码array[0] = new D2();
会成功编译,因为D2
是B
的子类型,但在运行时这必须失败,因为D2
不是D1
,所以运行时需要检查以确保正确性。然而,如果数组的类型是密封的,这个检查可以避免,因为这样就不会出现这种差异。coreclr已经做了这个优化;现在作为dotnet/runtime#99829的一部分,mono解释器也实现了这个优化。
本地AOT编译
本地AOT编译是一种直接从.NET应用程序生成原生可执行文件的方法。生成的二进制文件不需要安装.NET,也不需要JIT编译;相反,它包含了整个应用程序的所有程序集代码,包括访问的任何核心库功能代码、垃圾回收器的程序集等等。本地AOT首次出现在.NET 7中,并在.NET 8中得到了显著改进,特别是在减少生成应用程序的大小方面。现在在.NET 9中,我们继续在本地AOT上投入,并且已经看到了一些非常不错的成果。(注意,本地AOT工具链使用JIT来生成汇编代码,所以本文中JIT部分以及其他地方讨论的大多数代码生成改进也同样适用于本地AOT。)
对于本地AOT来说,最大的担忧是大小和裁剪。基于本地AOT的应用程序和库会编译所有内容,包括所有用户代码、所有库代码、运行时,一切,都编译到单个原生二进制文件中。因此,工具链必须采取额外措施,尽可能地去除内容,以保持文件大小。这可以包括更聪明地处理运行时所需的状态存储。也可以包括更细心地处理泛型,以减少大量泛型实例化可能导致的代码大小爆炸(实际上,这是为不同的类型参数生成多个完全相同的代码副本)。还可以包括非常谨慎地避免那些意外引入大量代码且裁剪工具无法充分理解以删除的依赖。以下是.NET 9中这些做法的一些示例:
- 重构瓶颈点。思考一下你的代码:你有多少次编写了一个接收某些输入然后根据提供的输入调度到多种不同事物的方法?这是比较常见的。不幸的是,这也会对本地AOT代码大小造成问题。
System.Security.Cryptography
中的一个很好的例子是,通过dotnet/runtime#91185修复的。这里有许多与哈希相关的类型,如SHA256
或SHA3_384
,它们都提供了一个HashData
方法。然后,还有一些地方会指定要使用的确切哈希算法,通过HashAlgorithmName
来实现。你可以想象到结果会是一个庞大的switch语句(或者不想想象的话,可以查看代码),根据指定的HashAlgorithmName
,实现选择调用正确类型的HashData
方法。这就是通常所说的“瓶颈点”,所有调用者最终都会通过这个方法进入,然后扩展到相关的实现,但也导致了本地AOT的这个问题:如果引用了这个瓶颈点,通常需要为所有引用的方法生成代码,即使实际上只使用了一部分。有些情况确实很难解决。但在这种特定情况下,幸运的是,所有的HashData
方法最终都调用了参数化、共享的实现。因此,修复方法是直接跳过中间层,让HashAlgorithmName
层直接调用主要实现,而不命名中间层的方法。 - 减少LINQ的使用。LINQ是一个强大的生产力工具。我们非常喜欢LINQ,并在每个.NET版本中都对其进行投资(请查看本文后面的关于.NET 9中LINQ性能提升的多个部分)。然而,在本地AOT中,大量使用LINQ也会显著增加代码大小,特别是在涉及值类型时。正如稍后会讨论LINQ优化时提到的,LINQ采用的一种优化方式是针对输入的特殊情况,为其返回不同类型的
IEnumerable<T>
。例如,如果你使用数组作为Select
方法的输入,返回的IEnumerable<T>
可能是内部ArraySelectIterator<T>
的实例;如果你使用List<T>
作为输入,返回的IEnumerable<T>
可能是内部ListSelectIterator<T>
的实例。本地AOT裁剪器无法轻易确定可能使用哪些路径,因此,当你调用Select<T>
时,本地AOT编译器需要为所有这些类型生成代码。如果T
是引用类型,那么将只有一个共享的生成代码副本。但如果是值类型,将需要为每个唯一的T
生成定制版本的代码。这意味着,如果大量使用这类LINQ API(以及其他类似的API),它们可能会不成比例地增加本地AOT二进制文件的大小。dotnet/runtime#98109是一个示例PR,它替换了一部分LINQ代码,从而显著减少了使用本地AOT编译的ASP.NET应用程序的大小。但你可以看到,该PR也仔细考虑了哪些LINQ使用被移除,指出了这些具体的实例对大小有显著影响,并保留了库中的其他LINQ使用。 - 避免不必要的数组类型。支持
ArrayPool<T>.Shared
的SharedArrayPool<T>
存储了大量的状态,包括几个类似T[][]
类型的字段。这是有道理的;因为它在存储数组,所以需要数组数组。但从本地AOT的角度来看,如果T
是值类型(在ArrayPool<T>
中非常常见),T[][]
作为一个唯一的数组类型需要为其生成独立的代码,这与T[]
的代码是不同的。实际上,ArrayPool<T>
在这些情况下并不需要与这些数组实例进行工作,所以它不需要强类型数组的特性;这可以简单地是object[]
或Array[]
。这正是dotnet/runtime#97058所做的:通过这个修改,编译后的二进制文件只需要为Array[]
生成代码,而不需要为byte[][]
、char[][]
、object[][]
以及ArrayPool<T>
在应用程序中使用的任何其他类型生成代码。 - 避免不必要的泛型代码。本地AOT编译器目前不执行任何类型的“展开”(与内联相反,内联是将调用方法的代码移动到调用者中,而展开则是将方法中的代码提取到调用者之外的新方法中)。如果你有一个大的方法,编译器将需要为整个方法生成代码,如果该方法泛型,并且编译了多个泛型特殊化,那么整个方法将针对每个特殊化进行编译和优化。但是,如果你在方法中有任何实际上不依赖于相关泛型类型的代码,你可以通过将其重构为独立的非泛型方法来避免这种重复。这就是dotnet/runtime#101474在
Microsoft.Extensions.Logging.Console
的一些类型中(如SimpleConsoleFormatter
和JsonConsoleFormatter
)所做的。这里有一个泛型Write<TState>
方法,但TState
仅在方法的第一行被使用,该行将参数格式化为字符串。之后,有很多关于实际写入的逻辑,但所有这些逻辑只需要格式化操作的结果,而不需要输入。因此,这个PR简单地重构了Write<TState>
,使其仅执行格式化操作,然后委托给一个独立的方法来完成大部分工作。 - 去除不必要的依赖项。有许多小的但有意义的不必要依赖,直到开始关注生成代码的大小,并深入挖掘代码大小的来源时才会注意到。例如,dotnet/runtime#95710是一个很好的例子。
AppContext.OnProcessExit
方法被保留(无法裁剪)是因为它在进程退出时被调用。这个OnProcessExit
方法调用了AppDomain.CurrentDomain
,它返回一个AppDomain
。AppDomain
的ToString
重写依赖于很多内容。而任何类型调用基类的object.ToString
时,系统需要知道所有可能的派生类型都是可调用的。这意味着,用于AppDomain.ToString
的所有内容从未被裁剪。这个小重构使得只有当用户代码实际上访问了AppDomain.CurrentDomain
时,才需要保留所有这些内容。另一个例子是dotnet/runtime#101858,它移除了对Convert
方法的一些依赖。 - 选用更适合的工具。有时,简单的答案才是最好的。dotnet/runtime#100916就是一个这样的例子。
Microsoft.Extensions.DependencyInjection
中的某些代码需要特定方法的MethodInfo
,它使用System.Linq.Expressions
来提取,而实际上它可以更简单地使用委托。这不仅更节省分配和开销,还去除了对Expressions
库的依赖。 - 编译时而非运行时。源生成器对于本地AOT来说是一个巨大的优势,因为它允许在构建时计算某些内容,并将结果嵌入到程序集中,而不是在运行时(在这种情况下,通常只计算一次然后缓存)。这有助于启动性能,因为你可以不必做这些工作就可以开始。它也有助于稳定状态吞吐量,因为如果你在构建时做这些工作,你通常可以做得更好。但这也对大小有益,因为这样可以移除对任何可能在计算过程中使用的依赖项。而通常这些依赖项是反射,它带来了大量的代码大小。实际上,
System.Private.CoreLib
在构建CoreLib
时使用了一个源生成器。而dotnet/runtime#102164扩展了这个源生成器,生成了一个专门的Environment.Version
和RuntimeInformation.FrameworkDescription
实现。之前,CoreLib
中的这两个方法都会使用反射查找也在CoreLib
中的属性,但源生成器可以在构建时完成这些工作,并将答案直接嵌入到这些方法的实现中。 - 避免重复。在应用程序的某些地方,两个方法有相同的实现是很常见的,尤其是对于小型辅助方法,如属性访问器。dotnet/runtime#101969教本地AOT工具链去重这些代码,使得代码只存储一次。
- 去除不必要的接口。之前,未使用的接口方法可以被裁剪掉(实际上是从接口类型和所有实现方法中移除),但编译器无法完全移除实际的接口类型。现在,有了dotnet/runtime#100000,编译器可以移除这些接口类型。
- 去除不必要的静态构造器。裁剪器会保留类型的静态构造器,如果任何字段被访问。这个条件过于宽泛:只有当访问的是静态字段时,才需要保留静态构造器。dotnet/runtime#96656改进了这一点。
在之前的版本中,我们投入了大量时间来减小二进制文件的大小,但这类改进可以进一步减少它们。让我们使用本地AOT创建一个新的ASP.NET最小API应用程序。这个命令使用webapiaot
模板并在新的myapp
目录中创建新的项目:
dotnet new webapiaot -o myapp
将生成的myapp.csproj
文件的内容替换为以下内容:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net9.0;net8.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<PublishAot>true</PublishAot>
<OptimizationPreference>Size</OptimizationPreference>
<StackTraceSupport>false</StackTraceSupport>
</PropertyGroup>
</Project>
我只是在模板的默认设置上添加了net9.0
和net8.0
作为目标框架,然后添加了一些设置(在底部),专注于减小本地AOT应用程序的大小。这个应用程序是一个简单的站点,它以JSON格式公开了一个/todos
列表。
我们可以使用本地AOT发布这个应用程序:
dotnet publish -f net8.0 -r linux-x64 -c Release
ls -hs bin/Release/net8.0/linux-x64/publish/myapp
这将得到:
9.4M bin/Release/net8.0/linux-x64/publish/myapp
在这里,我们可以看到整个站点、Web服务器、垃圾回收器等,都包含在myapp
应用程序中,在.NET 8中,它的重量为9.4兆字节。现在,让我们为.NET 9做同样的事情:
dotnet publish -f net9.0 -r linux-x64 -c Release
ls -hs bin/Release/net9.0/linux-x64/publish/myapp
这将产生:
8.5M bin/Release/net9.0/linux-x64/publish/myapp
现在,仅仅通过升级到新版本,相同的myapp
已经缩小到8.5兆字节,二进制文件大小减少了约10%。
除了关注大小之外,本地AOT编译与即时编译(JIT)的不同之处在于,每种方法都有自己的独特优化机会。JIT可以根据当前机器的详细情况,采用最佳指令集(例如,在支持AVX512指令的硬件上使用AVX512指令),并且可以使用动态PGO根据执行特性不断优化代码。但是,本地AOT能够进行整个程序的优化,它可以查看程序中的所有内容,并基于整个程序进行优化(相比之下,JIT的.NET应用程序可能在任何时间点加载额外的.NET库)。例如,dotnet/runtime#92923通过在整个程序中查找是否有任何可能从外部写入的字段,实现了自动将字段标记为readonly
;这可以进一步帮助改进预初始化。
dotnet/runtime#99761提供了一个很好的例子,编译器可以根据整个程序的分析,看到某个特定类型永远不会被实例化。如果类型从未被实例化,那么对该类型的类型检查永远不会成功。因此,如果一个程序有一个如if (variable is SomethingNeverInstantiated)
的检查,这可以被转换为常量false
,并删除与该if
块相关的所有代码。dotnet/runtime#102248也是类似的,但针对类型;如果代码中执行if (someType == typeof(X))
的检查,而编译器从未为X
构造方法表,它可以将这个检查转换为常量结果。
整个程序分析也适用于以非常酷的方式去除虚方法。通过dotnet/runtime#92440,编译器现在可以在没有看到任何从C
派生的类型实例化的情况下,去除对虚拟方法C.M
的所有调用。而通过dotnet/runtime#97812和dotnet/runtime#97867,编译器现在可以根据整个程序分析,将virtual
方法视为非virtual
和sealed
,如果程序中没有方法重写这些方法。
本地AOT的另一个超级能力是它的预初始化。编译器包含一个解释器,能够在构建时评估代码,并用结果替换那段代码;对于某些对象,解释器还能将对象的内存数据直接写入二进制文件,以便在执行时以低成本解压缩。解释器能够执行的操作和允许执行的操作正在逐步改进。dotnet/runtime#92470扩展了解释器的功能,使其支持更多的类型检查、静态接口方法调用、受限方法调用以及各种操作对span的支持;而dotnet/runtime#92666则扩展了解释器,添加了对硬件内联指令和各种IsSupported
方法的支持。dotnet/runtime#92739进一步完善了解释器,添加了对stackalloc
分配span、IntPtr
/nint
数学以及Unsafe.Add
的支持。
反射
反射是.NET中非常强大(尽管有时被过度使用)的功能,它允许代码加载和检查.NET程序集,并调用它们的功能。它被广泛应用于各种库和应用中,包括.NET核心库本身,因此我们需要继续寻找方法来减少与反射相关的开销。
在.NET 9中,有几个Pull Request(PR)在逐步减少反射中的一些分配开销。dotnet/runtime#92310 和 dotnet/runtime#93115 通过处理 ReadOnlySpan<T>
实例来避免了一些防御性数组复制,而 dotnet/runtime#95952 移除了一个只用于常量的 string.Split
调用,因此可以用手动拆分这些常量的方式来替代。但更有趣且影响更大的改进来自于 dotnet/runtime#97683,它增加了一种无需分配的从委托获取调用列表的方法。在.NET中,委托是“多播”的,意味着一个单独的委托实例实际上可能代表要调用的多个方法;这正是.NET事件实现的原理。如果我调用一个委托,委托实现会逐个顺序地调用每个组成方法。但如果我们想自定义调用逻辑呢?也许我们想在每个单独的方法上包裹一个try/catch,或者我们可能想跟踪所有方法的返回值而不是仅仅最后一个,或者类似的行为。为了实现这一点,委托提供了一个获取每个原始方法的一个委托数组的方法。所以,如果我们有:
Action action = () => Console.Write("A ");
action += () => Console.Write("B ");
action += () => Console.Write("C ");
action();
这将打印出 "A B C "
,如果我们有:
Action action = () => Console.Write("A ");
action += () => Console.Write("B ");
action += () => Console.Write("C ");
Delegate[] actions = action.GetInvocationList();
for (int i = 0; i < actions.Length; ++i)
{
Console.Write($"{i}: ");
((Action)actions[i])();
Console.WriteLine();
}
这将打印出:
0: A
1: B
2: C
然而,GetInvocationList
需要分配。现在在.NET 9中,有了新的 Delegate.EnumerateInvocationList<TDelegate>
方法,它返回一个基于结构的可枚举,用于迭代委托,而不是需要为存储所有委托而分配新的数组。
// dotnet run -c Release -f net9.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private Action _action;
private int _count;
[GlobalSetup]
public void Setup()
{
_action = () => _count++;
_action += () => _count += 2;
_action += () => _count += 3;
}
[Benchmark(Baseline = true)]
public void GetInvocationList()
{
foreach (Action action in _action.GetInvocationList())
{
action();
}
}
[Benchmark]
public void EnumerateInvocationList()
{
foreach (Action action in Delegate.EnumerateInvocationList(_action))
{
action();
}
}
}
方法 | 平均时间 | 比率 | 分配大小 | 分配比率 |
---|---|---|---|---|
GetInvocationList | 32.11纳秒 | 1.00 | 48字节 | 1.00 |
EnumerateInvocationList | 11.07纳秒 | 0.34 | - | 0.00 |
反射对于涉及依赖注入的库尤为重要,因为对象构造通常以更动态的方式进行。ActivatorUtilities.CreateInstance
在这里扮演了关键角色,并且也看到了分配减少的改进。dotnet/runtime#99383 通过使用在.NET 8中引入的 ConstructorInvoker
类型,以及利用 dotnet/runtime#99175 的变化来减少需要检查的构造函数数量,显著减少了分配。dotnet/runtime#99175 通过使用在.NET 8中引入的 ConstructorInvoker
类型,以及利用 dotnet/runtime#99175 的变化来减少需要检查的构造函数数量,显著减少了分配。
// Add a <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" /> to the csproj.
// dotnet run -c Release -f net8.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using Microsoft.Extensions.DependencyInjection;
var config = DefaultConfig.Instance
.AddJob(Job.Default.WithRuntime(CoreRuntime.Core80)
.WithNuGet("Microsoft.Extensions.DependencyInjection", "8.0.0")
.WithNuGet("Microsoft.Extensions.DependencyInjection.Abstractions", "8.0.1").AsBaseline())
.AddJob(Job.Default.WithRuntime(CoreRuntime.Core90)
.WithNuGet("Microsoft.Extensions.DependencyInjection", "9.0.0-rc.1.24431.7")
.WithNuGet("Microsoft.Extensions.DependencyInjection.Abstractions", "9.0.0-rc.1.24431.7"));
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "NuGetReferences")]
public class Tests
{
private IServiceProvider _serviceProvider = new ServiceCollection().BuildServiceProvider();
[Benchmark]
public MyClass Create() => ActivatorUtilities.CreateInstance<MyClass>(_serviceProvider, 1, 2, 3);
public class MyClass
{
public MyClass() { }
public MyClass(int a) { }
public MyClass(int a, int b) { }
[ActivatorUtilitiesConstructor]
public MyClass(int a, int b, int c) { }
}
}
方法 | 运行时 | 平均时间 | 比率 | 分配大小 | 分配比率 |
---|---|---|---|---|---|
Create | .NET 8.0 | 163.60纳秒 | 1.00 | 288字节 | 1.00 |
Create | .NET 9.0 | 83.46纳秒 | 0.51 | 144字节 | 0.50 |
前面提到的 ConstructorInvoker
和 MethodInvoker
在.NET 8中被引入,作为缓存首次使用信息以使后续操作更快的方法。不引入新的公共 FieldInvoker
,dotnet/runtime#98199 通过使用内部 FieldAccessor
缓存到 FieldInfo
对象上,实现了类似的加速(dotnet/runtime#92512 也为此做出了贡献,通过将一些本地运行时实现移回C#)。根据被访问的字段的精确性质,可以取得不同程度的速度提升。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Reflection;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "NuGetReferences")]
public class Tests
{
private static object s_staticReferenceField = new object();
private object _instanceReferenceField = new object();
private static int s_staticValueField = 1;
private int _instanceValueField = 2;
private object _obj = new();
private FieldInfo _staticReferenceFieldInfo = typeof(Tests).GetField(nameof(s_staticReferenceField), BindingFlags.NonPublic | BindingFlags.Static)!;
private FieldInfo _instanceReferenceFieldInfo = typeof(Tests).GetField(nameof(_instanceReferenceField), BindingFlags.NonPublic | BindingFlags.Instance)!;
private FieldInfo _staticValueFieldInfo = typeof(Tests).GetField(nameof(s_staticValueField), BindingFlags.NonPublic | BindingFlags.Static)!;
private FieldInfo _instanceValueFieldInfo = typeof(Tests).GetField(nameof(_instanceValueField), BindingFlags.NonPublic | BindingFlags.Instance)!;
[Benchmark] public object? GetStaticReferenceField() => _staticReferenceFieldInfo.GetValue(null);
[Benchmark] public void SetStaticReferenceField() => _staticReferenceFieldInfo.SetValue(null, _obj);
[Benchmark] public object? GetInstanceReferenceField() => _instanceReferenceFieldInfo.GetValue(this);
[Benchmark] public void SetInstanceReferenceField() => _instanceReferenceFieldInfo.SetValue(this, _obj);
[Benchmark] public int GetStaticValueField() => (int)_staticValueFieldInfo.GetValue(null)!;
[Benchmark] public void SetStaticValueField() => _staticValueFieldInfo.SetValue(null, 3);
[Benchmark] public int GetInstanceValueField() => (int)_instanceValueFieldInfo.GetValue(this)!;
[Benchmark] public void SetInstanceValueField() => _instanceValueFieldInfo.SetValue(this, 4);
}
方法 | 运行时 | 平均时间 | 比率 |
---|---|---|---|
GetStaticReferenceField | .NET 8.0 | 24.839纳秒 | 1.00 |
GetStaticReferenceField | .NET 9.0 | 1.720纳秒 | 0.07 |
SetStaticReferenceField | .NET 8.0 | 41.025纳秒 | 1.00 |
SetStaticReferenceField | .NET 9.0 | 6.964纳秒 | 0.17 |
GetInstanceReferenceField | .NET 8.0 | 29.595纳秒 | 1.00 |
GetInstanceReferenceField | .NET 9.0 | 5.960纳秒 | 0.20 |
SetInstanceReferenceField | .NET 8.0 | 31.753纳秒 | 1.00 |
SetInstanceReferenceField | .NET 9.0 | 9.577纳秒 | 0.30 |
GetStaticValueField | .NET 8.0 | 43.847纳秒 | 1.00 |
GetStaticValueField | .NET 9.0 | 36.011纳秒 | 0.82 |
SetStaticValueField | .NET 8.0 | 39.462纳秒 | 1.00 |
SetStaticValueField | .NET 9.0 | 10.396纳秒 | 0.26 |
GetInstanceValueField | .NET 8.0 | 45.125纳秒 | 1.00 |
GetInstanceValueField | .NET 9.0 | 39.104纳秒 | 0.87 |
SetInstanceValueField | .NET 8.0 | 36.664纳秒 | 1.00 |
SetInstanceValueField | .NET 9.0 | 13.571纳秒 | 0.37 |
当然,如果你能避免首先使用这些昂贵的反射方法,那是很理想的。使用反射的一个原因是访问其他类型的私有成员,尽管这样做可能令人害怕,通常应该避免,但在有些情况下这是有必要的,并且高效的解决方案是高度期望的。.NET 8 中增加了 [UnsafeAccessor]
这样的机制,它允许一个类型声明一个方法,作为直接访问另一个类型的成员的有效途径。因此,例如,在这种情况下:
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Reflection;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "NuGetReferences")]
public class Tests
{
private MyClass _myClass = new MyClass(new List<int>() { 1, 2, 3 });
private FieldInfo _fieldInfo = typeof(MyClass).GetField("_list", BindingFlags.NonPublic | BindingFlags.Instance)!;
private static class Accessors
{
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_list")]
public static extern ref object GetList(MyClass myClass);
}
[Benchmark(Baseline = true)]
public object WithFieldInfo() => _fieldInfo.GetValue(_myClass)!;
[Benchmark]
public object WithUnsafeAccessor() => Accessors.GetList(_myClass);
}
public class MyClass(object list)
{
private object _list = list;
}
我得到以下结果:
方法 | 运行时 | 平均时间 | 比率 |
---|---|---|---|
WithFieldInfo | .NET 8.0 | 27.5299纳秒 | 1.00 |
WithFieldInfo | .NET 9.0 | 4.0789纳秒 | 0.15 |
WithUnsafeAccessor | .NET 8.0 | 0.5005纳秒 | 0.02 |
WithUnsafeAccessor | .NET 9.0 | 0.5499纳秒 | 0.02 |
然而,在.NET 8中,这种机制只能用于非泛型成员。现在在.NET 9中,由于 dotnet/runtime#99468 和 dotnet/runtime#99830,这种能力现在也扩展到了泛型。
// dotnet run -c Release -f net9.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Reflection;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "NuGetReferences")]
public class Tests
{
private MyClass<int> _myClass = new MyClass<int>(new List<int>() { 1, 2, 3 });
private FieldInfo _fieldInfo = typeof(MyClass<int>).GetField("_list", BindingFlags.NonPublic | BindingFlags.Instance)!;
private static class Accessors<T>
{
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_list")]
public static extern ref List<T> GetList(MyClass<T> myClass);
}
[Benchmark(Baseline = true)]
public List<int> WithFieldInfo() => (List<int>)_fieldInfo.GetValue(_myClass)!;
[Benchmark]
public List<int> WithUnsafeAccessor() => Accessors<int>.GetList(_myClass);
}
public class MyClass<T>(List<T> list)
{
private List<T> _list = list;
}
方法 | 平均时间 | 比率 |
---|---|---|
WithFieldInfo | 4.4251 |
数值计算
基本数据类型
.NET中的核心数据类型位于堆栈的最底层,并被广泛应用。因此,在每次发布时,我们都希望减少可以避免的任何开销。.NET 9也不例外,其中多个PR(Pull Requests,即拉取请求)被投入以减少对这些核心类型的各种操作的开销。
考虑DateTime
。在性能优化方面,我们通常关注“快乐路径”,即“热点路径”或“成功路径”。异常已经为错误路径增加了显著的成本,而且它们被设计为“异常”的,相对较少发生,所以我们通常不会担心这里或那里的额外操作。但是,有时一种类型的错误路径是另一种类型的成功路径。这在对Try
方法的使用上尤其如此,其中失败是通过一个bool
而不是昂贵的异常来传达的。作为分析一个常用.NET库的一部分,分析器突出显示了一些来自DateTime
处理的意外分配,这是意外的,因为我们多年来一直在努力消除这个代码区域中的分配。
实际上,在处理错误路径时,代码会在调用树深处遇到错误,它会存储有关失败的信息(例如ParseFailureKind
枚举值);然后,在回溯调用栈回到公共方法Parse
之后,它会使用这些信息抛出一个详细异常,而TryParse
则忽略它并返回false
。但是,由于代码的编写方式,那个枚举值在存储时会被装箱,导致在TryParse
返回false
时产生分配。使用TryParse
的消耗库正在将不同的数据原始类型作为解释数据的一部分进行操作,例如:
if (int.TryParse(value, out int parsedInt32)) { ... }
else if (DateTime.TryParse(value, out DateTime parsedDateTime)) { ... }
else if (double.TryParse(value, out double parsedDouble)) { ... }
else if ...
这样,它的成功路径可能包括某些原始类型TryParse
方法的错误路径。dotnet/runtime#91303通过改变信息存储方式来避免装箱,同时也减少了一些额外的开销。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8
### BigInteger
尽管不是“原始”类型,但`BigInteger`与“原始”类型处于同一领域。与`sbyte`、`short`、`int`和`long`一样,`System.Numerics.BigInteger`实现了`IBinaryInteger<>`和`ISignedNumber<>`接口。与这些固定位数的类型(分别为8位、16位、32位和64位)不同,`BigInteger`可以表示任意位数的有符号整数(在合理范围内……当前表示法允许最多`Array.MaxLength / 64`位,这意味着可以表示2^33,554,432……这是一个非常大的数字)。这种大的大小带来了性能复杂性,从历史上看,`BigInteger`并不是高吞吐量的典范。虽然还有更多可以做的事情(实际上在我写这个的时候,还有几个待处理的PR),但.NET 9已经实现了一些不错的改进。
[dotnet/runtime#91176](https://github.com/dotnet/runtime/pull/91176) 由 [@Rob-Hague](https://github.com/Rob-Hague) 提供,改进了`BigInteger`的基于`byte`的构造函数(例如`public BigInteger(byte[] value)`),通过利用`MemoryMarshal`和`BinaryPrimitives`的向量操作。特别是,这些`BigInteger`构造函数中花费大量时间的操作是遍历字节数组,将每组四个字节构建成整数,并将这些整数存储到目标`uint[]`中。但是,使用spans,整个操作都是不必要的,可以通过优化的`CopyTo`操作(实际上是一个`memcpy`)来实现,目标只是将`uint[]`重新解释为一个字节的span。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Numerics;
using System.Security.Cryptography;
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private byte[] _bytes;
[GlobalSetup]
public void Setup()
{
_bytes = new byte[10_000];
new Random(42).NextBytes(_bytes);
}
[Benchmark]
public BigInteger NewBigInteger() => new BigInteger(_bytes);
}
方法 | 运行时 | 平均值 | 比率 |
| --- | --- | --- | --- |
| NewBigInteger | .NET 8.0 | 5.886微秒 | 1.00 |
| NewBigInteger | .NET 9.0 | 1.434微秒 | 0.24
解析是创建`BigInteger`的另一种常见方式。[dotnet/runtime#95543](https://github.com/dotnet/runtime/pull/95543) 改进了解析十六进制和二进制格式值的性能(这是在.NET 9中添加了对`BigInteger`的`"b"`格式说明符的支持的基础上进行的,参见 [@lateapexearlyspeed](https://github.com/lateapexearlyspeed) 的 [dotnet/runtime#85392](https://github.com/dotnet/runtime/pull/85392))。以前,解析是逐个数字进行的,但新算法可以同时解析多个字符,对于较大输入使用向量化的实现。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Globalization;
using System.Numerics;
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private string _hex = string.Create(1024, 0, (dest, _) => new Random(42).GetItems
[Benchmark]
public BigInteger ParseHex() => BigInteger.Parse(_hex, NumberStyles.HexNumber);
}
方法 | 运行时 | 平均值 | 比率 | 分配 | 分配比率 |
| --- | --- | --- | --- | --- | --- |
| ParseHex | .NET 8.0 | 5,155.5纳秒 | 1.00 | 5208字节 | 1.00 |
| ParseHex | .NET 9.0 | 236.8纳秒 | 0.05 | 536字节 | 0.10
这不是第一次努力改善`BigInteger`的解析。例如,.NET 7包括了一个引入了新解析算法的更改。以前的算法是`O(N^2)`的数字位数,新算法具有较低的算法复杂度,但由于涉及的常数,只有在较大数字位数下才值得。这两种算法都包括在内,根据20,000位数字的阈值在它们之间切换。事实证明,经过更多分析,这个阈值远高于实际需要,并且 [@kzrnm](https://github.com/kzrnm) 的 [dotnet/runtime#97101](https://github.com/dotnet/runtime/pull/97101) 将该阈值降低到了一个更小的值(1233)。此外,[@kzrnm](https://github.com/kzrnm) 的 [dotnet/runtime#97589](https://github.com/dotnet/runtime/pull/97589) 进一步改进了解析,通过a)识别在解析过程中使用的乘数(将数字下移以留出添加下一个集合的空间)包含许多可以在此操作中忽略的前导零,以及b)在解析10的幂时,尾随零可以更有效地计算。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Numerics;
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private string _digits = string.Create(2000, 0, (dest, _) => new Random(42).GetItems
[Benchmark]
public BigInteger ParseDecimal() => BigInteger.Parse(_digits);
}
方法 | 运行时 | 平均值 | 比率 | 分配 | 分配比率 |
| --- | --- | --- | --- | --- | --- |
| ParseDecimal | .NET 8.0 | 24.60微秒 | 1.00 | 5528字节 | 1.00 |
| ParseDecimal | .NET 9.0 | 18.95微秒 | 0.77 | 856字节 | 0.15
一旦有了`BigInteger`,当然可以对其进行各种操作。`BigInteger.Equals`被 [dotnet/runtime#91416](https://github.com/dotnet/runtime/pull/91416) 由 [@Rob-Hague](https://github.com/Rob-Hague) 改进,将实现方式从逐个元素遍历每个`BigInteger`背后的数组,改为使用优化的`MemoryExtensions.SequenceEqual`。[dotnet/runtime#104513](https://github.com/dotnet/runtime/pull/104513) 由 [@Rob-Hague](https://github.com/Rob-Hague) 改进了`BigInteger.IsPowerOfTwo`,同样通过替换手动遍历元素的方式,使用`ContainsAnyExcept`来检查是否所有元素在某个特定点之后都是0。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Numerics;
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private BigInteger _value1, _value2;
[GlobalSetup]
public void Setup()
{
var value1 = new byte[10_000];
new Random(42).NextBytes(value1);
_value1 = new BigInteger(value1);
_value2 = new BigInteger(value1.AsSpan().ToArray());
}
[Benchmark]
public bool Equals() => _value1 == _value2;
}
方法 | 运行时 | 平均值 | 比率 |
| --- | --- | --- | --- |
| Equals | .NET 8.0 | 1,110.38纳秒 | 1.00 |
| Equals | .NET 9.0 | 79.80纳秒 | 0.07
[dotnet/runtime#92208](https://github.com/dotnet/runtime/pull/92208) 由 [@kzrnm](https://github.com/kzrnm) 改进了`BigInteger.Multiply`,特别是在第一个值远大于第二个值时。
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Numerics;
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private BigInteger _value1 = BigInteger.Parse(string.Concat(Enumerable.Repeat("1234567890", 1000)));
private BigInteger _value2 = BigInteger.Parse(string.Concat(Enumerable.Repeat("1234567890", 300)));
[Benchmark]
public BigInteger MultiplyLargeSmall() => _value1 * _value2;
}
方法 | 运行时 | 平均值 | 比率 |
| --- | --- | --- | --- |
| MultiplyLargeSmall | .NET 8.0 | 231.0微秒 | 1.00 |
| MultiplyLargeSmall | .NET 9.0 | 118.8微秒 | 0.51
最后,除了解析,`BigInteger`的格式化也看到了一些改进。[dotnet/runtime#100181](https://github.com/dotnet/runtime/pull/100181) 去除了格式化过程中发生的各种临时缓冲区分配,并优化了各种计算以减少格式化这些值时的开销。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Numerics;
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private BigInteger _value = BigInteger.Parse(string.Concat(Enumerable.Repeat("1234567890", 300)));
private char[] _dest = new char[10_000];
[Benchmark]
public bool TryFormat() => _value.TryFormat(_dest, out _);
}
方法 | 运行时 | 平均值 | 比率 | 分配 | 分配比率 |
| --- | --- | --- | --- | --- | --- |
| TryFormat | .NET 8.0 | 102.49微秒 | 1.00 | 7456字节 | 1.00 |
| TryFormat | .NET 9.0 | 94.52微秒 | 0.92 | | 0.00
### 张量原语
在过去的几个版本中,.NET 对数值处理给予了极大的关注。现在,大量数值操作不仅暴露在每个数值类型上,还暴露在这些类型实现的通用接口上。但有时候,您希望对一组值而不是单个值执行相同的操作,为此,我们有了 `TensorPrimitives`。.NET 8 引入了 `TensorPrimitive` 类型,它提供了一系列数值 API,但针对的是值数组而不是单个值。例如,`float` 类型有一个 `Cosh` 方法:
```csharp
public static float Cosh(float x);
这个方法提供了 双曲余弦 的一个 float
,而在 IHyperbolicFunctions<TSelf>
接口上也有相应的方法:
static abstract TSelf Cosh(TSelf x);
TensorPrimitives
也对应有一个方法,但它接受的是值的 span,而不是单个 float
,并且它不是返回结果,而是将结果写入提供的目标 span:
public static void Cosh(ReadOnlySpan<float> x, Span<float> destination);
在 .NET 8 中,TensorPrimitives
提供了大约 40 个这样的方法,并且只针对 float
类型。现在,在 .NET 9 中,这一功能得到了显著扩展。TensorPrimitives
上现在有超过 200 个重载,覆盖了大多数在通用数学接口上暴露的数值操作(还有一些不是的),并且这些方法都是泛型的,因此可以与许多数据类型一起使用,而不仅仅是 float
。例如,虽然它保留了向后二进制兼容性的 float
特定 Cosh
重载,但 TensorPrimitives
现在也有这个泛型重载:
public static void Cosh<T>(ReadOnlySpan<T> x, Span<T> destination)
where T : IHyperbolicFunctions<T>
这样就可以使用 Half
、float
、double
、NFloat
或任何您可能有的自定义浮点类型,只要这些类型实现了相关接口。大多数这些操作也是向量化的,这意味着它不仅仅是一个围绕相应标量函数的简单循环。
// 在 csproj 文件中添加 <PackageReference Include="System.Numerics.Tensors" Version="9.0.0" />。
// 使用 dotnet run -c Release -f net9.0 --filter "*" 运行。
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Numerics.Tensors;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private float[] _source, _destination;
[GlobalSetup]
public void Setup()
{
var r = new Random(42);
_source = Enumerable.Range(0, 1024).Select(_ => (float)r.NextSingle()).ToArray();
_destination = new float[1024];
}
[Benchmark(Baseline = true)]
public void ManualLoop()
{
ReadOnlySpan<float> source = _source;
Span<float> destination = _destination;
for (int i = 0; i < source.Length; i++)
{
destination[i] = float.Cosh(source[i]);
}
}
[Benchmark]
public void BuiltIn()
{
TensorPrimitives.Cosh<float>(_source, _destination);
}
}
方法 | 平均时间 | 比率 |
---|---|---|
ManualLoop | 7,804.4 纳秒 | 1.00 |
BuiltIn | 621.6 纳秒 | 0.08 |
大量的 API 可用,其中大部分在简单循环上都能看到类似或更好的性能提升。以下是在 .NET 9 中目前可用的方法,它们都是泛型方法,并且大多数方法都有多个重载:
Abs, Acosh, AcosPi, Acos, AddMultiply, Add, Asinh, AsinPi, Asin, Atan2Pi, Atan2, Atanh, AtanPi, Atan, BitwiseAnd, BitwiseOr, Cbrt, Ceiling, ConvertChecked, ConvertSaturating, ConvertTruncating, ConvertToHalf, ConvertToSingle, CopySign, CosPi, Cos, Cosh, CosineSimilarity, DegreesToRadians, Distance, Divide, Dot, Exp, Exp10M1, Exp10, Exp2M1, Exp2, ExpM1, Floor, FusedMultiplyAdd, HammingDistance, HammingBitDistance, Hypot, Ieee754Remainder, ILogB, IndexOfMaxMagnitude, IndexOfMax, IndexOfMinMagnitude, IndexOfMin, LeadingZeroCount, Lerp, Log2, Log2P1, LogP1, Log, Log10P1, Log10, MaxMagnitude, MaxMagnitudeNumber, Max, MaxNumber, MinMagnitude, MinMagnitudeNumber, Min, MinNumber, MultiplyAdd, MultiplyAddEstimate, Multiply, Negate, Norm, OnesComplement, PopCount, Pow, ProductOfDifferences, ProductOfSums, Product, RadiansToDegrees, ReciprocalEstimate, ReciprocalSqrtEstimate, ReciprocalSqrt, Reciprocal, RootN, RotateLeft, RotateRight, Round, ScaleB, ShiftLeft, ShiftRightArithmetic, ShiftRightLogical, Sigmoid, SinCosPi, SinCos, Sinh, SinPi, Sin, SoftMax, Sqrt, Subtract, SumOfMagnitudes, SumOfSquares, Sum, Tanh, TanPi, Tan, TrailingZeroCount, Truncate, Xor
在其他操作和数据类型上,可能的速度提升更为显著;例如,这是一个手动实现两个输入 byte
数组汉明距离的简单实现(汉明距离是两个输入之间不同的元素数量),以及使用 TensorPrimitives.HammingDistance<byte>
的实现:
// 在 csproj 文件中添加 <PackageReference Include="System.Numerics.Tensors" Version="9.0.0" />。
// 使用 dotnet run -c Release -f net9.0 --filter "*" 运行。
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Numerics.Tensors;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private byte[] _x, _y;
[GlobalSetup]
public void Setup()
{
var r = new Random(42);
_x = Enumerable.Range(0, 1024).Select(_ => (byte)r.Next(0, 256)).ToArray();
_y = Enumerable.Range(0, 1024).Select(_ => (byte)r.Next(0, 256)).ToArray();
}
[Benchmark(Baseline = true)]
public int ManualLoop()
{
ReadOnlySpan<byte> source = _x;
Span<byte> destination = _y;
int count = 0;
for (int i = 0; i < source.Length; i++)
{
if (source[i] != destination[i])
{
count++;
}
}
return count;
}
[Benchmark]
public int BuiltIn() => TensorPrimitives.HammingDistance<byte>(_x, _y);
}
方法 | 平均时间 | 比率 |
---|---|---|
ManualLoop | 484.61 纳秒 | 1.00 |
BuiltIn | 15.76 纳秒 | 0.03 |
为了实现这一功能,有一系列 PR 被合并。通过 dotnet/runtime#94555、dotnet/runtime#97192、dotnet/runtime#97572、dotnet/runtime#101435、dotnet/runtime#103305 和 dotnet/runtime#104651,增加了泛型方法的表面面积。然后,更多的 PR 添加或改进了向量化,包括 dotnet/runtime#97361、dotnet/runtime#97623、dotnet/runtime#97682、dotnet/runtime#98281、dotnet/runtime#97835、dotnet/runtime#97846、dotnet/runtime#97874、dotnet/runtime#97999、dotnet/runtime#98877、dotnet/runtime#103214 和 dotnet/runtime#103820,由 @neon-sunset 提出。
在这一系列工作中,我们也认识到,我们已经有了标量操作,也有了作为 span 的无限数量元素的运算,但有效地执行后者需要实际上在各种 Vector128<T>
、Vector256<T>
和 Vector512<T>
类型上也有相同的操作集,因为这些操作的典型结构会同时处理元素向量。因此,已经朝着在这些向量类型上也暴露相同操作集的方向取得了进展。这已经在 dotnet/runtime#104848、dotnet/runtime#102181、dotnet/runtime#103837、dotnet/runtime#97114 和 dotnet/runtime#96455 中实现。
其他相关的数值类型也看到了改进。四元数乘法在 dotnet/runtime#96624 中由 @TJHeuvel 向量化,而在 dotnet/runtime#103527 中加速了 Quaternion
、Plane
、Vector2
、Vector3
、Vector4
、Matrix4x4
和 Matrix3x2
的各种操作。
// 使用 dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0 运行。
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Numerics;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private Quaternion _value1 = Quaternion.CreateFromYawPitchRoll(0.5f, 0.3f, 0.2f);
private Quaternion _value2 = Quaternion.CreateFromYawPitchRoll(0.1f, 0.2f, 0.3f);
[Benchmark]
public Quaternion Multiply() => _value1 * _value2;
}
方法 | 运行时 | 平均时间 | 比率 |
---|---|---|---|
Multiply | .NET 8.0 | 3.064 纳秒 | 1.00 |
Multiply | .NET 9.0 | 1.086 纳秒 | 0.35 |
dotnet/runtime#102301 还将许多类型(如 Quaternion
)的实现从 JIT/原生代码移到了 C#,这只有可能是因为许多其他改进。
字符串、数组、范围(Spans)
正则表达式
在过去的几年中,.NET中的正则表达式支持得到了大量的关注和改进。在.NET 5中,该实现经历了彻底的更新,从而带来了显著的性能提升[1]。随后,在.NET 7中,不仅再次实现了巨大的性能提升,而且还引入了源生成器、新的非回溯实现等新功能[2]。在.NET 8中,通过使用SearchValues
,它还看到了额外的性能改进[3]。
现在,在.NET 9中,这一趋势仍在继续。首先,重要的是要认识到,到目前为止讨论的大多数更改都是隐式适用于Regex
的。Regex
已经使用了SearchValues
,因此对SearchValues
的改进会直接惠及Regex
(这是我非常喜欢在堆栈最低层工作的原因之一:底层的改进具有乘法效应,即直接使用它们会改进,但通过中间组件间接使用也会立即变得更好)。除此之外,Regex
还增加了对SearchValues
的依赖。
目前支持Regex
的引擎有多个:
- 解释器,当你没有明确要求使用其他引擎时就会使用它。
- 基于反射发射的编译器,在运行时为特定的正则表达式和选项生成自定义IL。当你指定
RegexOptions.Compiled
时就会使用它。 - 非回溯引擎,它不支持
Regex
的所有功能,但保证了输入长度的O(N)
吞吐量。当你指定RegexOptions.NonBacktracking
时就会使用它。 - 源生成器,它与编译器非常相似,只不过在构建时生成C#代码而不是在运行时生成IL。使用
[GeneratedRegex(...)]
时就会使用它。
截至dotnet/runtime#98791、dotnet/runtime#103496和dotnet/runtime#98880,除了解释器之外的所有引擎都利用了新的SearchValues<string>
支持(解释器也可以使用,但我们的假设是有人在使用解释器是为了优化Regex
构造的速度,并且选择使用SearchValues<string>
的分析过程可能会花费可衡量的时间)。最好的方式是通过源生成器来观察这一变化,因为我们可以在.NET 8和.NET 9中轻松检查它输出的代码。考虑以下代码:
using System.Text.RegularExpressions;
internal partial class Example
{
[GeneratedRegex("(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday): (.*)", RegexOptions.IgnoreCase)]
public static partial Regex ParseEntry();
}
在Visual Studio中,你可以右键点击ParseEntry
,选择“转到定义”,工具会将你带到正则表达式源生成器生成的这个模式的C#代码(这个模式正在寻找一个星期几,然后是冒号,接着是任意文本,并将星期几和随后的文本都捕获到后续探索的捕获组中)。生成的代码包含两个相关的方法:TryFindNextPossibleStartingPosition
方法,它用于尽可能快速地跳到第一个可能匹配的位置,以及TryMatchAtCurrentPosition
方法,它在那个位置执行完整的匹配尝试。对我们这里的用途来说,我们关注TryFindNextPossibleStartingPosition
,因为那里是最能体现SearchValues
影响的地方。
编码
.NET 自发布之初就支持了 Base64 编码,提供了如 Convert.ToBase64String
和 Convert.FromBase64CharArray
这样的方法。最近,又增加了一系列与 Base64 相关的 API,包括 Convert
上的基于 span 的 API,以及一个专门的 System.Buffers.Text.Base64
,其中包含用于在任意字节和 UTF8 文本之间编码和解码的方法,以及最近用于非常高效地检查 UTF8 和 UTF16 文本是否表示有效 Base64 负载的方法。
Base64 是一种相对简单的编码方案,可以将任意二进制数据转换为 ASCII 文本。它将输入数据分成每组 6 位(2^6 等于 64 个可能值),并将这些值映射到 Base64 字母表中的特定字符:26 个大写 ASCII 字母、26 个小写 ASCII 字母、10 个 ASCII 数字、'+'
和 '/'
。虽然这是一种极其流行的编码机制,但由于字母表的选择,它在某些用例中会遇到问题。在 URI 中包含 Base64 数据可能是有问题的,因为 '+'
和 '/'
都在 URI 中有特殊含义,用于填充 Base64 数据的特殊 '='
符号也是如此。这意味着除了 Base64 编码数据之外,结果数据可能还需要进行 URL 编码才能使用,这既会消耗额外的时间,还会进一步增加负载的大小。为了解决这个问题,引入了一个变体,即 Base64Url,它去除了填充的需要,并使用了一个稍微不同的字母表,用 '-'
代替 '+'
,用 '_'
代替 '/'
。Base64Url 在多个领域中使用,包括作为 JSON Web 令牌 (JWT) 的一部分,其中用它来编码令牌的每个部分。
虽然 .NET 很早就有了 Base64 支持,但一直没有 Base64Url 支持。因此,开发者不得不自己实现。许多人通过在 Convert
或 Base64
中的 Base64 实现之上叠加来实现。例如,下面是 ASP.NET 的 WebEncoders.Base64UrlEncode
在 .NET 8 中实现的核心部分:
private static int Base64UrlEncode(ReadOnlySpan<byte> input, Span<char> output)
{
if (input.IsEmpty)
return 0;
Convert.TryToBase64Chars(input, output, out int charsWritten);
for (var i = 0; i < charsWritten; i++)
{
var ch = output[i];
if (ch == '+') output[i] = '-';
else if (ch == '/') output[i] = '_';
else if (ch == '=') return i;
}
return charsWritten;
}
显然,我们可以编写更多代码使其更高效,但有了 .NET 9,我们就不再需要这样做。随着 dotnet/runtime#102364,.NET 现在有一个功能完整的 Base64Url
类型,而且效率也非常高。实际上,它的实现几乎与 Base64
和 Convert
上的相同功能共享,使用泛型技巧以优化的方式替换不同的字母表。(ASP.NET 的实现也已经更新,开始使用 Base64Url
,参见 dotnet/aspnetcore#56959 和 dotnet/aspnetcore#57050)。
// dotnet run -c Release -f net9.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers.Text;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private byte[] _data;
private char[] _destination = new char[Base64.GetMaxEncodedToUtf8Length(1024 * 1024)];
[GlobalSetup]
public void Setup()
{
_data = new byte[1024 * 1024];
new Random(42).NextBytes(_data);
}
[Benchmark(Baseline = true)]
public int Old() => Base64UrlOld(_data, _destination);
[Benchmark]
public int New() => Base64Url.EncodeToChars(_data, _destination);
static int Base64UrlOld(ReadOnlySpan<byte> input, Span<char> output)
{
if (input.IsEmpty)
return 0;
Convert.TryToBase64Chars(input, output, out int charsWritten);
for (var i = 0; i < charsWritten; i++)
{
var ch = output[i];
if (ch == '+')
{
output[i] = '-';
}
else if (ch == '/')
{
output[i] = '_';
}
else if (ch == '=')
{
return i;
}
}
return charsWritten;
}
}
方法 | 平均值 | 比率 |
---|---|---|
Old | 1,314.20 us | 1.00 |
New | 81.36 us | 0.06 |
这还受益于一系列改进了 Base64
性能,因此也改进了 Base64Url
的变化,因为它们现在共享相同的代码。dotnet/runtime#92241 由 @DeepakRajendrakumaran 添加了 AVX512 优化的 Base64 编码/解码实现,而 dotnet/runtime#95513 和 dotnet/runtime#100589 由 @SwapnilGaikwad 和 @SwapnilGaikwad 分别为 Arm64 优化了 Base64 编码和解码。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private byte[] _toEncode;
private char[] _encoded;
[GlobalSetup]
public void Setup()
{
_toEncode = new byte[1000];
new Random(42).NextBytes(_toEncode);
_encoded = new char[Convert.ToBase64String(_toEncode).Length];
}
[Benchmark(Baseline = true)]
public int Old() => Base64UrlOld(_toEncode, _encoded);
[Benchmark]
public int New() => Base64Url.EncodeToChars(_toEncode, _encoded);
static int Base64UrlOld(ReadOnlySpan<byte> input, Span<char> output)
{
if (input.IsEmpty)
return 0;
Convert.TryToBase64Chars(input, output, out int charsWritten);
for (var i = 0; i < charsWritten; i++)
{
var ch = output[i];
if (ch == '+')
{
output[i] = '-';
}
else if (ch == '/')
{
output[i] = '_';
}
else if (ch == '=')
{
return i;
}
}
return charsWritten;
}
}
方法 | 运行时 | 平均值 | 比率 |
---|---|---|---|
Old | .NET 8.0 | 104.55 ns | 1.00 |
Old | .NET 9.0 | 60.19 ns | 0.58 |
另一种更简单的编码形式是十六进制编码,它实际上使用一个包含 16 个字符的字母表(对于每 4 位一组),而不是 64 个字符(对于每 6 位一组)。.NET 5 引入了 Convert.ToHexString
一系列方法,这些方法接受一个输入 ReadOnlySpan<byte>
或 byte[]
,并生成一个输出 string
,其中每输入字节对应两个十六进制字符。该编码选择的字母表是十六进制的字符‘0’到‘9’以及大写的‘A’到‘F’。这在需要大写字母的情况下是很好的,但有时你可能需要小写的‘a’到‘f’。因此,现在经常看到这样的调用:
string result = Convert.ToHexString(bytes).ToLowerInvariant();
其中 ToHexString
生成一个字符串,然后 ToLowerInvariant
可能会生成另一个(“可能”是因为只有当数据中包含字母时,它才需要创建一个新的字符串)。
随着 .NET 9 和 dotnet/runtime#92483 的引入,从 @determ1ne 新的 Convert.ToHexStringLower
方法可以直接生成小写版本;该 PR 还引入了 TryToHexString
和 TryToHexStringLower
方法,这些方法可以直接将格式化到提供的目标 span 中,而不是分配任何内容。
// dotnet run -c Release -f net9.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers.Text;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private byte[] _data = new byte[100];
private char[] _dest = new char[200];
[GlobalSetup]
public void Setup() => new Random(42).NextBytes(_data);
[Benchmark(Baseline = true)]
public string Old() => Convert.ToHexString(_data).ToLowerInvariant();
[Benchmark]
public string New() => Convert.ToHexStringLower(_data).ToLowerInvariant();
[Benchmark]
public bool NewTry() => Convert.TryToHexStringLower(_data, _dest, out int charsWritten);
}
方法 | 平均值 | 比率 | 分配 | 分配比率 |
---|---|---|---|---|
Old | 136.69 ns | 1.00 | 848 B | 1.00 |
New | 119.09 ns | 0.87 | 424 B | 0.50 |
NewTry | 21.97 ns | 0.16 | – | 0.00 |
在 .NET 5 引入 Convert.ToHexString
之前,实际上 .NET 中已经有了一些将字节转换为十六进制的功能:BitConverter.ToString
。BitConverter.ToString
做的是 Convert.ToHexString
现在正在做的事情,只是在每两个十六进制字符之间插入了一个短划线(即每字节之间)。因此,对于想要等效于 ToHexString
的人来说,通常会编写 BitConverter.ToString(bytes).Replace("-", "")
这样的代码。事实上,想要去除短划线的操作是非常常见的,GitHub Copilot 就会为此建议: 当然,这个操作比使用 ToHexString
要昂贵得多(且复杂得多),所以最好能够帮助开发者切换到 ToHexString{Lower}
。这正是 dotnet/roslyn-analyzers#6967 由 @mpidash 所做的。现在,CA1872 会标记出可以被转换为 Convert.ToHexString
的两种情况: 和可以被转换为 Convert.ToHexStringLower
的情况: 这对性能有好处,因为差异是相当明显的:
// dotnet run -c Release -f net9.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private byte[] _bytes = Enumerable.Range(0, 100).Select(i => (byte) i).ToArray();
[Benchmark(Baseline = true)]
public string WithBitConverter() => BitConverter.ToString(_bytes).Replace("-", "").ToLowerInvariant();
[Benchmark]
public string WithConvert() => Convert.ToHexStringLower(_bytes);
}
方法 | 平均值 | 比率 | 分配 | 分配比率 |
---|---|---|---|---|
WithBitConverter | 1,707.46 ns | 1.00 | 1472 B | 1.00 |
WithConvert | 61.66 ns | 0.04 | 424 B | 0.29 |
导致这种差异的原因有很多,包括显然的一个:Replace
需要搜索输入,找到所有的短划线,并分配一个新的不包含短划线的字符串。此外,BitConverter.ToString
本身也比 Convert.ToHexString
要慢,因为它需要插入短划线,这导致它无法轻易地使用向量指令。
相反,Convert.FromHexString
从字符串中解码十六进制数据,并将其转换回新的 byte[]
。dotnet/runtime#86556 由 @hrrrrustic 添加了 FromHexString
的重载,这些重载写入目标 span 而不是每次分配一个新的 byte[]
。
// dotnet run -c Release -f net9.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private string _hex = string.Concat(Enumerable.Repeat("0123456789abcdef", 10));
private byte[] _dest = new byte[100];
[Benchmark(Baseline = true)]
public byte[] FromHexString() => Convert.FromHexString(_hex);
[Benchmark]
public OperationStatus FromHexStringSpan() => Convert.FromHexString(_hex.AsSpan(), _dest, out int charsWritten, out int bytesWritten);
}
方法 | 平均值 | 比率 | 分配 | 分配比率 |
---|---|---|---|---|
FromHexString | 33.78 ns | 1.00 | 104 B | 1.00 |
FromHexStringSpan | 18.22 ns | 0.54 | – | 0.00 |
核心集合
正如在.NET 8中的性能改进中所提到的,Dictionary<TKey, TValue>
是.NET中所有集合中最受欢迎的集合之一,远远领先(这可能对任何人来说都不足为奇)。而在.NET 9中,它将获得我一直以来渴望的以性能为重点的功能。
字典最常见的用途之一是作为缓存,通常以string
键索引。在高性能场景中,这种缓存经常用于实际可能没有string
对象,但文本信息以其他形式存在的情况,比如ReadOnlySpan<char>
(或者对于以UTF8数据索引的缓存,键可能是byte[]
,但进行查找的数据只作为ReadOnlySpan<byte>
提供)。在这种情况下,对字典进行查找可能需要从数据中实例化字符串,这会使查找成本更高(在某些情况下甚至可能完全抵消缓存的目的),或者需要使用一种能够处理数据多种形式的自定义键类型,这通常也要求一个自定义的比较器。
.NET 9通过引入IAlternateEqualityComparer<TAlternate, T>
解决了这个问题。一个实现了IEqualityComparer<T>
的比较器现在可以一次或多次实现这个额外的接口,针对其他TAlternate
类型,使得该比较器能够将不同的类型视为T
。然后,像Dictionary<TKey, TValue>
这样的类型可以暴露出以TAlternateKey
为参数的额外方法,如果那个Dictionary<TKey, TValue>
的比较器实现了IAlternateEqualityComparer<TAlternateKey, TKey>
,这些方法就可以正常工作。在.NET 9中,通过dotnet/runtime#102907和dotnet/runtime#103191,Dictionary<TKey, TValue>
、ConcurrentDictionary<TKey, TValue>
、FrozenDictionary<TKey, TValue>
、HashSet<T>
和FrozenSet<T>
都做了这样的实现。例如,这里我有一个用来说明每个单词在span中出现的次数的Dictionary<string, int>
:
static Dictionary<string, int> CountWords1(ReadOnlySpan<char> input)
{
Dictionary<string, int> result = new(StringComparer.OrdinalIgnoreCase);
foreach (ValueMatch match in Regex.EnumerateMatches(input, @"\b\w+\b"))
{
ReadOnlySpan<char> word = input.Slice(match.Index, match.Length);
string key = word.ToString();
result[key] = result.TryGetValue(key, out int count) ? count + 1 : 1;
}
return result;
}
由于我返回的是一个Dictionary<string, int>
,所以当然需要为每个ReadOnlySpan<char>
实例化字符串以便在字典中存储它,但应该只在上次找到单词时这样做。我不应该每次都创建一个新的字符串,然而我却不得不在TryGetValue
调用时这样做。现在随着.NET 9,一个新的GetAlternateLookup
方法(以及相应的TryGetAlternateLookup
)存在,它可以产生一个单独的值类型包装器,使得可以使用一个替代的键类型进行所有相关的操作,这意味着我现在可以写出这样的代码:
static Dictionary<string, int> CountWords2(ReadOnlySpan<char> input)
{
Dictionary<string, int> result = new(StringComparer.OrdinalIgnoreCase);
Dictionary<string, int>.AlternateLookup<ReadOnlySpan<char>> alternate = result.GetAlternateLookup<ReadOnlySpan<char>>();
foreach (ValueMatch match in Regex.EnumerateMatches(input, @"\b\w+\b"))
{
ReadOnlySpan<char> word = input.Slice(match.Index, match.Length);
alternate[word] = alternate.TryGetValue(word, out int count) ? count + 1 : 1;
}
return result;
}
注意这里缺少了一个ToString()
调用,这意味着对于已经看到的单词,这里不会发生分配。那么alternate[word] = ...
部分是如何工作的呢?当然不是将ReadOnlySpan<char>
存储到字典中。而是IAlternateEqualityComparer<TAlternate, T>
看起来是这样的:
public interface IAlternateEqualityComparer<in TAlternate, T>
where TAlternate : allows ref struct
where T : allows ref struct
{
bool Equals(TAlternate alternate, T other);
int GetHashCode(TAlternate alternate);
T Create(TAlternate alternate);
}
Equals
和GetHashCode
应该看起来很熟悉,与IEqualityComparer<T>
的对应成员的主要区别在于第一个参数的类型。但接着有一个额外的Create
方法。这个方法接受一个TAlternate
并返回一个T
,这给了比较器从一种类型映射到另一种类型的能力。那么我们之前看到的设置器(以及其他方法如TryAdd
)就能够使用这个方法,在需要的时候才从TAlternate
创建TKey
,因此这个设置器在单词不在集合中时才会为单词分配字符串。
对于熟悉ReadOnlySpan<T>
的人来说,可能还有另一个令人困惑的问题:Dictionary<string, int>.AlternateLookup<ReadOnlySpan<char>>
是有效的吗?ref struct
如span不能用作泛型参数,对吧?是的……直到现在。C# 13和.NET 9现在允许ref struct
s作为泛型参数,但泛型参数需要通过新的allows ref struct
约束(或者我们中有些人经常称之为“反约束”)来同意。有一些方法可以对未约束的泛型参数执行操作,比如将其转换为object
或将它存储在类的字段中,这些操作对ref struct
是不允许的。通过在泛型参数上添加allows ref struct
,它告诉编译器编译消费者可以指定一个ref struct
,并告诉编译器编译具有约束的类型或方法,泛型实例可能是一个ref struct
,因此泛型参数只能在ref struct
合法的情况下使用。
当然,这一切都依赖于提供的比较器实现了适当的IAlternateEqualityComparer<TAlternate, T>
接口;如果没有,调用GetAlternateLookup
将抛出异常,调用TryGetAlternateLookup
将返回false
。你可以使用任何比较器,只要该比较器为所需的替代键类型提供了这个接口的实现。但是,由于string
和ReadOnlySpan<char>
如此常见,如果不存在内置支持,那就太遗憾了。确实,通过上述PR,所有内置的StringComparer
类型都实现了IAlternateEqualityComparer<ReadOnlySpan<char>, string>
。这就是为什么之前的代码示例中的Dictionary<string, int> result = new(StringComparer.OrdinalIgnoreCase);
行是成功的,因为随后的result.GetAlternateLookup<ReadOnlySpan<char>>()
调用将成功地在提供的比较器上找到这个接口。
// dotnet run -c Release -f net9.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private static readonly string s_input = new HttpClient().GetStringAsync("https://gutenberg.org/cache/epub/2600/pg2600.txt").Result;
[GeneratedRegex(@"\b\w+\b")]
private static partial Regex WordParser();
[Benchmark(Baseline = true)]
public Dictionary<string, int> CountWords1()
{
ReadOnlySpan<char> input = s_input;
Dictionary<string, int> result = new(StringComparer.OrdinalIgnoreCase);
foreach (ValueMatch match in WordParser().EnumerateMatches(input))
{
ReadOnlySpan<char> word = input.Slice(match.Index, match.Length);
string key = word.ToString();
result[key] = result.TryGetValue(key, out int count) ? count + 1 : 1;
}
return result;
}
[Benchmark]
public Dictionary<string, int> CountWords2()
{
ReadOnlySpan<char> input = s_input;
Dictionary<string, int> result = new(StringComparer.OrdinalIgnoreCase);
Dictionary<string, int>.AlternateLookup<ReadOnlySpan<char>> alternate = result.GetAlternateLookup<ReadOnlySpan<char>>();
foreach (ValueMatch match in WordParser().EnumerateMatches(input))
{
ReadOnlySpan<char> word = input.Slice(match.Index, match.Length);
alternate[word] = alternate.TryGetValue(word, out int count) ? count + 1 : 1;
}
return result;
}
}
方法 | Mean | Ratio | Allocated | Alloc Ratio |
---|---|---|---|---|
CountWords1 | 60.35 ms | 1.00 | 20.67 MB | 1.00 |
CountWords2 | 57.40 ms | 0.95 | 2.54 MB | 0.12 |
注意分配的巨大减少。 |
为了好玩,我们可以进一步扩展这个例子。.NET 6引入了CollectionsMarshal.GetValueRefOrAddDefault
方法,它返回一个可写入的ref
,指向给定TKey
的TValue
的实际存储位置,如果不存在则创建该条目。这对于上面的操作非常有用,因为它可以帮助避免额外的字典查找。没有它,我们需要在TryGetValue
部分做一次查找,然后在设置器部分再做一次查找,但有了它,我们只需要在GetValueRefOrAddDefault
部分做一次查找,然后就不需要额外的查找,因为我们已经有了可以直接写入的位置。由于这个基准测试中的查找是一个成本较高的操作,消除其中一半的查找可以显著降低操作的成本。作为对替代键工作的扩展,GetValueRefOrAddDefault
的新重载与它一起添加,使得可以使用TAlternateKey
执行相同的操作。
// dotnet run -c Release -f net9.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private static readonly string s_input = new HttpClient().GetStringAsync("https://gutenberg.org/cache/epub/2600/pg2600.txt").Result;
[GeneratedRegex(@"\b\w+\b")]
private static partial Regex WordParser();
[Benchmark(Baseline = true)]
public Dictionary<string, int> CountWords1()
{
ReadOnlySpan<char> input = s_input;
Dictionary<string, int> result = new(StringComparer.OrdinalIgnoreCase);
foreach (ValueMatch match in WordParser().EnumerateMatches(input))
{
ReadOnlySpan<char> word = input.Slice(match.Index, match.Length);
string key = word.ToString();
result[key] = result.TryGetValue(key, out int count) ? count + 1 : 1;
}
return result;
}
[Benchmark]
public Dictionary<string, int> CountWords2()
{
ReadOnlySpan<char> input = s_input;
Dictionary<string, int> result = new(StringComparer.OrdinalIgnoreCase);
Dictionary<string, int>.AlternateLookup<ReadOnlySpan<char>> alternate = result.GetAlternateLookup<ReadOnlySpan<char>>();
foreach (ValueMatch match in WordParser().EnumerateMatches(input))
{
ReadOnlySpan<char> word = input.Slice(match.Index, match.Length);
alternate[word] = alternate.TryGetValue(word, out int count) ? count + 1 : 1;
}
return result;
}
[Benchmark]
public
## 压缩
.NET核心库的一个重要目标是实现尽可能的平台无关性。一般来说,无论使用哪种操作系统或硬件,事情都应该以相同的方式表现,除非确实与操作系统或硬件特定(例如,我们故意不去掩盖不同文件系统的卷标差异)。为了这个目标,我们通常尽可能地使用C#来实现,只有在必要时才会将工作委托给操作系统和原生平台库;例如,默认的.NET HTTP实现`System.Net.Http.SocketsHttpHandler`就是基于`System.Net.Sockets`、`System.Net.Dns`等编写的C#代码,并受到每个平台上的套接字实现的影响(其中行为由操作系统实现),通常在任何地方运行时都会保持一致。
然而,确实有一些特定的地方,我们主动选择更多地依赖平台上的某些功能。这里最重要的案例是加密,我们希望依赖操作系统来实现这类与安全相关的功能;例如,在Windows上,TLS是通过`SChannel`组件实现的,在Linux上是通过`OpenSSL`实现的,而在macOS上是通过`SecureTransport`实现的。另一个值得注意的案例是压缩,特别是`zlib`。我们很久以前就决定简单地使用操作系统随附的`zlib`。但这带来了一系列的影响。首先,Windows并不随库形式提供`zlib`,因此针对Windows的.NET构建仍然必须包含自己的`zlib`副本。然后,由于决定分发由Intel生产的`zlib`变体,这进一步改进但也复杂化了情况,该变体针对x64进行了很好的优化,但对其他硬件(如Arm64)的关注较少。而且,最近`intel/zlib`仓库被归档,不再由Intel积极维护。
为了简化问题,提高跨更多平台的的一致性和性能,并转向一个得到积极支持和不断进化的实现,这些变化将从.NET 9开始。多亏了一系列的PR,特别是[dotnet/runtime#104454](https://github.com/dotnet/runtime/pull/104454)和[dotnet/runtime#105771](https://github.com/dotnet/runtime/pull/105771),.NET 9现在在Windows、Linux和macOS上内置了基于较新的[`zlib-ng/zlib-ng`](https://github.com/zlib-ng/zlib-ng)的`zlib`功能。`zlib-ng`是一个与`zlib`兼容的API,它得到积极维护,包含了Intel和Cloudflare分支所做的改进,并在许多不同的CPU寄存器中获得了改进。
使用BenchmarkDotNet很容易对吞吐量进行基准测试。不幸的是,虽然我很喜欢这个工具,但[dotnet/BenchmarkDotNet#784](https://github.com/dotnet/BenchmarkDotNet/issues/784)的问题使得对压缩进行适当的基准测试变得非常具有挑战性,因为吞吐量只是方程的一部分。压缩比率也是关键的一部分(你可以通过完全不进行实际操作就输出输入内容来使“压缩”变得非常快),因此我们还需要知道压缩后的输出大小,当讨论压缩速度时。为了这篇帖子,我在这个基准测试中仅修改了足够的代码使其适用于这个示例,实现了一个自定义的BenchmarkDotNet列,但请注意这并不是一个通用实现。
```csharp
// dotnet run -c Release -f net8.0 --filter "*"
// dotnet run -c Release -f net9.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Columns;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Reports;
using BenchmarkDotNet.Running;
using System.IO.Compression;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(
args,
DefaultConfig.Instance.AddColumn(new CompressedSizeColumn()));
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private byte[] _uncompressed = new HttpClient().GetByteArrayAsync(@"https://www.gutenberg.org/cache/epub/3200/pg3200.txt").Result;
[Params(CompressionLevel.NoCompression, CompressionLevel.Fastest, CompressionLevel.Optimal, CompressionLevel.SmallestSize)]
public CompressionLevel Level { get; set; }
private MemoryStream _compressed = new MemoryStream();
private long _compressedSize;
[Benchmark]
public void Compress()
{
_compressed.Position = 0;
_compressed.SetLength(0);
using (var ds = new DeflateStream(_compressed, Level, leaveOpen: true))
{
ds.Write(_uncompressed, 0, _uncompressed.Length);
}
_compressedSize = _compressed.Length;
}
[GlobalCleanup]
public void SaveSize()
{
File.WriteAllText(Path.Combine(Path.GetTempPath(), $"Compress_{Level}"), _compressedSize.ToString());
}
}
public class CompressedSizeColumn : IColumn
{
public string Id => nameof(CompressedSizeColumn);
public string ColumnName { get; } = "CompressedSize";
public bool AlwaysShow => true;
public ColumnCategory Category => ColumnCategory.Custom;
public int PriorityInCategory => 1;
public bool IsNumeric => true;
public UnitType UnitType { get; } = UnitType.Size;
public string Legend => "CompressedSize Bytes";
public bool IsAvailable(Summary summary) => true;
public bool IsDefault(Summary summary, BenchmarkCase benchmarkCase) => true;
public string GetValue(Summary summary, BenchmarkCase benchmarkCase, SummaryStyle style) =>
GetValue(summary, benchmarkCase);
public string GetValue(Summary summary, BenchmarkCase benchmarkCase) =>
File.ReadAllText(Path.Combine(Path.GetTempPath(), $"Compress_{benchmarkCase.Parameters.Items[0].Value}")).Trim();
}
在.NET 8上运行得到以下结果:
方法 | Level | Mean | CompressedSize |
---|---|---|---|
Compress | NoCompression | 1.783 ms | 16015049 |
Compress | Fastest | 164.495 ms | 7312367 |
Compress | Optimal | 620.987 ms | 6235314 |
Compress | SmallestSize | 867.076 ms | 6208245 |
在.NET 9上运行得到以下结果: | |||
方法 | Level | Mean | CompressedSize |
--- | --- | --- | --- |
Compress | NoCompression | 1.814 ms | 16015049 |
Compress | Fastest | 64.345 ms | 9578398 |
Compress | Optimal | 230.646 ms | 6276158 |
Compress | SmallestSize | 567.579 ms | 6215048 |
这里有一些值得注意的几点: |
- 在.NET 8和.NET 9上,都存在一个明显的相关性:请求的压缩程度越大,速度越慢,文件大小越小。
NoCompression
,实际上只是将输入字节原样输出,在.NET 8和.NET 9上产生的压缩大小完全相同,正如所期望的那样;压缩大小应该与输入大小相同。- 对于
SmallestSize
,.NET 8和.NET 9之间的压缩大小几乎相同;它们只相差约0.1%,但为了这个小的增加,SmallestSize
的吞吐量最终快了约35%。在这两种情况下,.NET层只是向下传递一个zlib压缩级别9,这是可能的最大值,表示最佳的压缩。只是zlib-ng
在这种情况下明显更快,虽然压缩率略差。 - 对于
Optimal
,这是默认值,代表了速度和压缩率之间的平衡(如果有20/20的先见之明,这个成员的名字应该是Balanced
),.NET 9使用zlib-ng
的版本快了60%,而只牺牲了约0.6%的压缩率。 Fastest
很特别。.NET实现只是向下传递一个压缩级别1给zlib-ng
原生代码,指示选择最快的速度同时仍然进行一些压缩(0表示完全不压缩)。但zlib-ng
显然在做出与旧zlib
代码不同的权衡,因为它更名副其实:它快了超过2倍,同时仍然进行了压缩,但压缩后的输出比.NET 8上的输出大了约30%。
总体效果是,特别是如果你使用的是Fastest
,你可能需要重新评估吞吐量/压缩率是否符合你的需求。如果你想进一步调整,现在你不再局限于这些选项。dotnet/runtime#105430为DeflateStream
、GZipStream
、ZLibStream
以及无关的BrotliStream
添加了新的构造函数,使得对传递给原生实现的参数进行更精细的控制成为可能,例如:
private static readonly ZLibCompressionOptions s_options = new ZLibCompressionOptions()
{
CompressionLevel = 2,
};
...
Stream sourceStream = ...;
using var ds = new DeflateStream(compressed, s_options, leaveOpen: true)
{
sourceStream.CopyTo(ds);
}
密码学
在System.Security.Cryptography
中的投资通常集中在提高系统的安全性、支持新的密码学原语、更好地与底层操作系统的安全功能集成等方面。但是,由于密码学在现代系统中无处不在,因此使现有功能更加高效也同样重要。在.NET 9中提交的多个PR(Pull Requests)就实现了这一目标。
首先从随机数生成开始。.NET 8为Random
(核心非密码学安全随机数生成器)和RandomNumberGenerator
(核心密码学安全随机数生成器)都添加了一个新的GetItems
方法。这个方法在您需要从特定值集合中随机生成N个元素时非常方便。例如,如果您想将100个随机十六进制字符写入目标Span<char>
,可以这样写:
Span<char> dest = stackalloc char[100];
Random.Shared.GetItems("0123456789abcdef", dest);
核心实现非常简单,只是为了一些您可能很容易自己完成的事情提供了一个便利的实现:
for (int i = 0; i < dest.Length; i++)
{
dest[i] = choices[Next(choices.Length)];
}
这很简单。然而,在某些情况下,我们可以做得更好。这个实现会为每个元素调用随机数生成器,而这个往返调用会增加可测量的开销。如果我们能够减少调用次数,那么就可以将这个开销分摊到单个调用可以填充的元素数量上。这正是dotnet/runtime#92229所做的。如果选择的数量小于或等于256且为2的幂,那么我们不需要为每个元素请求一个随机整数,而是可以为每个元素获取一个字节,并且可以使用单个调用NextBytes
来批量获取。选择的最大值为256,因为这是一个字节可以表示的值的数量,而2的幂是为了我们可以简单地屏蔽掉不需要的位,这有助于避免偏差。这对Random
有可测量的影响,但对RandomNumberGenerator
的影响更大,因为在获取随机字节时每个调用都需要切换到操作系统。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Security.Cryptography;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private char[] _dest = new char[100];
[Benchmark]
public void GetRandomHex() => RandomNumberGenerator.GetItems<char>("0123456789abcdef", _dest);
}
方法 | 运行时 | 平均值 | 比率 |
---|---|---|---|
GetRandomHex | .NET 8.0 | 58,659.2 纳秒 | 1.00 |
GetRandomHex | .NET 9.0 | 746.5 纳秒 | 0.01 |
有时性能提升需要重新审视过去的假设。.NET 5添加了一个新的GC.AllocateArray
方法,该方法允许将数组创建在“固定对象堆”(Pinned Object Heap,简称POH)上,这是一个可选参数。在POH上分配与正常分配相同,只是GC保证不会移动POH上的对象(通常GC可以压缩堆,移动对象以减少碎片)。这对密码学很有用,因为密码学采用了防御深度措施,如将缓冲区清零以减少攻击者能在进程的内存(或内存转储)中找到敏感信息的可能性。密码库想要能够分配一些内存,暂时包含一些敏感信息,然后在使用完毕前将其清零,但如果GC在中间移动对象,可能会在堆上留下数据阴影。因此,当POH引入时,System.Security.Cryptography
开始使用它,包括为相对短命的对象使用POH。然而,这可能是问题所在。因为POH的特点是对象不能被移动,创建短命对象在POH上可能会显著增加碎片,进而增加内存消耗、GC成本等。因此,POH仅建议用于长期对象,最好是在创建后持有直至进程结束。
dotnet/runtime#99168撤销了System.Security.Cryptography
对POH的依赖,而是选择使用本地内存(例如通过NativeMemory.Alloc
和NativeMemory.Free
)来满足这些需求。
关于内存的话题,多个PR被提交到密码库以减少分配。以下是一些示例:
- 使用指针而不是临时数组进行封送处理。
CngKey
类型公开了如ExportPolicy
、IsMachineKey
和KeyUsage
等属性,这些属性都使用内部GetPropertyAsDword
方法,该方法通过P/Invoke从Windows获取一个整数。然而,它通过一个共享助手这样做,该助手会分配一个4字节的byte[]
,将其传递给操作系统以填充,然后将这四个字节转换为int
。dotnet/runtime#91521更改了与操作系统的互操作路径,而是直接在栈上存储int
,并传递给操作系统一个指向它的指针,从而避免了分配和转换的需要。 - 特殊处理空情况。在整个核心库中,我们大量依赖
Array.Empty<T>()
来避免在可以仅使用单例的情况下分配大量空数组。密码库经常与数组打交道,出于防御深度的考虑,通常会为每个人提供这些数组的副本,这由一个共享的CloneByteArray
助手处理。然而,实际上空数组是相当常见的,但CloneByteArray
没有为空输入数组做特殊处理,因此总是分配新的数组,即使输入是空的。dotnet/runtime#93231简单地为空输入数组做了特殊处理,返回它们自身而不是克隆它们。 - 避免不必要的防御性复制。dotnet/runtime#97108避免了比上述空数组情况更多的防御性复制。
PublicKey
类型传递了两个AsnEncodedData
实例,一个是参数,一个是密钥值,并且为了避免任何可能的问题,都会克隆这两个实例。但在某些内部使用中,调用者会构造一个临时的AsnEncodedData
并实际转移所有权,然而PublicKey
仍然会进行防御性复制,即使临时实例可以恰当地使用。这个变更使得在这种情况下可以直接使用原始实例而不需要复制。 - 使用集合表达式与spans。C# 11引入的集合表达式功能允许您表达您的意图,并让系统尽可能地实现。在初始化
OidLookup
时,它有多个看起来像这样的多行:
AddEntry("1.2.840.10045.3.1.7", "ECDSA_P256", new[] { "nistP256", "secP256r1", "x962P256v1", "ECDH_P256" });
AddEntry("1.3.132.0.34", "ECDSA_P384", new[] { "nistP384", "secP384r1", "ECDH_P384" });
AddEntry("1.3.132.0.35", "ECDSA_P521", new[] { "nistP521", "secP521r1", "ECDH_P521" });
这实际上迫使它分配了这些数组,即使AddEntry
方法实际上不需要数组,它只是迭代提供的值。dotnet/runtime#100252将AddEntry
方法改为接受ReadOnlySpan<string>
而不是string[]
,并将所有调用站点改为集合表达式:
AddEntry("1.2.840.10045.3.1.7", "ECDSA_P256", ["nistP256", "secP256r1", "x962P256v1", "ECDH_P256"]);
AddEntry("1.3.132.0.34", "ECDSA_P384", ["nistP384", "secP384r1", "ECDH_P384"]);
AddEntry("1.3.132.0.35", "ECDSA_P521", ["nistP521", "secP521r1", "ECDH_P521"]);
允许编译器做“正确的事情”。所有的这些调用站点然后只使用栈空间来存储传递给AddEntry
的字符串,而不是分配任何数组。
- 预分配集合容量。许多集合,如
List<T>
或Dictionary<TKey, TValue>
,允许您创建一个新集合,而不需要事先知道它将增长到多大,集合内部会处理增长存储以适应额外数据。通常使用的增长算法涉及每次加倍容量,因为这样做在可能浪费一些内存和不需要频繁重新增长之间找到了一个合理的平衡。然而,增长确实有开销,避免它是可取的,因此许多集合提供了预分配集合容量的能力,例如List<T>
有一个接受int capacity
的构造函数,列表会立即创建一个足够大的后端存储来容纳那么多元素。密码学中的OidCollection
并没有这样的能力,尽管许多创建它的地方确实知道确切的大小,这导致了不必要的分配和复制,因为集合在达到目标大小时需要增长。 dotnet/runtime#97106内部添加了这样的构造函数,并在多个地方使用它,以避免这种开销。类似于OidCollection
,CborWriter
也缺乏预分配能力,这使得增长算法的问题更加明显。dotnet/runtime#92538添加了这样的构造函数。 - 避免
O(N^2)
增长算法。@MichalPetryka的dotnet/runtime#92435修复了一个很好的例子,展示了当你不使用加倍策略作为集合大小调整的一部分时会发生什么。CborWriter
用于增长缓冲区的算法会每次增加一个固定数目的元素。使用加倍策略确保你需要的增长操作不会超过O(log N)
,并确保将N
个元素添加到集合中需要O(N)
的时间,因为元素复制的次数是O(2N)
,这实际上是O(N)
(例如,如果N
等于128,并且你从大小1开始增长到2、4、8、16、32、64和128,那么这是1+2+4+8+16+32+64+128,即255,接近两倍的N
)。但是,每次增加固定数量可能会意味着O(N)
次操作。由于每次增长操作也需要复制所有元素(假设增长是通过数组大小调整实现的),这使得算法的时间复杂度为O(N^2)
。在最坏的情况下,如果这个固定数量是1,并且我们每次只增加一个元素从1增长到128,那么这实际上就是累加从1到128的所有数字,其公式为N(N+1)/2
,这是O(N^2)
。这个PR将CborWriter
的增长策略更改为使用加倍。
// Add a <PackageReference Include="System.Formats.Cbor" Version="8.0.0" /> to the csproj.
// dotnet run -c Release -f net8.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using System.Formats.Cbor;
var config = DefaultConfig.Instance
.AddJob(Job.Default.WithRuntime(CoreRuntime.Core80).WithNuGet("System.Formats.Cbor", "8.0.0").AsBaseline())
.AddJob(Job.Default.WithRuntime(CoreRuntime.Core90).WithNuGet("System.Formats.Cbor", "9.0.0-rc.1.24431.7"));
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "NuGetReferences")]
public class Tests
{
[Benchmark]
public CborWriter Test()
{
const int NumArrayElements = 100_000;
CborWriter writer = new();
writer.WriteStartArray(NumArrayElements);
for (int i = 0; i < NumArrayElements; i++)
{
writer.WriteInt32(i);
}
writer.WriteEndArray();
return writer;
}
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
| --- | --- | --- | --- | --- | --- |
| Test | .NET 8.0 | 25,185.2 微秒 | 1.00 | 65350.11 KB | 1.00 |
| Test | .NET 9.0 | 697.2 微秒 | 0.03 | 1023.82 KB | 0.02 |
当然,提高性能不仅仅是避免分配。一系列变更在其他方面也提供了帮助。
[dotnet/runtime#99053](https://github.com/dotnet/runtime/pull/99053)通过“缓存”(即记忆化)`CngKey`上多次访问但答案不变的多个属性;它只是通过在类型上添加几个字段来缓存这些值,这在任何属性在对象生命周期中被多次访问时都是一个巨大的胜利,因为操作系统实现这些函数时需要执行远程过程调用(RPC)到另一个Windows进程以访问相关数据。
```csharp
// Windows-only test.
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Security.Cryptography;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private RSACng _rsa = new RSACng(2048);
[GlobalCleanup]
public void Cleanup() => _rsa.Dispose();
[Benchmark]
public CngAlgorithm GetAlgorithm() => _rsa.Key.Algorithm;
[Benchmark]
public CngAlgorithmGroup? GetAlgorithmGroup() => _rsa.Key.AlgorithmGroup;
[Benchmark]
public CngProvider? GetProvider() => _rsa.Key.Provider;
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
| --- | --- | --- | --- | --- | --- |
| GetAlgorithm | .NET 8.0 | 63,619.352 纳秒 | 1.000 | 88 B | 1.00 |
| GetAlgorithm | .NET 9.0 | 10.216 纳秒 | 0.000 | – | 0.00 |
| GetAlgorithmGroup | .NET 8.0 | 62,580.363 纳秒 | 1.000 | 88 B | 1.00 |
| GetAlgorithmGroup | .NET 9.0 | 8.354 纳秒 | 0.000 | – | 0.00 |
| GetProvider | .NET 8.0 | 62,108.489 纳秒 | 1.000 | 232 B | 1.00 |
| GetProvider | .NET 9.0 | 8.393 纳秒 | 0.000 | – | 0.00 |
还有一些与加载证书和密钥相关的改进。[dotnet/runtime#97267](https://github.com/dotnet/runtime/pull/97267)由[@birojnayak](https://github.com/birojnayak)处理了Linux上重复处理相同证书的问题,而[dotnet/runtime#97827](https://github.com/dotnet/runtime/pull/97827)通过避免密钥验证执行的一些不必要的操作提高了RSA密钥加载的性能。
## 网络编程
快速回答我,你上一次工作于一个完全不需要网络的实际应用或服务是什么时候?我等着……(我很有幽默感吧。)几乎所有的现代应用都以某种方式依赖于网络,特别是那些遵循更云原生架构、涉及微服务等应用。降低与网络相关的成本是我们非常重视的事情,而.NET社区在每一次发布中都在逐步减少这些成本,包括.NET 9。
在过去的版本中,`SslStream`一直是性能优化的重点。它在`HttpClient`和ASP.NET Kestrel Web服务器中的大量流量中使用,因此在许多系统中都处于热点路径。之前的改进既针对了稳态吞吐量,也针对了创建开销。
在.NET 9中,一些Pull Request(PR)专注于稳态吞吐量,例如[dotnet/runtime#95595](https://github.com/dotnet/runtime/pull/95595),该PR解决了一个问题:某些数据包被不必要地分割成两个,导致需要额外发送和接收这个额外数据包所产生的额外开销。这个问题在写入恰好16K时尤为明显,尤其是在Windows系统上(我就是在那里运行的这个测试):
```csharp
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Security.Cryptography;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
// ...
[Benchmark]
public async Task SendReceive()
{
await _client.WriteAsync(_buffer);
await _server.ReadExactlyAsync(_buffer);
}
// ...
}
方法 | 运行时 | 平均值 | 比率
| --- | --- | --- |
JSON
System.Text.Json 在 .NET Core 3.0 中亮相,并随着每一次的发布而变得更加功能丰富和高效。.NET 9 也不例外。除了支持导出 JSON 模式、JsonElement
的深度语义相等比较、尊重可空引用类型注解、支持排序 JsonObject
属性、新的合同元数据 API 等新功能外,性能提升也是其重要关注点。
其中一项改进来自于 JsonSerializer
与 System.IO.Pipelines
的集成。大部分 .NET 栈通过 Stream
在字节之间移动,然而 ASP.NET 内部实现则是使用 System.IO.Pipelines
。流与管道之间存在内置的双向适配器,但在某些情况下,这些适配器会增加一些开销。由于 JSON 对现代服务至关重要,因此 JsonSerializer
必须能够同样高效地与流和管道一起工作。因此,dotnet/runtime#101461 添加了新的 JsonSerializer.SerializeAsync
重载,它针对 PipeWriter
,除了现有的针对 Stream
的重载。这样,无论你有 Stream
还是 PipeWriter
,JsonSerializer
都能原生地与两者一起工作,而不需要任何中间适配来转换它们。只需使用你已有的任何一种即可。
JsonSerializer
对枚举的处理也得到了改善,由 dotnet/runtime#105032 实现。除了添加对新的 [JsonEnumMemberName]
特性支持外,它还采用了一种零分配的枚举解析解决方案,利用了 Dictionary<TKey, TValue>
和 ConcurrentDictionary<TKey, TValue>
中新增的 GetAlternateLookup
支持来启用一个可通过 ReadOnlySpan<char>
查询的枚举信息缓存。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.Json;
using System.Reflection;
using System.Text.Json.Serialization;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private static readonly JsonSerializerOptions s_options = new()
{
Converters = { new JsonStringEnumConverter() },
DictionaryKeyPolicy = JsonNamingPolicy.KebabCaseLower,
};
[Params(BindingFlags.Default, BindingFlags.NonPublic | BindingFlags.Instance)]
public BindingFlags _value;
private byte[] _jsonValue;
private Utf8JsonWriter _writer = new(Stream.Null);
[GlobalSetup]
public void Setup() => _jsonValue = JsonSerializer.SerializeToUtf8Bytes(_value, s_options);
[Benchmark]
public void Serialize()
{
_writer.Reset();
JsonSerializer.Serialize(_writer, _value, s_options);
}
[Benchmark]
public BindingFlags Deserialize() =>
JsonSerializer.Deserialize<BindingFlags>(_jsonValue, s_options);
}
方法 | 运行时 | _value |
平均 | 比率 | 分配 | 分配比率 |
---|---|---|---|---|---|---|
Serialize | .NET 8.0 | Default | 38.67 ns | 1.00 | 24 B | 1.00 |
Serialize | .NET 9.0 | Default | 27.23 ns | 0.70 | – | 0.00 |
Deserialize | .NET 8.0 | Default | 73.86 ns | 1.00 | – | NA |
Deserialize | .NET 9.0 | Default | 70.48 ns | 0.95 | – | NA |
Serialize | .NET 8.0 | Instance, NonPublic | 37.60 ns | 1.00 | 24 B | 1.00 |
Serialize | .NET 9.0 | Instance, NonPublic | 26.82 ns | 0.71 | – | 0.00 |
Deserialize | .NET 8.0 | Instance, NonPublic | 97.54 ns | 1.00 | – | NA |
Deserialize | .NET 9.0 | Instance, NonPublic | 70.72 ns | 0.73 | – | NA |
JsonSerializer
依赖于 System.Text.Json
的许多其他功能,后者也得到了提升。以下是一些示例:
-
直接使用 UTF8。
JsonProperty.WriteTo
总是使用writer.WritePropertyName(Name)
输出属性名。然而,Name
属性可能会在JsonProperty
未缓存的情况下分配一个新的string
。@karakasa 的 dotnet/runtime#90074 调整了实现,如果JsonProperty
已经有一个string
,则会直接写出它,否则直接根据将要用于创建该string
的 UTF8 字节写出名称。 -
避免不必要的中间状态。 @habbes 的 dotnet/runtime#97687 是那些纯粹赢的 PR 之一。主要更改是一个
Base64EncodeAndWrite
方法,该方法将源ReadOnlySpan<byte>
Base64-编码到目标Span<byte>
。实现之前要么stackalloc
缓冲区,要么租用缓冲区,然后编码到临时缓冲区,再将其复制到确保足够大的缓冲区。为什么不直接将数据编码到目标缓冲区而不是通过临时缓冲区?不清楚。但感谢这个 PR,中间开销被简单地移除了。类似地,dotnet/runtime#92284 从JsonNode.GetPath
中移除了一些不必要的中间状态。JsonNode.GetPath
一直在做很多分配,创建一个包含所有路径段的List<string>
,然后将其反序组合到一个StringBuilder
中。这个更改将实现改为首先反序提取路径段,然后使用栈空间或从ArrayPool
租用的数组构建结果路径。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.Json.Nodes;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private JsonNode _json = JsonNode.Parse("""
{
"staff": {
"Elsa": {
"age": 21,
"position": "queen"
}
}
}
""")["staff"]["Elsa"]["position"];
[Benchmark]
public string GetPath() => _json.GetPath();
}
方法 | 运行时 | _value |
平均 | 比率 | 分配 | 分配比率 |
---|---|---|---|---|---|---|
GetPath | .NET 8.0 | Default | 176.68 ns | 1.00 | 472 B | 1.00 |
GetPath | .NET 9.0 | Default | 27.23 ns | 0.30 | 64 B | 0.14 |
-
使用现有缓存。
JsonNode.ToString
和JsonNode.ToJsonString
会分配新的PooledByteBufferWriter
和Utf8JsonWriter
,但内部的Utf8JsonWriterCache
类型已经提供了使用这些相同对象的缓存支持。dotnet/runtime#92358 只是更新了这些JsonNode
方法,使其利用现有的缓存。 -
预大小集合。
JsonObject
有一个接受要添加到对象的属性的可枚举的构造函数。对于许多属性,在添加属性时,后端存储可能需要不断增长,从而产生分配和复制的开销。dotnet/runtime#96486 从 @olo-ntaylor 测试是否可以从可枚举中检索计数,如果可以,就使用它来预大小字典。 -
允许快速路径快速。
JsonValue
有一个特殊的特性,可以包装任意的 .NET 对象。由于JsonValue
继承自JsonNode
,JsonNode
需要考虑这个特性。当前的做法使得一些常见的操作比必要的更昂贵。dotnet/runtime#103733 重新设计了实现,以优化常见的场景。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.Json;
using System.Text.Json.Nodes;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private JsonNode[] _nodes = new JsonNode[] { 42, "I am a string", false, DateTimeOffset.Now };
[Benchmark]
[Arguments(JsonValueKind.String)]
public int Count(JsonValueKind kind)
{
var count = 0;
foreach (var node in _nodes)
{
if (node.GetValueKind() == kind)
{
count++;
}
}
return count;
}
}
方法 | 运行时 | kind | 平均 | 比率 |
---|---|---|---|---|
Count | .NET 8.0 | String | 729.26 ns | 1.00 |
Count | .NET 9.0 | String | 12.14 ns | 0.02 |
- 去重访问。
JsonValue.CreateFromElement
访问JsonElement.ValueKind
来确定如何处理数据,例如:
if (element.ValueKind is JsonValueKind.Null) { ... }
else if (element.ValueKind is JsonValueKind.Object or JsonValueKind.Array) { ... }
else { ... }
如果 ValueKind
是简单的字段访问,那还好。但实际情况比这复杂得多,涉及到一个庞大的 switch
来确定返回哪种类型。而不是可能读取两次,dotnet/runtime#104108 从 @andrewjsaid 只是对实现进行了小小的调整,使其只访问属性一次。没有必要做两次同样的工作。
- 使用现有数据跨度。
JsonElement.GetRawText
方法对于提取支撑JsonElement
的原始输入很有用,但数据存储为 UTF8 字节,GetRawText
返回一个string
,所以每次调用都会分配和转码以产生结果。从 dotnet/runtime#104595 开始,新的JsonMarshal.GetRawUtf8Value
简单地返回对原始数据的跨度,不进行编码,不进行分配。
// dotnet run -c Release -f net9.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Nodes;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private JsonElement _json = JsonSerializer.Deserialize("""
{
"staff": {
"Elsa": {
"age": 21,
"position": "queen"
}
}
}
""");
[Benchmark(Baseline = true)]
public string GetRawText() => _json.GetRawText();
[Benchmark]
public ReadOnlySpan<byte> TryGetRawText() => JsonMarshal.GetRawUtf8Value(_json);
}
方法 | 平均 | 比率 | 分配 | 分配比率 |
---|---|---|---|---|
GetRawText | 51.627 ns | 1.00 | 192 B | 1.00 |
TryGetRawText | 7.998 ns | 0.15 | – | 0.00 |
注意,新方法是位于新的 JsonMarshal
类中,因为这是一个具有安全顾虑的 API(一般来说,Unsafe
类或 System.Runtime.InteropServices
命名空间中的 API 被认为是“不安全的”)。这里的顾虑是 JsonElement
可能被租用的 ArrayPool
支持的数组所支撑,如果 JsonElement
来自 JsonDocument
。你得到的跨度只是指向那个数组。如果在获取跨度之后,JsonDocument
被丢弃,它将把那个数组返回给池,现在跨度指向了一个其他人可能租用的数组。如果他们这样做并写入那个数组,跨度现在将包含那里写入的内容,实际上导致了数据损坏。试试这个:
// dotnet run -c Release -f net9.0
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text;
ReadOnlySpan<byte> elsaUtf8;
using (JsonDocument elsaJson = JsonDocument.Parse("""
{
"staff": {
"Elsa": {
"age": 21,
"position": "queen"
}
}
}
"""))
{
elsaUtf8 = JsonMarshal.GetRawUtf8Value(elsaJson.RootElement);
}
using (JsonDocument annaJson = JsonDocument.Parse("""
{
"staff": {
"Anna": {
"age": 18,
"position": "princess"
}
}
}
"""))
{
Console.WriteLine(Encoding.UTF8.GetString(elsaUtf8)); // 呀哦!
}
当我运行这个时,它打印出了关于“Anna”的信息,尽管我从“Elsa”JsonElement
中检索了原始文本。糟糕!就像 C# 或 .NET 中的任何“不安全”的东西一样,你需要确保正确地持有它。
最后一个改进
最后一个我想提及的改进,其功能本身并不直接关乎性能,但人们为缺乏这种功能而采用的权宜之计确实对性能产生了重大影响,因此,有了这个功能后,整体的性能提升将会是显著的。dotnet/runtime#104328 为 Utf8JsonReader
和 JsonSerializer
添加了从输入中解析多个顶级 JSON 对象的支持。在此之前,如果在输入中找到一个 JSON 对象之后还有数据,这会被视为错误并导致解析失败。这意味着,如果特定的数据源连续发送多个 JSON 对象,数据就需要预先解析,以便只将相关的部分传递给 System.Text.Json
。这对于流式数据的服务特别相关。
// dotnet run -c Release -f net9.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.Json;
using System.Text.Json.Nodes;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private MemoryStream _source = new MemoryStream("""
{
"name": "Alice",
"age": 30,
"city": "New York"
}
{
"name": "Bob",
"age": 25,
"city": "Los Angeles"
}
{
"name": "Charlie",
"age": 35,
"city": "Chicago"
}
"""u8.ToArray());
[Benchmark]
[Arguments("Dave")]
public async Task<Person?> FindAsync(string name)
{
_source.Position = 0;
await foreach (var p in JsonSerializer.DeserializeAsyncEnumerable<Person>(_source, topLevelValues: true))
{
if (p?.Name == name)
{
return p;
}
}
return null;
}
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public string City { get; set; }
}
}
诊断
在现代服务的运营中,能够观察其应用程序在生产环境中的运行状况至关重要。.System.Diagnostics.Metrics.Meter
是 .NET 推荐用于发布度量指标的类型,并且 .NET 9 中对其进行了多项改进,以提高其效率。
Counter
和 UpDownCounter
通常用于对活跃或排队请求数量等指标的热路径跟踪。在生产环境中,这些仪器经常受到多个线程的同时攻击,这意味着它们不仅需要线程安全,而且还需要能够很好地扩展。线程安全是通过在更新(简单地读取值、添加到它并存储回)周围使用 lock
来实现的,但在高负载下,这可能导致锁定上的显著争用。为了解决这个问题,dotnet/runtime#91566 对实现进行了几种更改。首先,而不是使用 lock
来保护状态:
lock (this)
{
_delta += value;
}
它使用一个原子操作来进行加法。在这里,_delta
是一个 double
,而且没有 Interlocked.Add
可以与 double
值一起使用,所以采用了围绕 Interlocked.CompareExchange
的循环的标准方法。
double currentValue;
do
{
currentValue = _delta;
}
while (Interlocked.CompareExchange(ref _delta, currentValue + value, currentValue) != currentValue);
这有所帮助,但尽管这样做确实减少了开销并提高了可扩展性,在重负载下它仍然代表了一个瓶颈。为了解决这个问题,更改还将单个 _delta
分割成一个数组,每个核心一个值,并且线程选择其中一个值进行更新,通常是与它当前运行的核心关联的值。这样,争用显著减少,因为不仅分布在了 N 个值而不是 1 个值之间,而且因为线程倾向于更新它们所在核心的值,并且在特定时刻只有一个线程在特定核心上执行,所以冲突的机会大大减少。仍然有一些争用,因为线程不一定保证使用关联的值(例如,线程可能在检查核心和执行访问之间迁移)并且因为我们实际上限制了数组的大小(这样就不会消耗太多内存),但它仍然使系统更加可扩展。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Diagnostics.Metrics;
using System.Diagnostics.Tracing;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private MetricsEventListener _listener = new MetricsEventListener();
private Meter _meter = new Meter("Example");
private Counter<int> _counter;
[GlobalSetup]
public void Setup() => _counter = _meter.CreateCounter<int>("counter");
[GlobalCleanup]
public void Cleanup()
{
_listener.Dispose();
_meter.Dispose();
}
[Benchmark]
public void Counter_Parallel()
{
Parallel.For(0, 1_000_000, i =>
{
_counter.Add(1);
_counter.Add(1);
});
}
private sealed class MetricsEventListener : EventListener
{
protected override void OnEventSourceCreated(EventSource eventSource)
{
if (eventSource.Name == "System.Diagnostics.Metrics")
{
EnableEvents(eventSource, EventLevel.LogAlways, EventKeywords.All, new Dictionary<string, string?>() { { "Metrics", "Example\\upDownCounter;Example\\counter" } });
}
}
}
}
方法 | 运行时 | 平均 | 比率 |
---|---|---|---|
Counter_Parallel | .NET 8.0 | 137.90 毫秒 | 1.00 |
Counter_Parallel | .NET 9.0 | 30.65 毫秒 | 0.22 |
另一个值得注意的改进方面是数组中的填充。从单个 double _delta
到 delta 数组,你可能想象我们会得到:
private readonly double[] _deltas;
但是,如果你查看代码,它实际上是:
private readonly PaddedDouble[] _deltas;
其中 PaddedDouble
定义为:
[StructLayout(LayoutKind.Explicit, Size = 64)]
private struct PaddedDouble
{
[FieldOffset(0)]
public double Value;
}
这实际上将每个值的尺寸从 8 字节增加到 64 字节,其中只使用每个值的前 8 字节,其余 56 字节是填充。这听起来很奇怪,对吧?通常,我们会抓住将 64 字节缩减到 8 字节的机会,以减少分配和内存消耗,但在这里我们故意采取了相反的方向。
这样做的原因是“假共享”。考虑以下基准,我从 Scott Hanselman 和我最近在 Deep .NET 系列的 Let’s Talk Parallel Programming 中的对话中毫不客气地借用:
// dotnet run -c Release -f net9.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private int[] _values = new int[32];
[Params(1, 31)]
public int Index { get; set; }
[Benchmark]
public void ParallelIncrement()
{
Parallel.Invoke(
() => IncrementLoop(ref _values[0]),
() => IncrementLoop(ref _values[Index]));
static void IncrementLoop(ref int value)
{
for (int i = 0; i < 100_000_000; i++)
{
Interlocked.Increment(ref value);
}
}
}
}
当我运行这个基准时,我会得到如下结果:
方法 | 索引 | 平均 |
---|---|---|
ParallelIncrement | 1 | 1,779.9 毫秒 |
ParallelIncrement | 31 | 432.3 毫秒 |
在这个基准中,一个线程正在递增 _values[0]
,另一个线程正在递增 _values[1]
或 _values[31]
。唯一的不同是索引,但访问 _values[31]
的线程比访问 _values[1]
的线程快得多。这是因为即使在这段代码中看不到争用,实际上仍然存在争用。争用源于硬件以称为“缓存行”的分组字节为单位进行操作。大多数硬件的缓存行大小为 64 字节。为了更新特定的内存位置,硬件将获取整个缓存行。如果另一个核心想要更新同一个缓存行,它也需要获取它。这种来回获取导致了大量开销。一个核心是否触及这 64 字节中的第一个字节,而另一个线程触及最后一个字节,对硬件来说没有区别,从硬件的角度来看仍然存在共享。这就是“假共享”。因此,Counter
的修复是使用填充在 double
值周围,以尝试更多地分散它们,从而最小化限制可扩展性的共享。
作为旁注,还有一些额外的 BenchmarkDotNet 诊断器可以帮助突出显示假共享的影响。Windows 上的 ETW 启用收集各种 CPU 性能计数器,如分支缺失或指令退休,而 BenchmarkDotNet 有一个 [HardwareCounters]
诊断器能够收集这种 ETW 数据。其中一个计数器是用于缓存缺失的,这通常反映了假共享问题。如果你在 Windows 上,可以尝试获取单独的 BenchmarkDotNet.Diagnostics.Windows
nuget 软件包并按以下方式使用它:
// 此基准仅在 Windows 上运行。
// 在 csproj 中添加 <PackageReference Include="BenchmarkDotNet.Diagnostics.Windows" Version="0.14.0" />。
// dotnet run -c Release -f net9.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Diagnosers;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HardwareCounters(HardwareCounter.InstructionRetired, HardwareCounter.CacheMisses)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private int[] _values = new int[32];
[Params(1, 31)]
public int Index { get; set; }
[Benchmark]
public void ParallelIncrement()
{
Parallel.Invoke(
() => IncrementLoop(ref _values[0]),
() => IncrementLoop(ref _values[Index]));
static void IncrementLoop(ref int value)
{
for (int i = 0; i < 100_000_000; i++)
{
Interlocked.Increment(ref value);
}
}
}
}
在这里,我要求了指令退休和缓存缺失两个计数器。指令退休反映了完全执行了多少指令(这本身在分析性能时可以是一个有用的指标,因为它不像墙钟测量那样容易变化),缓存缺失反映了发生了多少次数据未在 CPU 缓存中可用的情况。
方法 | 索引 | 平均 | 指令退休/操作 | 缓存缺失/操作 |
---|---|---|---|---|
ParallelIncrement | 1 | 1,846.2 毫秒 | 804,300,000 | 177,889 |
ParallelIncrement | 31 | 442.5 毫秒 | 824,333,333 | 52,429 |
在这两个基准中,我们可以看到,当发生假共享时(索引为 1)和没有假共享时(索引为 31)执行的指令数量几乎相同,但假共享情况下的缓存缺失数量比非假共享情况多出三倍以上,并且与时间增加合理相关。当一个核心执行写入时,它会将相应缓存行在另一个核心的缓存中失效,这样另一个核心就需要重新加载缓存行,从而导致缓存缺失。但我不想再展开讲了...
另一个很好的改进来自 dotnet/runtime#105011,@stevejgordon 添加了 Measurement
的新构造函数。通常在创建 Measurement
时,还会为它们附加额外的键/值对信息,TagList
类型就是为了这个目的而存在的。TagList
实现 IList<KeyValuePair<string, object?>>
,Measurement
有一个接受 IEnumerable<KeyValuePair<string, object?>>
的构造函数,所以你可以传递一个 TagList
给 Measurement
,它会“自然而然”地工作...但速度不如可能那么快。如果你有如下代码:
measurements.Add(new Measurement<long>(
snapshotV4.LastAckCount,
new TagList { tcpVersionFourTag, new(NetworkStateKey, "last_ack") }));
这将导致将 TagList
结构体作为可枚举的内容装箱,然后通过接口进行枚举,这还涉及到枚举器的分配。这个 PR 添加的新构造函数避免了这些开销。TagList
本身也通过 dotnet/runtime#104132 得到了改进,该 PR 在 .NET 8+ 上基于 [InlineArray]
重新实现了类型。TagList
实际上是一个键/值对的列表,但为了避免总是分配后端存储,它在其结构体中直接存储了一些包含的键/值对。现在,使用了一个 [InlineArray]
,清理了代码并允许通过跨度进行访问。
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Diagnostics;
using System.Diagnostics.Metrics;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private Counter<long> _counter;
private Meter _meter;
[GlobalSetup]
public void Setup()
{
this._meter = new Meter("TestMeter");
this._counter = this._meter.CreateCounter<long>("counter");
}
[GlobalCleanup]
public void Cleanup() => this._meter.Dispose();
[Benchmark]
public void CounterAdd()
{
this._counter?.Add(100, new TagList
{
{ "Name1", "Val1" },
{ "Name2", "Val2" },
{ "Name3", "Val3" },
{ "Name4", "Val4" },
{ "Name5", "Val5" },
{ "Name6", "Val6" },
{ "Name7", "Val7" },
});
}
}
方法 | 运行时 | 平均 | 比率 |
---|---|---|---|
CounterAdd | .NET 8.0 | 31.88 纳秒 | 1.00 |
CounterAdd | .NET 9.0 | 13.93 纳秒 | 0.44 |
花生酱
在这篇文章中,我尝试按照主题区域对改进进行分组,以便创建一个更加流畅和有趣的讨论。然而,对于一个像.NET这样的社区来说,在一年时间里,随着平台功能范围的增加,不可避免地会出现大量单独的PR,这些PR虽然只是略微改善了这里的或那里的功能。想象任何一个单独的改进能够显著“推动指针”是很困难的,但整体来看,这些改进减少了性能开销的“花生酱”,这种开销被薄薄地分散在各个库中。以下是一些这些改进的非详尽概述,不分先后:
-
StreamWriter.Null。
StreamWriter
公开了一个静态Null
字段。它存储了一个StreamWriter
实例,旨在成为一个“垃圾桶”,你可以将其写入,但它会忽略所有数据,类似于Unix中的/dev/null
、Stream.Null
等。不幸的是,它的实现有两个问题,其中一个让我非常惊讶,因为这种情况已经存在了.NET问世以来很长时间。它被实现为new StreamWriter(Stream.Null, ...)
。StreamWriter
中进行的所有状态跟踪都不是线程安全的,而这个实例是从一个公共静态成员公开的,这意味着它应该是线程安全的。如果多个线程同时操作这个StreamWriter
实例,可能会导致非常奇怪的异常发生,比如算术溢出。从性能的角度来看,这也是一个问题,因为尽管实际写入底层Stream
被忽略了,但StreamWriter
实际上完成的所有工作都是无用的。dotnet/runtime#98473通过创建一个内部的NullStreamWriter : StreamWriter
类型,并覆盖了所有操作为空操作(nop),然后Null
被初始化为该类型的实例来修复这两个问题。// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0 using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args); [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")] public class Tests { [Benchmark] public void WriteLine() => StreamWriter.Null.WriteLine("Hello, world!"); } | Method | Runtime | Mean | Ratio | | --- | --- | --- | --- | | WriteLine | .NET 8.0 | 7.5164 ns | 1.00 | | WriteLine | .NET 9.0 | 0.0283 ns | 0.004 |
-
NonCryptographicHashAlgorithm.Append{Async}。
NonCryptographicHashAlgorithm
是System.IO.Hashing
中如XxHash3
和Crc32
这样的类型的基类。它提供的一个不错功能是能够通过单个调用将整个Stream
的内容附加到它上,例如:XxHash3 hash = new(); hash.Append(someStream);
Append
的实现相对简单:从ArrayPool
借用一个缓冲区,然后在循环中反复将缓冲区填满,然后调用Append
将填充的缓冲区的一部分附加到它。然而,这种方法有几个性能缺点。首先,被租用的缓冲区大小为4096字节。虽然这不算小,但使用更大的缓冲区可以减少对被附加流进行附加操作的调用次数,从而减少任何Stream
的I/O操作。其次,许多流对此类操作有优化的实现:例如CopyTo
。MemoryStream.CopyTo
,例如,只需将内部缓冲区的内容一次性写入到传递给它的Stream
。即使一个Stream
没有重写CopyTo
,基类的CopyTo
实现也已经提供了一个这样的复制循环,并且默认使用租借的更大缓冲区。因此,dotnet/runtime#103669将Append
的实现更改为分配一个小的临时Stream
对象,包装这个NonCryptographicHashAlgorithm
实例,任何对Write
的调用都被翻译成对Append
的调用。这是一个很好的例子,有时我们实际上会选择为换取显著的吞吐量收益而付出小的、短暂的分配成本。// dotnet run -c Release -f net8.0 --filter "*" using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Configs; using BenchmarkDotNet.Environments; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Running; using System.IO.Hashing; var config = DefaultConfig.Instance .AddJob(Job.Default.WithRuntime(CoreRuntime.Core80).WithNuGet("System.IO.Hashing", "8.0.0").AsBaseline()) .AddJob(Job.Default.WithRuntime(CoreRuntime.Core90).WithNuGet("System.IO.Hashing", "9.0.0-rc.1.24431.7")); BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config); [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "NuGetReferences")] public class Tests { private Stream _stream; private byte[] _bytes; [GlobalSetup] public void Setup() { _bytes = new byte[1024 * 1024]; new Random(42).NextBytes(_bytes); string path = Path.GetRandomFileName(); File.WriteAllBytes(path, _bytes); _stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 0, FileOptions.DeleteOnClose); } [GlobalCleanup] public void Cleanup() => _stream.Dispose(); [Benchmark] public ulong Hash() { _stream.Position = 0; var hash = new XxHash3(); hash.Append(_stream); return hash.GetCurrentHashAsUInt64(); } } | Method | Runtime | Mean | | --- | --- | --- | | Hash | .NET 8.0 | 91.60 us | | Hash | .NET 9.0 | 61.26 us |
-
不必要的virtual。
virtual
方法有开销。首先,与non-virtual
方法相比,调用virtual
方法更昂贵,因为它需要几次间接操作来找到实际要调用的目标方法(实际的目标可能基于使用的具体类型而不同)。其次,如果没有像动态PGO这样的技术,virtual
方法不会内联,因为编译器不能静态地看到应该内联哪个目标(即使动态PGO使得对最常见的类型的内联成为可能,仍然需要检查以确保可以跟随该路径)。因此,如果某些东西不需要virtual
,从性能角度来看,最好不是virtual
。如果这些是internal
的,除非它们被某个东西明确重写,否则没有理由保持它们为virtual
。dotnet/runtime#104453、dotnet/runtime#104456和dotnet/runtime#104483都是由@xtqqczze提交的,它们都解决了这样的问题,从一些internal
成员中移除了virtual
。 -
ReadOnlySpan vs Span。我们作为开发者喜欢保护自己,例如通过将字段设置为
readonly
来避免意外更改它们。这样的更改也可能带来性能好处,例如JIT可以更好地优化静态readonly
字段,而不是那些不是readonly
的。这些原则同样适用于Span<T>
和ReadOnlySpan<T>
。如果一个方法不需要更改传递给它的集合的内部内容,使用ReadOnlySpan<T>
而不是Span<T>
既减少了犯错的可能性,又能带来性能优势。这两个类型的实现几乎完全相同,关键的区别在于索引器返回的是ref T
还是ref readonly T
。然而,Span<T>
还有一个额外的行,这在ReadOnlySpan<T>
中不存在。Span<T>
的构造函数有这一额外检查:if (!typeof(T).IsValueType && array.GetType() != typeof(T[])) ThrowHelper.ThrowArrayTypeMismatchException();
这个检查存在的原因是数组协变。假设你有以下代码:
Base[] array = new Derived[3]; class Base { } class Derived : Base { }
这段代码可以编译并成功运行,因为.NET支持数组协变,意味着派生类型的数组可以被用作基类型的数组。但是,这里有一个重要的限制。让我们稍微修改一下这个例子:
Base[] array = new Derived[3]; array[0] = new AlsoDerived(); // 呀哦! class Base { } class Derived : Base { } class AlsoDerived : Base { }
这段代码也可以编译成功,但在运行时会导致
ArrayTypeMismatchException
。这是因为它试图将AlsoDerived
实例存储到Derived[]
中,两者之间没有允许这种操作的关系。强制执行这种限制所需的检查会产生成本,每次尝试写入数组时都会产生(除了编译器可以证明是安全的并省略这些成本的情况)。当Span<T>
引入时,决定将这个检查提升到Span
的构造函数中;这样,一旦得到一个有效的span
,就无需在每个写入操作上进行检查,只在构造时进行一次。这就是那行额外代码的作用,检查确保指定的T
与提供的数组的元素类型相同。这意味着像这样的代码也会抛出ArrayTypeMismatchException
:Span<Object> span = new string[2]; // 呀哦
但这也意味着,如果你在可能使用
ReadOnlySpan<T>
的情况下使用了Span<T>
,你很可能是不必要地承担了这种开销,这意味着你可能会遇到意外的异常,同时也在承担性能上的“花生酱”成本。dotnet/runtime#104864通过将一些Span<T>
替换为ReadOnlySpan<T>
来减少这种开销,同时也提高了代码的可维护性。 -
readonly和const。同样地,将字段更改为
const
,将非readonly
字段更改为readonly
,并移除不必要的属性设置器都对维护性有益,同时也可能带来性能提升。将字段设置为const
避免了不必要的内存访问,同时允许JIT更好地执行常量传播。将静态字段设置为readonly
使得JIT可以将它们视为 tier 1 编译中的const
。dotnet/runtime#100728对此进行了更新,覆盖了数百个实例。 -
MemoryCache。dotnet/runtime#103992由@ADNewsom09提交,解决了
Microsoft.Extensions.Caching.Memory
中的一个效率问题。如果多个并发操作最终触发了缓存压缩操作,许多涉及的线程可能会重复彼此的工作。修复方法是仅让一个线程执行压缩操作。 -
BinaryReader。dotnet/runtime#80331由@teo-tsirpanis提交,使得仅在读取文本时相关的
BinaryReader
分配变得延迟,如果BinaryReader
从未被用于读取文本,应用程序就不需要为这个分配付出代价。 -
ArrayBufferWriter。dotnet/runtime#88009由@AlexRadch添加了一个新的
ResetWrittenCount
方法到ArrayBufferWriter
。ArrayBufferWriter.Clear
已经存在,但它除了将写入计数设置为0之外,还会清除底层缓冲区。在许多情况下,这种清除是不必要的开销,因此ResetWrittenCount
允许避免这种开销。(有一个关于是否需要这样的新方法,以及是否可以修改Clear
以去除零化的有趣讨论。但关于可能将损坏的数据作为无效数据传递给消费方的担忧导致了新方法的添加。) -
基于Span的文件方法。静态的
File
类提供了与文件交互的简单辅助器,例如File.WriteAllText
。历史上,这些方法适用于字符串和数组。这意味着,如果有人传递了一个Span,他们要么不能使用这些简单的辅助器,要么需要为从Span创建字符串或数组付出代价。dotnet/runtime#103308添加了新的基于Span的重载,使得开发者在这里不必在简单性和性能之间做出选择。 -
字符串连接vs Append。在循环内进行字符串连接是众所周知的性能陷阱,因为极端情况下,它可能导致显著的
O(N^2)
成本。然而,在MailAddressCollection
中确实发生了这样的字符串连接,每个地址的编码版本都被附加到一个字符串上,使用的是字符串连接。@YohDeadfall提交的dotnet/runtime#95760将其更改为使用构建器。 -
闭包。配置生成器是在.NET 8中引入的,旨在显著提高配置绑定的性能,同时使其对本地AOT更友好。它实现了这两点。然而,它还可以进一步改进。成功路径上的一个意外额外分配仅与失败路径相关,因为代码是如何被生成的。对于这样的调用站点:
public static void M(IConfiguration configuration, C1 c) => configuration.Bind(c);
生成器会生成一个类似这样的方法:
public static void BindCore(IConfiguration configuration, ref C1 obj, BinderOptions? binderOptions) { ValidateConfigurationKeys(typeof(C1), s_configKeys_C1, configuration, binderOptions); if (configuration["Value"] is string value15) obj.Value = ParseInt(value15, () => configuration.GetSection("Value").Path); }
被传递给
ParseInt
辅助器的lambda访问configuration
,这是一个在外部作为参数定义的。为了将这个数据传递给lambda,编译器会分配一个“显示类”来存储信息,将lambda体的内容转换为对这个显示类的方法。这个显示类在包含数据的范围开始时被分配,在这种情况下,意味着它在这个范围的开始时被分配。这意味着它无论如何都会被分配,即使ParseInt
被调用,传递给它的委托也仅在失败时被调用。dotnet/runtime#100257由@pedrobsaila重写了生成器代码,使得这种分配不再发生。 -
Stream.Read/Write Span覆盖。没有重写基于Span的
Read
/Write
方法的Stream
最终会使用分配的基类实现。在dotnet/runtime
中,我们已经几乎在所有地方覆盖了这些方法,但偶尔我们还是会发现一个漏掉的实例。dotnet/runtime#86674由@hrrrrustic修复了StreamOnSqlBytes
类型中的一个这样的实例。 -
全球化数组。每个
NumberFormatInfo
对象默认将NumberGroupSizes
、CurrentGroupSizes
和PercentGroupSizes
初始化为新的实例new int[] { 3 }
(即使后续的初始化会覆盖它们)。而且,这些数组从未被传递给
接下来是什么?
或许再来一首诗吧?这次来个首字母诗:
以无与伦比的速度推动创新,
为开发者打开所需的大门。
涡轮增压性能,突破传统模式,
新基准被超越,指标如此大胆。
赋予开发者力量,梦想展翅高飞,
用.NET的力量将愿景转变。
精确而巧妙地应对挑战,
激发创造力,无处不在的改进。
滋养成长,挑战极限,
提升成功,向天空伸手。
几百页之后,我依然不是诗人。哦,算了吧。
有人偶尔会问我为什么投资撰写这些“.NET性能改进”的文章。答案并不是单一的。不分先后顺序:
-
个人学习。 整年我都会密切关注所有在发布过程中发生的性能改进,有时是远程观察,有时是作为做出这些改变的人。撰写这篇文章就像是一个强制性的过程,让我重新审视所有的改进,并深刻理解这些改变及其对整体格局的相关性。这对我来说是一个学习的机会。
-
测试。 团队中的一个开发者最近对我说:“我喜欢一年一度你对我们优化进行压力测试,揭示出低效之处。”每年当我审视这些改进时,仅仅重新验证这些改进通常就能揭示回归问题、被遗漏的案例或未来可以进一步解决的机遇。这再次证明,进行更多测试、以全新的视角审视问题的重要性。
-
感谢。 每个版本中的许多性能改进并非来自.NET团队或甚至微软的员工。它们来自全球.NET生态系统中那些热情且才华横溢的个体,我喜欢突出他们的贡献。因此,在文章中,我会在提及非微软全职员工提交的PR时进行标注。在这篇文章中,这部分PR占了所有引用的PR的大约20%。非常了不起。衷心感谢每一位为让.NET对所有人变得更好而努力的人。
-
兴奋。 对于.NET的发展速度,开发者们往往持有不同的看法,有些人非常欣赏频繁引入的新功能,而有些人则担心无法跟上所有的新变化。但大家似乎都认同对“免费性能”的热爱,这正是这些文章所讨论的主要内容。.NET在每次发布中都变得更加快速,看到所有亮点汇集在一个地方是非常令人兴奋的。
-
教育。 文章中涵盖了多种性能改进的形式。有些改进你只需升级运行时就能完全免费获得;运行时的实现更优,所以当你运行在这些运行时上时,你的代码也会变得更好。有些改进你只需升级运行时并重新编译就能完全免费获得;C#编译器本身会生成更好的代码,通常利用运行时暴露的新表面区域。还有其他改进是新的功能,除了运行时和编译器利用之外,你还可以直接利用,从而使你的代码运行得更快。教育人们了解这些功能的能力和为什么以及在哪里利用它们对我来说很重要。但除了新功能之外,在运行时应用的所有其他优化技术通常具有更广泛的应用性。通过学习这些优化技术如何在运行时应用,你可以将其推广并应用到自己的代码中,使代码运行得更快。
如果你已经读到这里,我希望你确实学到了一些东西,并对.NET 9的发布感到兴奋。从我的热情絮语和笨拙的诗歌中,你很可能已经看出来了,我对.NET、.NET 9所取得的成就以及这个平台的未来感到无比兴奋。如果你已经在使用.NET 8,升级到.NET 9应该会非常轻松(.NET 9 发布候选版 已可供下载),如果你能升级并分享你在过程中取得的成果或遇到的问题,我们将非常感激。我们很高兴从你那里学习。如果你有关于如何进一步改进.NET性能以供.NET 10使用的想法,请加入我们的 dotnet/runtime。
祝编码愉快!
标签:Tests,Improvements,using,dotnet,Performance,NET,runtime,public From: https://www.cnblogs.com/yahle/p/18535209/performance-improvements-in-net-9