Performance Improvements in .NET 9 [翻译 by chatglm]
.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性能改进、.NET 7中的Arm64性能改进以及.NET 8中的Arm64性能改进中了解更多相关信息。在.NET 9中,这些改进还在持续进行。以下是一些例子:
-
更好的屏障。dotnet/runtime#91553通过使用
stlur
(存储-释放寄存器)指令来实现volatile写入,而不是dmb
(数据内存屏障)/str
(存储)指令对(stlur
通常更便宜)。类似地,dotnet/runtime#101359在处理float
类型的volatile读取和写入时消除了完整的内存屏障。例如,之前可能产生ldr
(寄存器加载)/dmb
对的操作,现在可能产生ldar
(加载-获取寄存器)/fmov
(浮点数移动)对。 -
更好的开关。根据
switch
语句的形状,C#编译器可能会生成多种IL模式,其中之一是使用switch
IL指令。通常,对于switch
IL指令,JIT会生成跳转表,但对于某些形式,它有一个优化,可以改用位测试。到目前为止,这个优化仅存在于x86/64上,使用bt
(位测试)指令。现在,随着dotnet/runtime#91811,它也适用于Arm,使用tbz
(测试位并零分支)指令。 -
更好的条件判断。Arm有包含分支的条件的指令,但不需要实际的分支,例如.NET 8中的性能改进中提到的
csel
(条件选择)指令,它“根据条件从两个寄存器中的一个选择一个值”。另一个这样的指令是csinc
(条件选择增量),它根据条件从两个寄存器中的一个选择值或另一个寄存器的值加一。dotnet/runtime#91262由@c272实现,使得JIT可以利用csinc
,因此像x = condition ? x + 1 : y;
这样的语句可以编译成csinc
而不是分支结构。dotnet/runtime#92810还改进了JIT为某些SequenceEqual
操作(例如"hello, there"u8.SequenceEqual(spanOfBytes)
)生成的自定义比较操作,使其能够使用ccmp
(条件比较)。 -
更好的乘法。Arm有单条指令代表执行乘法后跟加法、减法或取反。@c272的dotnet/runtime#91886找到这样的连续乘法后跟一个操作的序列,并将其合并到使用单个组合指令。
-
更好的加载。Arm有将值从内存加载到单个寄存器的指令,但它也有将多个值加载到多个寄存器的指令。当JIT生成自定义内存复制(例如
byteArray.AsSpan(0, 32).SequenceEqual(otherByteArray)
)时,它可能发出多个ldr
指令来加载值到寄存器。dotnet/runtime#92704允许将这些指令对合并到ldp
(寄存器对加载)指令中,以便将两个值加载到两个寄存器。
ARM SVE
推出一个新的指令集是一项重大且艰巨的任务。我以前提到过,为撰写这类“.NET X性能提升”的文章,我有一套准备流程,包括在整个年份中,我会维护一个可能要讨论的PR(Pull Request,拉取请求)清单。仅就“SVE”而言,我就有超过200个链接。我不会用这样的清单来烦扰你;如果你感兴趣,可以搜索SVE PRs,其中包括@a74nh的PR、@ebepho的PR、@mikabl-arm的PR、@snickolls-arm的PR和@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还需要学会如何用用户代码表示这些数据,即这些向量。所有这些都需要大量的工作,尤其是考虑到有数千个操作需要表示。而硬件内嵌函数使这项工作更加繁重。
硬件内嵌函数是.NET的一个特性,其中每个这样的指令都显示为.NET方法,例如Sse2.Add
,JIT会将使用该方法的调用转换为底层对应的指令。如果你查看dotnet/runtime中的Sve.cs,你会看到System.Runtime.Intrinsics.Arm.Sve
类型,它已经公开了超过1400个方法(这个数字不是笔误)。
如果你打开这个文件,有两个有趣的地方值得注意(除了它的长度之外):
-
Vector<T>
的使用。 .NET对SIMD的探索始于2014年,当时引入了Vector<T>
类型。《JIT终于提议了JIT和SIMD的婚姻》一文中提到。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>
是非常有意义的。旧的就是新的。 -
Sve
类被标记为[Experimental]
。 .NET 8和C# 12引入了[Experimental]
属性,目的是用来指示一个在稳定程序集之外的某些功能还不稳定,未来可能会发生变化。如果代码尝试使用这样的成员,默认情况下C#编译器会发出错误,告诉开发者他们正在使用可能会破坏的东西。但只要开发者愿意接受这种破坏性的变化风险,他们就可以抑制这个错误。设计和启用SVE支持是一项巨大的、跨多年的努力,尽管支持是功能性的,并且鼓励人们尝试,但它还没有足够成熟,让我们完全有信心它的形状不会需要进化(对于.NET 9,它也限制在具有128位向量宽度的硬件上,但随后的版本将取消这个限制)。因此,标记为[Experimental]
。
AVX10.1
尽管SVE(Scalable Vector Extension)的工作量很大,但它并不是.NET 9中唯一可用的新指令集架构(ISA)。这主要归功于@Ruihan-Yin的dotnet/runtime#99784和@khushal1996的dotnet/runtime#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,JIT现在可以在可用的情况下使用AVX512指令来每次指令零初始化512位,而不是仅使用其他ISA时“仅仅”256位或128位。
下面是一个需要零初始化256字节的基准测试示例:
// 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问世以来,普遍的共识是,绝大多数需要同步访问共享状态的代码应该直接使用Monitor
,或者更常见的是通过C#语言的语法,使用lock(...)
。虽然还有许多其他同步原语可供选择,它们在复杂性和目的上各不相同,但lock(...)
是主力工具,也是大家应该默认尝试的方法。
在.NET问世20多年后,这种观点正在逐渐变化,但变化仅限于细微之处。lock(...)
仍然是首选语法,但在.NET 9中,根据dotnet/runtime#87672和dotnet/runtime#102222的引入,现在有一个专门的System.Threading.Lock
类型。任何之前只是为了使用lock(...)
而分配object
的地方,现在都应该考虑使用新的Lock
类型。当然,你仍然可以使用object
,在某些情况下,比如当你使用Monitor
的“条件变量”特性(如Signal
和Wait
)时,你仍然需要这样做,而且如果尝试减少托管分配,你有一个可以充当监视器的现有对象,你也可能想要这样做。但锁定Lock
可能会更高效。它还可以帮助代码自我文档化,使得代码更加整洁和易于维护。
正如这个基准测试所示,使用这两种方法的语法可以是相同的。
// 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 readonly object _monitor = new();
private readonly Lock _lock = new();
private int _value;
[Benchmark]
public void WithMonitor()
{
lock (_monitor)
{
_value++;
}
}
[Benchmark]
public void WithLock()
{
lock (_lock)
{
_value++;
}
}
}
Lock
通常会更便宜一些(并且随着大多数锁定转移到新类型,我们未来可能能够使大多数object
更轻量,因为不再优化对任意对象的直接锁定):
Method | Mean |
| --- | --- |
| WithMonitor | 14.30 ns |
| WithLock | 13.86 ns
注意,C# 13对System.Threading.Lock
有特殊的识别。如果你查看WithMonitor
上面生成的代码,它与以下代码等效:
public void WithMonitor()
{
object monitor = _monitor;
bool lockTaken = false;
try
{
Monitor.Enter(monitor, ref lockTaken);
_value++;
}
finally
{
if (lockTaken)
{
Monitor.Exit(monitor);
}
}
}
尽管语法相同,但这里是一个WithLock
生成的等效代码:
Lock.Scope scope = _lock.EnterScope();
try
{
_value++;
}
finally
{
scope.Dispose();
}
我们已经开始在内部使用Lock
。例如,dotnet/runtime#103085和dotnet/runtime#103104在Timer
、ThreadLocal
和RegisteredWaitHandle
中使用了它,而不是object
锁。随着时间的推移,我预计会看到越来越多的使用转移到这种新方式。
当然,虽然锁定是同步的首选推荐,但仍然有很多代码需要更高吞吐量和可扩展性,而这正是来自无锁编程的优势,而实现这种编程的主力工具是Interlocked
。在.NET 9中,Interlocked.Exchange
和Interlocked.CompareExchange
获得了一些非常受欢迎的功能。首先,dotnet/runtime#92974由@MichalPetryka、dotnet/runtime#97588由@filipnavara和dotnet/runtime#106660为Interlocked
赋予了新的能力,允许操作小于int
的类型。它引入了新的Exchange
和CompareExchange
重载,可以工作在byte
、sbyte
、ushort
和short
上。这些重载是公开的,任何人都可以调用,但它们也被@MichalPetryka在dotnet/runtime#97528中消费,以改进Parallel.ForAsync<T>
。ForAsync
接受一个要处理的T
的范围,并安排多个工作者,这些工作者需要重复从范围中获取下一个项目,直到范围耗尽。对于任意类型,这意味着ForAsync
需要锁定以保护范围的迭代。但对于可以Interlocked
操作的类型,我们可以使用低锁定技术来避免完全不需要锁定。
// 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 async Task ParallelForAsync()
{
await Parallel.ForAsync('\0', '\uFFFF', async (c, _) =>
{
});
}
}
Method | Runtime | Mean | Ratio |
---|---|---|---|
ParallelForAsync | .NET 8.0 | 42.807 ms | 1.00 |
ParallelForAsync | .NET 9.0 | 7.184 ms | 0.17 |
即使有了这些新的重载,仍然有些地方希望使用Interlocked.Exchange
或Interlocked.CompareExchange
,但它们无法轻松使用。考虑一下前面提到的Parallel.ForAsync
。我们非常希望能够简单地调用Interlocked.CompareExchange<T>
,但CompareExchange<T>
只适用于引用类型。所以我们只能求助于不安全代码:
static unsafe bool CompareExchange<T>(ref T location, T value, T comparand) =>
sizeof(T) == sizeof(byte) ? Interlocked.CompareExchange(ref Unsafe.As<T, byte>(ref location), Unsafe.As<T, byte>(ref value), Unsafe.As<T, byte>(ref comparand)) == Unsafe.As<T, byte>(ref comparand) :
sizeof(T) == sizeof(ushort) ? Interlocked.CompareExchange(ref Unsafe.As<T, ushort>(ref location), Unsafe.As<T, ushort>(ref value), Unsafe.As<T, ushort>(ref comparand)) == Unsafe.As<T, ushort>(ref comparand) :
sizeof(T) == sizeof(uint) ? Interlocked.CompareExchange(ref Unsafe.As<T, uint>(ref location), Unsafe.As<T, uint>(ref value), Unsafe.As<T, uint>(ref comparand)) == Unsafe.As<T, uint>(ref comparand) :
sizeof(T) == sizeof(ulong) ? Interlocked.CompareExchange(ref Unsafe.As<T, ulong>(ref location), Unsafe.As<T, ulong>(ref value), Unsafe.As<T, ulong>(ref comparand)) == Unsafe.As<T, ulong>(ref comparand) :
throw new UnreachableException();
另一个希望使用Interlocked.Exchange
和Interlocked.CompareExchange
的地方是与枚举一起使用。在某种算法中,使用这些API来转换状态是很常见的,理想的状态是这些状态应该用枚举来表示。然而,没有{Compare}Exchange
的重载适用于枚举,因此开发人员被迫使用整数而不是枚举,并且通常会在代码中看到这样的注释:“这应该是一个枚举,但枚举不能与CompareExchange一起使用。”至少,他们是这样做的,直到.NET 9。
现在,在.NET 9中,根据dotnet/runtime#104558,通用的Exchange
和CompareExchange
的class
约束已被移除。这意味着使用Exchange<T>
和CompareExchange<T>
可以为任何T
编译。然后,在运行时,会检查T
以确保它是引用类型、原始类型或枚举类型;如果不是这些类型,则会抛出异常。当它是这些类型之一时,它会委托给相应大小的重载。例如,下面的代码现在可以编译并成功运行:
static DayOfWeek UpdateIfEqual(ref DayOfWeek location, DayOfWeek newValue, DayOfWeek expectedValue) =>
Interlocked.CompareExchange(ref location, newValue, expectedValue);
这不仅对易用性有好处,而且在几个方面都有助于性能。首先,它使得像Parallel.ForAsync
这样的性能改进变得可能,而不需要
线程 -2
Volatile(易变的)。一个“内存模型”描述了线程如何与内存交互,以及关于不同线程如何在共享内存中产生和消费变化所做的保证。来自单个线程的内存读取和写入保证了该线程能够按发生的顺序观察到这些操作,但一旦有多个线程参与,内存模型就必须定义哪些行为可以依赖,哪些不能。例如,如果有两个字段_a
和_b
,它们都初始化为0
,如果一个线程执行以下操作:
_a = 1;
_b = 2;
然后另一个线程执行:
while (_b != 2);
Assert(_a == 1);
这个断言是否总是通过?这取决于内存模型,以及线程1的写入是否可能被重新排序(由涉及的编译器或甚至由硬件执行),使得线程2在看到对_b
的写入之前看到了对_a
的写入。很长一段时间,.NET唯一的官方内存模型是由ECMA 335规范定义的,但实际实现,包括coreclr,通常比ECMA详细的内容提供了更强的保证。幸运的是,官方的 .NET内存模型 已经被文档化了。然而,由于防御性编码、对内存模型的未知或过时的需求等原因,核心库中使用的一些做法现在不再是必要的。一个主要工具是volatile
关键字/Volatile
类,用于在相关的层面上进行编码,当内存模型相关时。将字段标记为volatile
会使对该字段的任何读取或写入被视为“易变”的,就像使用Volatile.Read
/Volatile.Write
执行读取或写入一样。使读取或写入易变意味着它阻止了某些类型的“移动”,例如,如果上一个例子中的_a
和_b
都被标记为volatile
,那么断言总是会通过。根据具体情况和目标平台,将字段或操作标记为volatile
可能会带来一定的开销。例如,它可能会限制C#编译器和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
{
private volatile int _volatile;
private int _nonVolatile;
[Benchmark]
public int UsingVolatile() => _volatile + _volatile;
[Benchmark]
public int UsingNonVolatile() => _nonVolatile + _nonVolatile;
}
在.NET 9上产生以下汇编代码:
; Tests.UsingVolatile()
mov eax,[rdi+8]
add eax,[rdi+8]
ret
; Total bytes of code 7
; Tests.UsingNonVolatile()
mov eax,[rdi+0C]
add eax,eax
ret
; Total bytes of code 6
两个汇编代码块之间的主要区别在于add
指令。在UsingVolatile
方法中,第一条指令是从地址rcx+8
加载值,然后再次读取相同的rcx+8
内存位置,以便将刚刚读取的值与当前内存位置中的值相加。在UsingNonVolatile
中,它以相同的方式从rcx+0xc
读取值,但add
指令并不是再次从内存中读取,而是只是将寄存器中的值加倍。易变性的一个效应是它要求读取不能被移动,这也意味着两个读取都必须保留。这里再看另一个例子:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private volatile bool _volatile;
private bool _nonVolatile;
[Benchmark]
public int CountVolatile()
{
int count = 0;
while (_volatile) count++;
return count;
}
[Benchmark]
public int CountNonVolatile()
{
int count = 0;
while (_nonVolatile) count++;
return count;
}
}
在.NET 9上产生以下汇编代码:
; Tests.CountVolatile()
push rbp
mov rbp,rsp
xor eax,eax
cmp byte ptr [rdi+8],0
jne short M00_L01
M00_L00:
pop rbp
ret
M00_L01:
inc eax
cmp byte ptr [rdi+8],0
jne short M00_L01
jmp short M00_L00
; Total bytes of code 24
; Tests.CountNonVolatile()
push rbp
mov rbp,rsp
xor eax,eax
cmp byte ptr [rdi+9],0
jne short M00_L00
pop rbp
ret
M00_L00:
jmp short M00_L00
; Total bytes of code 16
它们看起来有些相似,事实上前五个指令几乎相同,但有一个关键的区别。在这两种情况下,bool
值都被加载并检查是否为false
(cmp
与0
后跟条件跳转),如果是,则跳转到结束ret
以退出方法。编译器将while (cond) { ... }
循环重写为更类似于if (cond) { do { ... } while(cond); }
的形式,因此这个初始测试是那个if (cond)
的测试。但是,然后事情就有了重大的变化。CountVolatile
继续执行do while
等效的操作,递增count
(存储在eax
中),读取_volatile
并将其与0
(false
)进行比较,如果仍然是true
,则跳回循环顶部再次执行。基本上就是你期望的结果。但是,现在看看CountNonVolatile
。循环现在变成了:
M00_L00:
jmp short M00_L00
它现在处于一个无限循环中,无条件地跳回到同一个jmp
指令,永远循环。这是因为JIT能够将读取_nonVolatile
的操作从循环中提出来。它还看到没有人会观察到count
的更改值,因此它也忽略了递增。在这种情况下,它更像是如果我用C#编写了这个:
public int CountNonVolatile()
{
int count = 0;
if (_nonVolatile)
{
while (true);
}
return count;
}
当字段不是volatile
时,这种提升是允许的。多次看到人们尝试进行低锁编程时遇到这种后例子的后果:他们会使用某个bool
来通知消费者应该跳出循环,但bool
不是volatile
的,因此消费者从未注意到生产者最终将其设置为true
。
这些都是volatile
在C#或JIT编译器可以做什么(或避免做什么)方面的影响的例子。但是,JIT还需要为了确保硬件能够遵守开发人员设定的要求而执行某些操作(而不是避免)。在某些硬件上,如x64,内存模型的硬件相对“强大”,意味着它不会进行大多数volatile
阻止的重排,因此你不会在汇编代码中看到JIT插入任何帮助硬件强制执行约束的代码。但是,在其他硬件上,如Arm64,硬件具有相对“较弱”的模型,意味着它允许更多的这种重排,因此JIT需要积极抑制这些重排,通过在代码中插入适当的“内存屏障”。在Arm上,这体现在使用如dmb
(“数据内存屏障”)指令上。这些屏障有一些相关开销。
由于这些原因,减少volatile
的数量对性能有好处,但当然你需要确保有足够的volatile
来实现正确的应用程序(最好的答案是避免编写锁-锁代码,这样你就永远不需要知道或考虑volatile
)。这是一个平衡。幸运的是,并且把我们带回到我们为什么在讨论这个问题,有一些常见的场景中volatile
过去被推荐,但现在由于我们已经有一个明确的内存模型,这些使用变得过时了。移除它们可以帮助避免在代码中产生薄成本层。因此,dotnet/runtime#100969 和 dotnet/runtime#101346 移除了一些不再必要的volatile
使用。几乎所有这些使用都是作为引用类型惰性初始化的一部分,例如:
private volatile MyReferenceType? _instance;
public MyReferenceType Instance => _instance ??= new MyReferenceType();
如果我们展开这个不使用??=
,它看起来像这样:
private MyReferenceType? _instance;
public MyReferenceType Instance
{
get
{
MyReferenceType? instance = _instance;
if (instance is null)
{
_instance = instance = new MyReferenceType();
}
return instance;
}
}
在这里使用volatile
的原因有两个,一个用于读取部分,一个用于写入部分。如果没有volatile
,可能会引入一个读取,使代码等同于这个:
private MyReferenceType? _instance;
public MyReferenceType Instance
{
get
{
MyReferenceType? instance = _instance;
if (_instance is null) // 注意这里的使用 _
{
_instance = instance = new MyReferenceType();
}
return instance;
}
}
如果发生这种情况,那么在两个读取之间,_instance
的值可能会从null
变为非null
,在这种情况下,instance
可能会被分配为null
,_instance is null
可能为false
,return instance
将返回null
。
幸运的是,.NET内存模型明确指出“读取不能被引入。”然后是关于写入的担忧。导致在此处使用volatile
的担忧是MyReferenceType
中的初始化操作。想象一下,如果MyReferenceType
被定义为这样:
class MyReferenceType()
{
internal int _value;
public MyReferenceType() => _value = 42;
}
那么问题就变成了“是否可能在另一个线程看到_value
写入之后将实例写入到_instance
?”换句话说,代码是否可能逻辑上等同于这个:
private MyReferenceType? _instance;
public MyReferenceType Instance
{
get
{
MyReferenceType? instance = _instance;
if (_instance is null)
{
_instance = instance = RuntimeHelpers.GetUninitializedObject(typeof(MyReferenceType));
instance._value = 42;
}
return instance;
}
}
如果这可以发生,那么两个线程可能会竞争访问Instance
,其中一个线程可能已经设置了_instance
(但_value
尚未设置),然后另一个线程访问Instance
,看到_instance
为非null
,并开始使用它,尽管_value
尚未初始化。幸运的是,在这里,.NET内存模型也明确覆盖了这一点:
“对象分配到可能由其他线程访问的位置是对实例的字段/元素和元数据的发布。优化编译器必须保留对象分配和依赖于数据的内存访问的顺序。动机是确保将对象引用存储在共享内存中作为所有通过实例引用的可达修改的“提交点”。”
呼!
ManagedThreadId(托管线程ID)
dotnet/runtime#91232 是有趣的,因为它让人感觉像是“我们为什么不早做这件事”。Thread.ManagedThreadId
实现为一个内部的调用(一个FCALL),进入运行时,然后调用 ThreadNative::GetManagedThreadId
,这又反过来读取线程对象的 m_ManagedThreadId
字段。至少,这是对象的C定义中的字段位置。托管的 Thread
对象有对应于此的相应字段,在这个例子中是 _managedThreadId
。所以这个PR做了什么?它移除了那些复杂的操作,直接将整个实现变为 public int ManagedThreadId => _managedThreadId
。(值得注意的是,Thread.CurrentThread.ManagedThreadId
之前已经被JIT特别识别,所以这个更改只影响从其他 Thread
实例访问 ManagedThreadId
的情况。)
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private Thread _thread = Thread.CurrentThread;
[Benchmark]
public int GetID() => _thread.ManagedThreadId;
}
// .NET 8
; Tests.GetID()
mov rdi,[rdi+8]
cmp [rdi],edi
jmp near ptr System.Threading.Thread.get_ManagedThreadId()
; Total bytes of code 11
**Extern method**
System.Threading.Thread.get_ManagedThreadId()
// .NET 9
; Tests.GetID()
mov rax,[rdi+8]
mov eax,[rax+34]
ret
; Total bytes of code 8
本地AOT的端口
之前的.NET版本使coreclr上的线程局部状态(TLS)访问的快速路径进行了内联。通过 dotnet/runtime#104282、dotnet/runtime#89472 和 dotnet/runtime#97910,这个改进也来到了本地AOT。类似地,dotnet/runtime#103675 将coreclr的“yield规范化”实现端口到了本地AOT;这是为了支持运行时测量各种pause
指令的成本,这些成本可以用于调整轮询和轮询等待。
启动时间
与线程相关的性能改进通常与稳定状态吞吐量改进有关,例如在处理请求时减少同步成本。然而,dotnet/runtime#106724 来自 @harisokanovic 的改进却专注于减少Linux上使用.NET的过程的启动开销。垃圾收集器使用相当于进程范围内内存屏障(也公开作为 Interlocked.MemoryBarrierProcessWide
)来确保所有参与收集的线程看到一致的状态。在Linux上,高效地实现这个方法涉及使用 membarrier
系统调用,并且使用这个调用需要在启动时提前进行,这意味着在启动时就要做同样的系统调用,以实现优化。然而,Linux内核有一些优化,使得当进程内只有一个线程时,这种初始化的成本非常低。之前的.NET实现保证了总会有多个线程。这个PR改变了初始化的位置,以最大化只有一个线程在进程中的可能性,从而使得启动更快。在各种系统上的测量显示,这个改进可以带来超过10毫秒的改进,这占到了.NET过程在Linux上的启动开销的一个相当大的比例。
反射
反射是.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)
IndexOf
如前文在 .NET 8 中的性能提升 和本篇博客中提到的,我单挑最喜欢的 .NET 8 性能提升来自于启用动态 Profile-Guided Optimization(PGO)。但是,我第二喜欢的提升来自于 SearchValues<T>
的引入。SearchValues<T>
通过预先计算在搜索特定一组值(或者搜索除了这些特定值以外的任何值)时使用的算法,并将这些信息存储起来以供后续重复使用,从而实现了搜索优化。在 .NET 8 内部,根据提供数据的特点,可能选择多达 15 个不同的实现。这种类型在它所做的事情上非常出色,以至于在 .NET 8 发布的部分中,它被使用了超过 60 次。在 .NET 9 中,SearchValues<T>
的使用更加广泛,并且以多种方式得到了进一步的提升。
SearchValues<T>
是一个泛型类型,从理论上讲,它可以用于任何 T
,但实际上,涉及到的算法需要针对数据的特点进行特殊处理,因此 SearchValues.Create
工厂方法仅允许创建 SearchValues<byte>
和 SearchValues<char>
实例,这些实例提供了专门的实现。例如,许多之前提到的 SearchValues<T>
的使用案例是搜索 ASCII 子集的一部分,比如来自 Regex.Escape
的这个使用示例,它使得快速搜索所有需要转义的字符变得简单:
private static readonly SearchValues<char> s_metachars = SearchValues.Create("\t\n\f\r #$()*+.?[\\^{|");
如果你打印出由那个 Create
调用的实例返回的类型名称,作为今天的实现细节,你会看到类似这样的内容:
System.Buffers.AsciiCharSearchValues`1[System.Buffers.IndexOfAnyAsciiSearcher+Default]
这个类型为 SearchValues<char>
提供了一个优化搜索任何 ASCII 子集的专门实现,基于 http://0x80.pl/articles/simd-byte-lookup.html 中描述的“通用算法”。本质上,这个算法维护了一个 8 行 16 列的位图,这并不是巧合,因为 ASCII 的范围是 0 到 127。位图中的每个 128 位表示对应的 ASCII 值是否在集合中。输入字符被映射到字节,其中大于 127 的字符被映射为一个表示不匹配的值。ASCII 值的低位四比特(4位)用于选择 16 行中的一行,高位四比特用于选择 8 列中的一列。这个算法的妙处在于,在大多数支持的平台上,存在 SIMD 指令,可以在几条指令中并发处理许多字符。
所以,在 .NET 8 中,SearchValues<T>
仅适用于 byte
和 char
。但是,现在在 .NET 9 中,得益于 dotnet/runtime#88394、dotnet/runtime#96429、dotnet/runtime#96928、dotnet/runtime#98901 和 dotnet/runtime#98902,你也可以创建 SearchValues<string>
实例。字符串处理与 byte
和 char
不同。对于 byte
,你是在搜索一个 byte
集合中的特定 byte
值。对于 char
,你是在搜索一个 char
集合中的特定 char
值。但是,对于 string
,SearchValues<string>
不是在搜索一个 string
集合中的特定 string
值,而是在搜索一个 string
集合中的特定 string
值,即在 char
集合中的多字符串搜索。换句话说,这是一种多字符串搜索。例如,假设你想要搜索文本中的 ISO 8601 星期,并以区分大小写的方式执行搜索(例如,"Monday" 和 "MONDAY" 都会匹配)。现在可以这样表达:
private static readonly SearchValues<string> s_daysOfWeek = SearchValues.Create(
new[] { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" },
StringComparison.OrdinalIgnoreCase);
...
ReadOnlySpan<char> textToSearch = ...;
int i = textToSearch.IndexOfAny(s_daysOfWeek);
这也突显了与现有的 byte
和 char
支持的另一个有趣的不同点。对于这些类型,SearchValues
是纯粹的优化:IndexOfAny
重载已经存在了很长时间,用于在更大的集合中搜索 T
值的集合(例如,string.IndexOfAny(char[] anyOf)
早在二十多年前就被引入了),而 SearchValues
支持只是使这些用例更快(通常 much 更快)。相比之下,直到 .NET 9,内置方法都没有用于执行多字符串搜索,所以这个新支持不仅增加了这样的支持,而且是以高度高效的方式实现的。
但是,假设我们想要在没有核心库中这样的功能的情况下执行此类搜索。一种方法是简单地遍历输入,位置一个一个地比较目标值:
// 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 static readonly string s_input = new HttpClient().GetStringAsync("https://gutenberg.org/cache/epub/2600/pg2600.txt").Result;
private static readonly string[] s_daysOfWeek = new[] { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" };
[Benchmark(Baseline = true)]
public bool Contains_Iterate()
{
ReadOnlySpan<char> input = s_input;
for (int i = 0; i < input.Length; i++)
{
foreach (string dow in s_daysOfWeek)
{
if (input.Slice(i).StartsWith(dow, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
return false;
}
}
方法 | 平均值 | 比率 |
---|---|---|
Contains_Iterate | 227.526 微秒 | 1.000 |
这是经典的。函数式的。而且很慢。这是在输入的每个字符上做了大量的工作,每次循环遍历每个日期名称并进行比较。我们能做得更好吗?首先,我们可以尝试让内部循环更高效。而不是迭代字符串,我们可以硬编码自己的开关:
// 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 static readonly string s_input = new HttpClient().GetStringAsync("https://gutenberg.org/cache/epub/2600/pg2600.txt").Result;
[Benchmark]
public bool Contains_Iterate_Switch()
{
ReadOnlySpan<char> input = s_input;
for (int i = 0; i < input.Length; i++)
{
ReadOnlySpan<char> slice = input.Slice(i);
switch ((char)(input[i] | 0x20))
{
case 's' when slice.StartsWith("Sunday", StringComparison.OrdinalIgnoreCase) || slice.StartsWith("Saturday", StringComparison.OrdinalIgnoreCase):
case 'm' when slice.StartsWith("Monday", StringComparison.OrdinalIgnoreCase):
case 't' when slice.StartsWith("Tuesday", StringComparison.OrdinalIgnoreCase) || slice.StartsWith("Thursday", StringComparison.OrdinalIgnoreCase):
case 'w' when slice.StartsWith("Wednesday", StringComparison.OrdinalIgnoreCase):
case 'f' when slice.StartsWith("Friday", StringComparison.OrdinalIgnoreCase):
return true;
}
}
return false;
}
}
这种方法的主要优点是使 StartsWith
调用更加高效。因为每个调用都是针对特定的针,JIT 可以看到,所以它可以发出优化的代码来优化这个比较(关于我选择的语言,"针"通常用于描述正在搜索的东西,"针在 haystack 中"的比喻,而"haystack"用于描述正在搜索的东西)。我们还在通过使用 ASCII 大小写转换技巧来减少开关中的情况数量;大写 ASCII 字符在数值上与小写 ASCII 字符只差一个比特,所以我们只需确保设置这个比特,然后只比较小写字母。
方法 | 平均值 | 比率 |
---|---|---|
Contains_Iterate | 227.526 微秒 | 1.000 |
Contains_Iterate_Switch | 13.885 微秒 | 0.061 |
这要好得多,快了 16 倍。如果我们保持简单,只使用已经优化的 IndexOf
来搜索每个单独的字符串会怎样?
// 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 static readonly string s_input = new HttpClient().GetStringAsync("https://gutenberg.org/cache/epub/2600/pg2600.txt").Result;
private static readonly string[] s_daysOfWeek = new[] { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" };
[Benchmark]
public bool Contains_ContainsEachNeedle()
{
ReadOnlySpan<char> input = s_input;
foreach (string dow in s_daysOfWeek)
{
if (input.Contains(dow, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
方法 | 平均值 | 比率 |
---|---|---|
Contains_Iterate | 227.526 微秒 | 1.000 |
Contains_Iterate_Switch | 13.885 微秒 | 0.061 |
Contains_ContainsEachNeedle | 302.330 微秒 | 1.329 |
哎呀。虽然这种方法从向量化中受益,因为 Contains
操作本身已经向量化了,可以高效地同时检查多个位置,但是这种情况下搜索顺序的影响很大。实际上,大部分的星期几在这个输入文本(在这个例子中是《战争与和平》)中出现了,但位置却非常不同,周一根本就没有出现。以下代码:
using var hc = new HttpClient();
var s = await hc.GetStringAsync("https://gutenberg.org/cache/epub/2600/pg2600.txt");
Console.WriteLine($"Length: {s.Length}");
Console.WriteLine($"Monday: {s.IndexOf("Monday", StringComparison.OrdinalIgnoreCase)}");
Console.WriteLine($"Tuesday: {s.IndexOf("Tuesday", StringComparison.OrdinalIgnoreCase)}");
Console.WriteLine($"Wednesday: {s.IndexOf("Wednesday", StringComparison.OrdinalIgnoreCase)}");
Console.WriteLine($"Thursday: {s.IndexOf("Thursday", StringComparison.OrdinalIgnoreCase)}");
Console.WriteLine($"Friday: {s.IndexOf("Friday", StringComparison.OrdinalIgnoreCase)}");
Console.WriteLine($"Saturday: {s.IndexOf("Saturday", StringComparison.OrdinalIgnoreCase)}");
Console.WriteLine($"Sunday: {s.IndexOf("Sunday", StringComparison.OrdinalIgnoreCase)}");
会输出以下内容:
Length: 3293614
Monday: -1
Tuesday: 971396
Wednesday: 10652
Thursday: 107470
Friday: 640801
Saturday: 1529549
Sunday: 891753
这意味着 Contains_Iterate_Switch
只需要检查 10,652 个位置(第一个“Wednesday”的位置)就能找到匹配项,而 Contains_ContainsEachNeedle
需要检查 3,293,614 个位置(因为“Monday”没有匹配,所以它会查看所有内容)+ 971,396(“Tuesday”的索引)== 4,265,010 个位置才能找到匹配项。这意味着迭代方法需要检查 400 倍于内部方法的工作量。即使向量化的好处也无法弥补这个差距。
好的,那么如果我们改变方法,而是搜索每个单词的第一个字母,以便快速跳过不可能匹配的位置,会怎样?我们甚至可以使用 SearchValues<char>
来执行这个搜索。
// dotnet run -c Release -f net9.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private static readonly string s_input = new HttpClient().GetStringAsync("https://gutenberg.org/cache/epub/2600/pg2600.txt").Result;
private static readonly SearchValues<char> s_daysOfWeekFCSV = SearchValues.Create(new[] { 'S', 's', 'M', 'm', 'T', 't', 'W', 'w', 'F', 'f' });
[Benchmark]
public bool Contains_IndexOfAnyFirstChars_SearchValues()
{
ReadOnlySpan<char> input = s_input;
int i;
while ((i = input.IndexOfAny(s_daysOfWeekFCSV)) >= 0)
{
ReadOnlySpan<char> slice = input.Slice(i);
switch ((char)(input[i] | 0x20))
{
case 's' when slice.StartsWith("Sunday", StringComparison.OrdinalIgnoreCase) || slice.StartsWith("Saturday", StringComparison.OrdinalIgnoreCase):
case 'm' when slice.StartsWith("Monday", StringComparison.OrdinalIgnoreCase):
case 't' when slice.StartsWith("Tuesday", StringComparison.OrdinalIgnoreCase) || slice.StartsWith("Thursday", StringComparison.OrdinalIgnoreCase):
case 'w' when slice.StartsWith("Wednesday", StringComparison.OrdinalIgnoreCase):
case 'f' when slice.StartsWith("Friday", StringComparison.OrdinalIgnoreCase):
return true;
}
input = input.Slice(i + 1);
}
return false;
}
}
IndexOf -2
在有些情况下,这是一个非常可行的策略;实际上,这是Regex
经常采用的一种技术。但在其他情况下,这就不太合适了。潜在的问题是像's'和't'这样的字母太常见了。这里出现的字符('s'、'm'、't'、'w'和'f'),包括大小写变体,占输入文本的约17%(相比之下,仅大写字母子集只占约0.54%)。这意味着,平均而言,这个IndexOfAny
调用需要每六个字符就跳出其内部向量化处理循环一次,这降低了向量化的潜在效率提升。即便如此,这仍然是迄今为止最好的方法:
方法 | 平均值 | 比率 |
---|---|---|
Contains_Iterate | 227.526微秒 | 1.000 |
Contains_Iterate_Switch | 13.885微秒 | 0.061 |
Contains_ContainsEachNeedle | 302.330微秒 | 1.329 |
Contains_IndexOfAnyFirstChars_SearchValues | 7.151微秒 | 0.031 |
现在,让我们尝试使用SearchValues<string> : |
// dotnet run -c Release -f net9.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private static readonly string s_input = new HttpClient().GetStringAsync("https://gutenberg.org/cache/epub/2600/pg2600.txt").Result;
private static readonly SearchValues<string> s_daysOfWeekSV = SearchValues.Create(
["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
StringComparison.OrdinalIgnoreCase);
[Benchmark]
public bool Contains_StringSearchValues() =>
s_input.AsSpan().ContainsAny(s_daysOfWeekSV);
}
功能是内置的,所以我们没有编写任何自定义逻辑,只是调用了ContainsAny
方法。结果是:
方法 | 平均值 | 比率 |
---|---|---|
Contains_Iterate | 227.526微秒 | 1.000 |
Contains_Iterate_Switch | 13.885微秒 | 0.061 |
Contains_ContainsEachNeedle | 302.330微秒 | 1.329 |
Contains_IndexOfAnyFirstChars_SearchValues | 7.151微秒 | 0.031 |
Contains_StringSearchValues | 2.153微秒 | 0.009 |
不仅更简单,而且比我们之前实现的最快结果快了几倍,比最初的尝试快了约105倍。太棒了! |
这个是如何工作的?背后的算法非常令人着迷。就像byte
和char
一样,有多种具体实现可能会被采用,根据传递给Create
的精确针值来选择。最简单的实现是处理退化情况(例如零输入),在这种情况下,所有方法都可以直接返回硬编码的“未找到”结果。还有一个专门针对单个输入的实现,在这种情况下,它可以执行与IndexOf(needle)
相同的搜索,但将针值中的字符选择提取出来,以执行向量化搜索。IndexOf(string)
会选择针值中的几个字符(通常是针值的第一个和最后一个字符),为每个选择的字符创建一个向量,然后根据选择的字符之间的距离设置适当的偏移量,遍历输入文本,比较这些向量,并在特定位置匹配时进行完整字符串比较。SearchValues<string>
执行类似操作(在内部实现中称为SingleStringSearchValuesThreeChars
),但使用三个而不是两个字符,并使用频率分析来选择这些字符,而不是简单地选择第一个和最后一个字符,尝试使用在一般输入中不太可能出现的字符(例如,对于字符串“amazing”,它可能选择'm'、'z'和'g',因为它们被认为在平均输入中比'a'、'i'或'n'不太可能)。这可能会花费更多的时间,因为它可以在第一次计算后缓存结果,以便后续搜索使用。我们稍后会再提到这一点。
除了这些特殊情况,事情变得非常有趣。在过去50年中,人们进行了大量研究,寻找执行多字符串搜索的最有效方法。一种流行的算法是Rabin-Karp,由理查德·卡普(Richard Karp)和迈克尔·拉宾(Michael Rabin)在1980年代创建,它通过“滚动哈希”工作。想象一下创建一个长度为N的针值的第一个N个字符的哈希值(N是搜索的针值长度),将这个哈希值与针值的哈希值进行比较;如果它们匹配,就在该位置进行实际的全字符串比较,否则继续。然后更新哈希值,移除第一个字符并添加下一个字符,重复检查。然后重复,重复,重复。每次向前移动时,你只需通过固定次数的操作更新哈希值,这意味着整个哈希函数的更新操作都是O(输入文本)
。最佳情况是,你只找到可能匹配的位置,算法的复杂性是O(输入文本 + 针值)
。最坏情况(但通常不太可能)是每个位置都是可能的匹配位置,算法的复杂性是O(输入文本 * 针值)
。一个简单的实现可能如下(为了教学目的,这个实现使用了一个非常糟糕的哈希函数,只是计算字符数值的总和;实际算法推荐使用更好的哈希函数):
private static bool RabinKarpContains(ReadOnlySpan<char> haystack, ReadOnlySpan<char> needle)
{
if (haystack.Length >= needle.Length)
{
// 计算针值和输入文本前needle.Length个字符的哈希值。
// 为了教学目的,这个哈希函数非常简单:只是计算字符的总和。
int i, rollingHash = 0, needleHash = 0;
for (i = 0; i < needle.Length; i++)
{
rollingHash += haystack[i];
needleHash += needle[i];
}
while (true)
{
// 如果哈希值匹配,则在那个位置比较字符串。
if (needleHash == rollingHash && haystack.Slice(i - needle.Length).StartsWith(needle))
{
return true;
}
// 如果已经到达输入文本的末尾,就退出。
if (i == haystack.Length)
{
break;
}
// 更新滚动哈希。
rollingHash += haystack[i] - haystack[i - needle.Length];
i++;
}
}
return needle.IsEmpty;
}
这支持一个针值,但要扩展以支持多个针值,可以通过多种方式实现,例如按针值的哈希码对针值进行分类(类似于哈希表的做法),当有命中时,检查相应桶中的所有针值,或者进一步减少需要检查的内容,使用布隆过滤器或类似技术。SearchValues<string>
将使用Rabin-Karp算法,但仅限于非常短的输入,因为对于较长的输入,有更有效的算法。
另一种流行的算法是Aho-Corasick,由阿尔弗雷德·阿霍(Alfred Aho)和玛格丽特·科拉斯尼克(Margaret Corasick)在1970年代设计。它的主要目的是多字符串搜索,使匹配操作可以在输入长度的线性时间内完成,假设针值集是固定的。它通过构建一种形式的字典树(trie),在有限自动机中进行转换,从根节点开始,根据与该子节点关联的字符进行转换。但是,它扩展了典型的字典树,在节点之间添加了额外的边,作为回退使用。例如,这里是以之前讨论过的星期几为示例的Aho-Corasick自动机: 给定的输入文本“wednesunday”,它将从根节点开始,经过“w”、“we”、“wed”、“wedn”、“wedne”和“wednes”节点,但在遇到下一个字符‘u’时无法继续,然后它会使用到“s”节点的回退链接,从“s”、“su”等节点继续,直到它到达叶子节点“sunday”,并可以宣布成功。Aho-Corasick有效地支持更长的字符串,并且是SearchValues<string>
使用的通用回退实现。然而,在许多情况下,它还可以做得更好……
SearchValues<string>
中真正的主力实现,尽可能选择的是“Teddy”算法的向量化版本。这个算法最初源于英特尔Hyperscan库,后来被Rust的aho_corasick crate采纳,现在作为.NET 9
中SearchValues<string>
的一部分使用。它非常酷,非常高效。
之前,我简要概述了SingleStringSearchValuesThreeChars
和IndexOfAnyAsciiSearcher
的实现方式。SingleStringSearchValuesThreeChars
优化了查找可能开始子字符串的位置,通过检查多个包含的字符减少误报,然后对可能的位置进行完整字符串比较进行验证。IndexOfAnyAsciiSearcher
优化了在大型字符集中查找任意字符的下一个位置。你可以将Teddy视为这两个实现的结合。在源代码中有对算法的很好描述(在源代码中),所以这里就不详细介绍了。总的来说,它保持了一个与IndexOfAnyAsciiSearcher
类似的位图,但每个ASCII字符不是只有一个位,而是每个四分位(nibble)有一个8位位图,并且不是只有一个位图,而是有两个或三个,每个位图对应于子字符串中的某个位置(例如,一个位图用于0位置,另一个用于1位置)。位图中的8位用于指示在对应位置上哪些针值包含该四分位。如果搜索的针值数量不超过8个,那么每个位单独标识一个针值;如果超过8个,就像Rabin-Karp算法一样,我们可以创建针值子字符串的桶,位图中的位引用一个桶。如果位图比较表明可能匹配,就进行相关的针值(或针值集)的完整匹配。就像IndexOfAnyAsciiSearcher
一样,所有这些操作都利用SIMD指令,每次处理16到64个字符的输入文本块,从而获得显著的速度提升。
对于大量字符串的搜索,SearchValues<string>
非常棒,但对于仅几个字符串的情况也很相关。例如,考虑MSBuild中的一部分代码,用于解析构建输出以查找警告和错误:
if (message.IndexOf("warning", StringComparison.OrdinalIgnoreCase) == -1 &&
message.IndexOf("error", StringComparison.OrdinalIgnoreCase) == -1)
{
return null;
}
而不是进行两次单独搜索,我们可以进行一次搜索:
// dotnet run -c Release -f net9.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private static readonly string s_input = new HttpClient().GetStringAsync("https://gutenberg.org/cache/epub/2600/pg2600.txt").Result;
private static readonly SearchValues<string> s_warningError = SearchValues.Create(["warning", "error"], StringComparison.OrdinalIgnoreCase);
[Benchmark(Baseline = true)]
public bool TwoContains() =>
s_input.Contains("warning", StringComparison.OrdinalIgnoreCase) ||
s_input.Contains("error", StringComparison.OrdinalIgnoreCase);
[Benchmark]
public bool ContainsAny() =>
s_input.AsSpan().ContainsAny(s_warningError);
}
这是在搜索“战争与和平”中的“warning”或“error”,尽管两者都出现在文本中,因此原始代码中的第二次“error”搜索永远不会发生,但SearchValues<string>
搜索速度更快,因为“error”比“warning”出现得更早。
除了SearchValues<string>
,现有的SearchValues<byte>
和SearchValues<char>
在.NET 9中也得到了各种增强。dotnet/runtime#96588就是一个例子,它使得一些常见的SearchValues<char>
搜索更快,特别是当搜索2或4个字符且这些字符代表1或2个ASCII大小写不敏感字符时,例如['A', 'a']
或['A', 'a', 'B', 'b']
。在.NET 8中,例如,SearchValues.Create
将选择一个实现,为'A'
和'a'
各自创建一个向量,然后在搜索的内部循环中比较每个向量与输入的文本哈希。这个PR教会了它做我们之前讨论过的ASCII技巧:而不是创建两个分离的向量,它可以创建一个只包含'a'
的向量,然后只对输入向量执行一个OR
操作加0x20
,这样任何'A'
都会变成'a'
。OR加一个比较比两个比较加比较结果的OR更便宜。有趣的是,这甚至不一定要关于大小写:因为我们只做0x20
的OR操作,所以这适用于任何两个只相差一个位的字符。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private static readonly string s_input = new HttpClient().GetStringAsync("https://www.gutenberg.org/cache/epub/100/pg100.txt").Result;
private static readonly SearchValues<char> s_symbols = SearchValues.Create("@`");
[Benchmark]
public bool ContainsAny() => s_input.AsSpan().ContainsAny(s_symbols);
}
方法 | 运行时 | 平均值 | 比率 |
---|---|---|---|
ContainsAny | .NET 8.0 | 262.7微秒 | 1.02 |
ContainsAny | .NET 9.0 | 232.3微秒 | 0.90 |
同样的技巧也适用于四个字符:而不是执行四个向量比较和三个OR操作来合并结果,我们可以对输入执行一个OR操作来混合0x20
,然后执行两个向量比较和一个OR操作来合并结果。实际上,四个向量的方法在之前描述的IndexOfAnyAsciiSearcher
实现上已经更昂贵了,因为IndexOfAnyAsciiSearcher
支持任何数量的ASCII字符,所以当适用时,SearchValues.Create
会优先选择这个。但在.NET 9中,有了这个优化,SearchValues.Create
会优先选择这个专门的比较路径。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private static readonly string s_input = new HttpClient().GetStringAsync("https://www.gutenberg.org/cache/epub/100/pg100.txt").Result;
private static readonly SearchValues<char> s_symbols = SearchValues.Create("@`^~");
[Benchmark]
public bool ContainsAny() => s_input.AsSpan().ContainsAny(s_symbols);
}
方法 | 运行时 | 平均值 | 比率 |
---|---|---|---|
ContainsAny | .NET 8.0 | 247.5微秒 | 1.01 |
ContainsAny | .NET 9.0 | 196.2微秒 | 0.80 |
IndexOf -3
在 .NET 9 中,其他 SearchValues
实现也得到了改进,特别是“ProbabilisticMap”实现。这些实现被 SearchValues<char>
在更快的向量化实现不适用但针(needle)中的字符数量不过分(当前限制是 256)时作为备用选择。它通过一种布隆过滤器(Bloom filter)的形式工作。实际上,它维护一个 256 位的位图,针中的字符映射到一个或两个位,具体取决于 char
。如果一个给定 char
的位不是 1
,那么这个 char
肯定不在集合中。如果所有给定 char
的位都是 1
,那么这个 char
可能在集合中,需要执行更昂贵的检查来确定是否包含。这些位是否被设置是一个可向量化的操作,因此只要假阳性相对较少(这就是为什么有字符数量限制的原因;表示的字符越多,假阳性的可能性就越大),它就成为一种高效的搜索方法。然而,这种向量化仅适用于正例(例如 IndexOfAny
/ ContainsAny
),而不适用于负例(例如 IndexOfAnyExcept
/ ContainsAnyExcept
);对于这些“Except”方法,实现仍然逐个字符遍历,每个字符的检查是 O(Needle)
。感谢 dotnet/runtime#101001,它用一个“完美哈希”替换线性搜索,将 O(Needle)
降低到 O(1)
,使这些“Except”调用变得更加高效。
.NET 8.0 和 .NET 9.0 的基准测试
下面是两个基准测试的对比,展示了 .NET 8.0 和 .NET 9.0 在执行相关操作时的性能差异。
基准测试 1:CountNonGreekOrAsciiDigitsChars
Method | Runtime | Mean | Ratio |
| --- | --- | --- | --- |
| CountNonGreekOrAsciiDigitsChars | .NET 8.0 | 1,814.7 us | 1.00 |
| CountNonGreekOrAsciiDigitsChars | .NET 9.0 | 881.7 us | 0.49
这个基准测试展示了在 .NET 9 中,CountNonGreekOrAsciiDigitsChars
方法相比 .NET 8.0 有近一半的性能提升。
基准测试 2:CountGreekChars
Method | Runtime | Mean | Ratio |
| --- | --- | --- | --- |
| CountGreekChars | .NET 8.0 | 126.454 ms | 1.00 |
| CountGreekChars | .NET 9.0 | 8.956 ms | 0.07
这个基准测试展示了在处理包含大量希腊字符的情况时,.NET 9 的 CountGreekChars
方法相比 .NET 8.0 有显著的性能提升。
基准测试 3:Count
Method | Runtime | Mean | Ratio |
| --- | --- | --- | --- |
| Count | .NET 8.0 | 28.35 us | 1.00 |
| Count | .NET 9.0 | 13.19 us | 0.47
这个基准测试展示了在处理包含特殊符号的情况时,.NET 9 的 Count
方法相比 .NET 8.0 有近一半的性能提升。
基准测试 4:HasAnyAccented_IndexOfAny 和 HasAnyAccented_LastIndexOfAny
Method | Runtime | Mean | Ratio |
| --- | --- | --- | --- |
| HasAnyAccented_IndexOfAny | .NET 8.0 | 7.910 ms | 1.00 |
| HasAnyAccented_IndexOfAny | .NET 9.0 | 4.476 ms | 0.57 |
| HasAnyAccented_LastIndexOfAny | .NET 8.0 | 17.491 ms | 1.00 |
| HasAnyAccented_LastIndexOfAny | .NET 9.0 | 5.253 ms | 0.30
这个基准测试展示了在处理包含重音字符的情况时,.NET 9 的 HasAnyAccented_IndexOfAny
和 HasAnyAccented_LastIndexOfAny
方法相比 .NET 8.0 有显著的性能提升。
基准测试 5:ContainsAny
Method | Runtime | Mean | Ratio |
| --- | --- | --- | --- |
| ContainsAny | .NET 8.0 | 3.640 ns | 1.00 |
| ContainsAny | .NET 9.0 | 2.382 ns | 0.65
这个基准测试展示了在处理包含元音字符的情况时,.NET 9 的 ContainsAny
方法相比 .NET 8.0 有显著的性能提升。
分析器改进
除了新的 API 和 API 实现,还进行了帮助开发者更好地使用 SearchValues
的改进。例如,dotnet/roslyn-analyzers#6898 和 dotnet/roslyn-analyzers#7252 添加了一个新的分析器 (CA1870),可以找到使用 SearchValues
的机会,并自动修复调用站点以使用 SearchValues
。
IndexOf
和 Contains
的改进
在 .NET 9 中,除了 SearchValues
,IndexOf
和 Contains
也有一些改进。例如,dotnet/runtime#97632 添加了一个简单的 if
块到 string.Contains(string)
方法中,这有助于在某些情况下减少开销。
正则表达式
在过去的几年中,.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 |
Span,Span,还有更多的Span
Span<T>
和ReadOnlySpan<T>
在.NET Core 2.1的引入,彻底改变了我们编写.NET代码的方式(尤其是在核心库中),以及我们暴露的API(如果你对深入了解感兴趣,请查看A Complete .NET Developer’s Guide to Span)。.NET 9继续强调使用Span作为提升性能的一种绝佳方式,同时也暴露了让开发者在其代码中实现更多性能优化的API。
一个很好的例子是C# 13对“参数集合”(“params collections”)的支持,该功能已合并到C#编译器的主分支中,参见dotnet/roslyn#72511。这个特性使得C#的params
关键字不仅可以用于数组参数,还可以用于任何可以使用集合表达式的集合类型……这包括了Span。实际上,这个特性使得如果有两个重载,一个接受params T[]
,另一个接受params ReadOnlySpan<T>
,则后者将在重载解析中获胜。此外,为params ReadOnlySpan<T>
调用站点生成的代码与集合表达式获得的非分配方式相同,例如以下代码:
using System;
public class C
{
public void M()
{
Helpers.DoAwesomeStuff("Hello", "World");
}
}
public static class Helpers
{
public static void DoAwesomeStuff<T>(params T[] values) { }
public static void DoAwesomeStuff<T>(params ReadOnlySpan<T> values) { }
}
C#编译器为C.M
生成的IL将与以下C#代码等效:
<>y__InlineArray2<string> buffer = default;
<PrivateImplementationDetails>.InlineArrayElementRef<<>y__InlineArray2<string>, string>(ref buffer, 0) = "Hello";
<PrivateImplementationDetails>.InlineArrayElementRef<<>y__InlineArray2<string>, string>(ref buffer, 1) = "World";
Helpers.DoAwesomeStuff(<PrivateImplementationDetails>.InlineArrayAsReadOnlySpan<<>y__InlineArray2<string>, string>(ref buffer, 2));
这使用了.NET 8中引入的[InlineArray]
特性,堆栈分配一个字符串的span,然后将其传递给方法。没有堆分配。这对库开发者来说是个巨大的好处,因为它意味着在任何接受params T[]
的方法中,都可以添加一个params ReadOnlySpan<T>
重载,并且当调用代码重新编译时,它将变得更好。dotnet/runtime#101308和dotnet/runtime#101499依赖这一点,为之前不接受span的方法添加了大约40个新重载,并将params
添加到超过20个已经接受span的重载中。例如,如果代码使用Path.Join
来构建由五个或更多段组成的路径,之前会使用params string[]
重载,但现在在重新编译后,将切换到使用params ReadOnlySpan<string>
重载,并且不需要为输入分配string[]
。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers;
using System.Numerics;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
[Benchmark]
public string Join() => Path.Join("a", "b", "c", "d", "e");
}
方法 | 运行时 | 平均值 | 比率 | 分配 | 分配比率 |
---|---|---|---|---|---|
Join | .NET 8.0 | 30.83纳秒 | 1.00 | 104字节 | 1.00 |
Join | .NET 9.0 | 24.85纳秒 | 0.81 | 40字节 | 0.38 |
C#编译器在其他方面也对Span进行了改进。例如,dotnet/roslyn#71261扩展了支持在初始化数组、ReadOnlySpan<T>
和stackalloc
时使用程序集数据。如果你有如下代码:
var array = new char[] { 'a', 'b', 'c', 'd', 'e', 'f', 'g' };
编译器会生成类似于以下代码的代码:
char[] array = new char[7];
RuntimeHelpers.InitializeArray(array, (RuntimeFieldHandle)&<PrivateImplementationDetails>.FD43C34A357FF620C00C04D0247059F8628CBB3DB349DF05DFA15EF6C7AC514C2);
编译器将取该char数据并将其复制到程序集中;然后当创建数组时,而不是逐个设置数组的每个值,它只是直接从程序集复制该数据到数组。类似地,如果你有:
ReadOnlySpan<char> span = new char[] { 'a', 'b', 'c', 'd', 'e', 'f', 'g' };
编译器会识别出所有数据都是常量,并且被存储到一个“只读”的位置,因此不需要实际分配一个数组。相反,它会生成类似于以下代码的代码:
ReadOnlySpan<char> span =
RuntimeHelpers.CreateSpan<char>((RuntimeFieldHandle)&<PrivateImplementationDetails>.FD43C34A357FF620C00C04D0247059F8628CBB3DB349DF05DFA15EF6C7AC514C2);
这实际上创建了一个指向程序集数据的span;无需分配,也无需复制。但是,如果你有:
ReadOnlySpan<char> span = stackalloc char[] { 'a', 'b', 'c', 'd', 'e', 'f', 'g' };
或者:
Span<char> span = stackalloc char[] { 'a', 'b', 'c', 'd', 'e', 'f', 'g' };
你会得到更类似于以下代码的代码生成:
char* ptr = stackalloc char[7];
*(char*)ptr = 97;
*(char*)(ptr + 1) = 98;
*(char*)(ptr + 2) = 99;
*(char*)(ptr + 3) = 100;
*(char*)(ptr + 4) = 101;
*(char*)(ptr + 5) = 102;
*(char*)(ptr + 6) = 103;
Span<char> span = new Span<char>(ptr, 7);
但现在,得益于dotnet/roslyn#71261,最后一个例子也将采用与其他构造相同的方法,生成更类似于以下代码的代码:
char* ptr = stackalloc char[7];
Unsafe.CopyBlockUnaligned(ptr, &<PrivateImplementationDetails>.FD43C34A357FF620C00C04D0247059F8628CBB3DB349DF05DFA15EF6C7AC514C2, 14);
Span<char> span = new Span<char>(ptr, 7);
(编译器实际上会生成一个cpblk
IL指令,而不是调用Unsafe.CopyBlockUnaligned
)。
C#编译器还提高了在从某些表达式的数组构造或集合表达式中创建ReadOnlySpan<T>
时避免分配的能力。C#编译器多年前添加的一个非常不错的优化是,能够识别当一个新的byte
/sbyte
/bool
数组被构造、填充了仅有的常量,并直接分配给一个ReadOnlySpan<T>
时的情况。在这种情况下,它会识别数据都是可传输的且无法被修改,因此而不是分配一个数组并围绕它包装一个span,它会将数据直接复制到程序集中,然后仅构造一个指向程序集数据的指针的span,并具有适当的长短。所以这个:
ReadOnlySpan<byte> Values => new[] { (byte)0, (byte)1, (byte)2 };
被降低到更类似于以下代码:
ReadOnlySpan<byte> Values => new ReadOnlySpan<byte>(
&<PrivateImplementationDetails>.AE4B3280E56E2FAF83F414A6E3DABE9D5FBE18976544C05FED121ACCB85B53FC),
3);
当时,这个优化仅限于单字节原始类型,因为考虑到字节序的问题,但.NET 7添加了一个RuntimeHelpers.CreateSpan
方法,它处理了这样的字节序问题,因此这种优化被扩展到所有这样的原始类型,无论大小。所以这个:
ReadOnlySpan<char> Values1 => new[] { 'a', 'b', 'c' };
ReadOnlySpan<int> Values2 => new[] { 1, 2, 3 };
ReadOnlySpan<long> Values3 => new[] { 1L, 2, 3 };
ReadOnlySpan<DayOfWeek> Values4 => new[] { DayOfWeek.Monday, DayOfWeek.Friday };
被降低到更类似于以下代码:
ReadOnlySpan<char> Values1 => new ReadOnlySpan<char>(
&<PrivateImplementationDetails>.13E228567E8249FCE53337F25D7970DE3BD68AB2653424C7B8F9FD05E33CAEDF2),
3);
ReadOnlySpan<int> Values2 => new ReadOnlySpan<int>(
&<PrivateImplementationDetails>.4636993D3E1DA4E9D6B8F87B79E8F7C6D018580D52661950EABC3845C5897A4D4),
3);
ReadOnlySpan<long> Values3 => new ReadOnlySpan<long>(
&<PrivateImplementationDetails>.E2E2033AE7E19D680599D4EB0A1359A2B48EC5BAAC75066C317FBF85159C54EF8),
3);
ReadOnlySpan<DayOfWeek> Values3 => new ReadOnlySpan<DayOfWeek>(
&<PrivateImplementationDetails>.ECA75F8497701D6223817CDE38BF42CDD1124E01EF6B705BCFE9A584F7B42F0F4),
2);
很好。但是……对于在C#级别支持但无法以这种形式传输的类型怎么办?这包括nint
和nuint
(它们根据进程的位数而变化的大小),decimal
(实际上在元数据中以[DecimalConstant(...)]
属性表示),和string
(这是一个引用类型)。在这种情况下,即使我们目标的是可以修改的,并且我们使用的是常量,仍然会进行数组分配:
ReadOnlySpan<nint> Values1 => new nint[] { 1, 2, 3 };
ReadOnlySpan<nuint> Values2 => new nuint[] { 1, 2, 3 };
ReadOnlySpan<decimal> Values3 => new[] { 1m, 2m, 3m };
ReadOnlySpan<string> Values4 => new[] { "a", "b", "c" };
它们被降低为,好吧,就是它们自己,因此仍然会有分配。或者,至少之前是这样的。得益于dotnet/roslyn#69820,这些情况现在也得到了处理。它们通过懒惰地分配一个数组,然后为所有后续使用缓存它来处理。因此,现在,相同的例子被降低到类似于以下代码的等效形式:
ReadOnlySpan<nint> Values1 =>
<PrivateImplementationDetails>.4636993D3E1DA4E9D6B8F87B79E8F7C6D018580D52661950EABC3845C5897A4D_B8 ??=
new nint[] { 1, 2, 3 };
ReadOnlySpan<nuint> Values2 =>
<PrivateImplementationDetails>.4636993D3E1DA4E9D6B8F87B79E8F7C6D018580D52661950EABC3845C5897A4D_B16 ??=
new nuint[] { 1, 2, 3 };
ReadOnlySpan<decimal> Values3 =>
<PrivateImplementationDetails>.04B64E80BCEFE521678C4D6565B6EEBCE2791130A600CCB5D23E1B5538155110_B18 ??=
new[] { 1m, 2m, 3m };
ReadOnlySpan<string> Values4 =>
<PrivateImplementationDetails>.13E228567E8249FCE53337F25D7970DE3BD68AB2653424C7B8F9FD05E33CAEDF_B11 ??=
new[] { "a", "b", "c" };
当然,库中还有许多与span相关的其他改进。一个现有span相关方法的改进是dotnet/runtime#103728,它进一步优化了MemoryExtensions.Count
用于在span中计数元素的出现次数。该实现是向量化的,一次处理一个向量值得数据,例如如果256位的向量被硬件加速,并且正在搜索char
s,它将一次处理16个char
s(16 char
s * 2字节每个char
* 8位每个字节 == 256)。如果元素的数量不是16的偶数倍会发生什么?那么在处理最后一个完整向量后,我们会剩下一些剩余元素。之前,实现会逐个处理这些剩余元素;现在,它会在输入的末尾处理最后一个向量。这样做意味着我们最终会重新检查一个或多个我们已经检查过的元素,但这并不重要,因为我们可以用处理单个元素大约相同的指令数量检查所有元素。
// dotnet run -c Release -f net8.0 --filter "*" --runtimes net8.0 net9.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private char[][] _values = new char[10_000][];
[GlobalSetup]
public void Setup()
{
var rng = new Random(42);
for (int i = 0; i < _values.Length; i++)
{
_values[i] = new char[rng.Next(0, 128)];
rng.NextBytes(MemoryMarshal.AsBytes(_values[i].AsSpan()));
}
}
[Benchmark]
public int Count()
{
int count = 0;
foreach (char[] numbers in _values)
{
count += numbers.AsSpan().Count('a');
}
return count;
}
}
方法 | 运行时 | 平均值 | 比率 |
---|---|---|---|
Count | .NET 8.0 | 133.25微秒 | 1.00 |
Count | .NET 9.0 | 74.30微秒 | 0.56 |
.NET 9中出现了与span相关的新的功能。字符串分割是一个到处都在使用的操作;在GitHub上的C#代码中搜索“.Split(”会返回数百万个结果,来自各种来源的数据表明,仅最基本的重载Split(params char[]? separator)
就占到了应用程序的90%以上,以及NuGet包的20%。所以,对于span添加分割功能的需求非常受欢迎。
当然,细节是魔鬼,这花了很长时间来弄清楚应该如何公开。我们看到的野外使用场景大致有两种。一种情况是,被分割的内容有一个期望的或最大的段数,分割用于提取它们。例如,FileVersionInfo
需要能够接受版本字符串,并解析出最多由四个部分组成,由点号分隔的组件。.NET 8引入了新的Split
扩展方法在MemoryExtensions
上,以解决这种用例,通过让Split
接受一个目标Span<Range>
来写入每个段的范围。然而,这仍然留下了第二大使用类别,即迭代一个未定义数量的段。一个代表性的例子是HttpListener
的WebSockets实现中的一段代码:
string[] requestProtocols = clientSecWebSocketProtocol.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < requestProtocols.Length; i++)
{
if (string.Equals(acceptProtocol, requestProtocols[i], StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
Span、Span,还有更多的 Span -2
clientSecWebSocketProtocol
字符串由逗号分隔的值组成,这里正在遍历它们以查看是否有任何值等于目标 acceptProtocol
。然而,它使用了一种相对昂贵的操作来完成这个任务。那个 Split
调用需要分配返回的 string[]
,该数组持有所有组成部分的字符串,并且每个片段都会导致分配一个新的 string
。我们可以做得更好,@bbartels 在 dotnet/runtime#104534 中实现了这一点。它为 MemoryExtensions.Split
和 MemoryExtensions.SplitAny
添加了四个新的重载:
public static SpanSplitEnumerator<T> Split<T>(this ReadOnlySpan<T> source, T separator) where T : IEquatable<T>;
public static SpanSplitEnumerator<T> Split<T>(this ReadOnlySpan<T> source, ReadOnlySpan<T> separator) where T : IEquatable<T>;
public static SpanSplitEnumerator<T> SplitAny<T>(this ReadOnlySpan<T> source, params ReadOnlySpan<T> separators) where T : IEquatable<T>;
public static SpanSplitEnumerator<T> SplitAny<T>(this ReadOnlySpan<T> source, SearchValues<T> separators) where T : IEquatable<T>;
有了这些,相同的操作可以写成:
foreach (Range r in clientSecWebSocketProtocol.AsSpan().Split(','))
{
if (clientSecWebSocketProtocol.AsSpan(r).Trim().Equals(acceptProtocol, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
这样,它就不再需要分配 string[]
来持有结果,也不需要为每个片段分配一个新的 string
:相反,它返回一个 ref struct
枚举器,该枚举器生成每个片段的 Range
。调用者可以使用这个 Range
来切割输入。它返回一个 Range
而不是 ReadOnlySpan<T>
,是为了使拆分可以用于原始来源以外的 Span,并且能够以原始形式获取片段。例如,如果我有一个 ReadOnlyMemory<T>
,并想将其片段添加到一个列表中,我可以这样做:
ReadOnlyMemory<T> source = ...;
List<ReadOnlyMemory<T>> list = ...;
foreach (Range r in source.Split(separator))
{
list.Add(source.Slice(r));
}
而如果 Split
强制所有生成的结果都是 Span,那么这是不可能的。
你可能注意到这些重载中没有 StringSplitOptions
。这是因为它既不适用也不是必要的。它不适用,因为我们在这里使用的是 T
,这可能是除 char
之外的其他类型,而 StringSplitOptions.TrimEntries
暗示了空格的概念,这仅与文本相关。而且它也不是必要的,因为 StringSplitOptions
的主要好处,无论是 TrimEntries
还是 RemoveEmptyEntries
,都是减少分配开销。如果这些选项不与 string
重载一起存在,并且你想用原始示例(以及 Span 不存在的情况)模拟它们,它最终会看起来像这样:
string[] requestProtocols = clientSecWebSocketProtocol.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
for (int i = 0; i < requestProtocols.Length; i++)
{
if (string.Equals(acceptProtocol, requestProtocols[i].Trim(), StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
这里存在几个可能的性能问题。想象一下 clientSecWebSocketProtocol
输入是 "a , b, , , , , , c"
。这里我们只关心三个条目("a"
、"b"
和 "c"
),但返回的数组将是 string[8]
而不是 string[3]
,因为它将为每个仅包含空格的片段保留空间。这比必要的分配要大。然后,我们还将为所有八个片段生成 string
s,尽管只需要三个 string
s。此外,"a "
," b"
和 " c"
中的一些额外空格需要修剪,这样每个 Trim()
调用都会为每个片段分配一个新的字符串。StringSplitOptions
允许 Split
的实现避免所有这些开销,只分配所需的内容。但是,在 Span 版本中,根本不存在这样的分配。消费循环可以自行修剪 Span,而不需要承担额外的开销,并且消费循环可以选择忽略空条目,而不会增加 string[]
分配的大小。
最终的结果是,这种操作可以显著提高效率,同时不会在可维护性上牺牲太多。
// 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 string _input = "a , b, , , , , , c";
private string _target = "d";
[Benchmark(Baseline = true)]
public bool ContainsString()
{
foreach (string item in _input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (item.Equals(_target, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
[Benchmark]
public bool ContainsSpan()
{
foreach (Range r in _input.AsSpan().Split(','))
{
if (_input.AsSpan(r).Trim().Equals(_target, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
}
方法 | 平均值 | 比率 | 分配大小 | 分配比率 |
---|---|---|---|---|
ContainsString | 127.26 纳秒 | 1.00 | 208 字节 | 1.00 |
ContainsSpan | 61.89 纳秒 | 0.49 | - | 0.00 |
这些新的拆分 API 的特性是只找到下一个分隔符/片段;这既实用也可能是性能提升。它实用,因为我们每次只生成一个片段,而且我们不需要存储所有可能的分隔符位置(我们也不想为此分配空间)。它也是期望的,因为消费者可能会在消费循环中提前退出,在这种情况下,我们不想无谓地搜索将被忽略的额外片段。现有的拆分 API 一次返回所有找到的片段,无论是通过返回的 string[]
还是将范围写入目标 Span。因此,对于这些重载来说,一次找到所有分隔符更有意义,因为这种操作可以被向量化。实际上,以前的版本就是这样做的。但是,这种向量化只从 128 位向量中受益。随着 dotnet/runtime#93043 从 @khushal1996 在 .NET 9 中引入,现在它可以使用 512 位或 256 位向量(如果可用)来运行得更快,这使分隔符搜索在拆分过程中快了四倍。
Spans 也出现在其他新方法中。dotnet/runtime#93938 从 @TheMaximum 添加了新的重载 StringBuilder.Replace
,该重载接受 ReadOnlySpan<char>
而不是 string
。与大多数此类重载一样,它们共享相同的实现,而基于 string
的重载只是创建 string
的 Span 并使用基于 Span 的实现。在实践中,StringBuilder.Replace
的绝大多数使用情况是将常量字符串作为参数,例如用于转义某些已知分隔符(Replace (“$”, “\\$”)
),或者使用先前创建的 string
实例,例如从文本中删除某些子字符串(Replace(substring,“”)
)。但是,也存在少数情况,Replace
是用于即时创建的某些内容,这些新重载可以帮助避免为创建参数进行分配。例如,以下是 MSBuild 今天使用的某些转义代码:
char[] charsToEscape = ...;
StringBuilder escapedString = ...;
foreach (char unescapedChar in charsToEscape)
{
string escapedCharacterCode = string.Format(CultureInfo.InvariantCulture, "%{0:x00}", (int)unescapedChar);
escapedString.Replace(unescapedChar.ToString(CultureInfo.InvariantCulture), escapedCharacterCode);
}
这段代码需要进行两次 string
分配来创建传递给 Replace
的输入,这将为每个 char
在 charsToEscape
中调用一次。如果 charsToEscape
是固定的,可以避免每个迭代中的格式化操作,而是为所有使用缓存必要的字符串,例如:
private static readonly char[] charsToEscape = ...;
private static readonly string[] escapedCharsToEscape = charsToEscape.Select(c => $"%{(uint)unescapedChar:x00}").ToArray();
private static readonly string[] stringsToEscape = charsToEscape.Select(c => c.ToString()).ToArray();
...
for (int i = 0; i < charsToEscape.Length; i++)
{
escapedString.Replace(stringsToEscape[i], escapedCharsToEscape[i]);
}
但是,如果 charsToEscape
不可预测,则可以至少通过使用新重载来避免分配,例如:
char[] charsToEscape = ...;
StringBuilder escapedString = ...;
Span<char> escapedSpan = stackalloc char[5];
foreach (char unescapedChar in charsToEscape)
{
escapedSpan.TryWrite($"%{(uint)unescapedChar:x00}", out int charsWritten);
escapedString.Replace(new ReadOnlySpan<char>(in unescapedChar), escapedSpan.Slice(0, charsWritten));
}
这样,就不再需要为参数进行分配了。
在 string
操作方面还进行了各种其他改进,主要是更好地利用向量化。StringComparison.OrdinalIgnoreCase
操作以前被向量化,但只支持 128 位向量,这意味着一次可以处理最多 8 个 char
。多亏了 dotnet/runtime#93116,这些代码路径现在已经更新,支持 256 位和 512 位向量,这意味着在支持它们的硬件上,一次可以处理最多 16 或 32 个 char
。
// 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 static string s_s1 = """
Let me not to the marriage of true minds
Admit impediments; love is not love
Which alters when it alteration finds,
Or bends with the remover to remove.
O no, it is an ever-fixed mark
That looks on tempests and is never shaken;
It is the star to every wand'ring bark
Whose worth's unknown, although his height be taken.
Love's not time's fool, though rosy lips and cheeks
Within his bending sickle's compass come.
Love alters not with his brief hours and weeks,
But bears it out even to the edge of doom:
If this be error and upon me proved,
I never writ, nor no man ever loved.
""";
private static string s_s2 = s_s1[0..^1] + "!";
[Benchmark]
public bool EqualsIgnoreCase() => s_s1.Equals(s_s2, StringComparison.OrdinalIgnoreCase);
}
方法 | 运行时 | 比较结果 | 平均值 | 比率 |
---|---|---|---|---|
EqualsIgnoreCase | .NET 8.0 | 不相等 | 86.79 纳秒 | 1.00 |
EqualsIgnoreCase | .NET 9.0 | 不相等 | 20.97 纳秒 | 0.24 |
EndsWith
也得到了改进,无论是字符串还是 Span。以前,StartsWith
成为 JIT 内嵌指令,使得 JIT 可以在传递常量时为 StartsWith
生成专门的 SIMD 代码。现在,随着 dotnet/runtime#98593 的引入,同样的做法也应用到了 EndsWith
。
// 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
{
[Benchmark]
[Arguments("helloworld.txt")]
public bool EndsWith(string path) => path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase);
}
方法 | 运行时 | 路径 | 平均值 | 比率 | 代码大小 |
---|---|---|---|---|---|
EndsWith | .NET 8.0 | helloworld.txt | 3.5006 纳秒 | 1.00 | 26 字节 |
EndsWith | .NET 9.0 | helloworld.txt | 0.6653 纳秒 | 0.19 | 61 字节 |
比我这些美好的收益更有趣的是实现这些收益的代码。这是这个基准在 .NET 8 中生成的汇编代码:
; Tests.EndsWith(System.String)
mov rdi,rsi
mov rsi,7EE3C2D25E38
mov edx,5
cmp [rdi],edi
jmp qword ptr [7F24678663A0]; System.String.EndsWith(System.String, System.StringComparison)
; Total bytes of code 26
相当简单,一些参数操作然后跳转到实际的 string.EndsWith
实现。
现在看看 .NET 9:
; Tests.EndsWith(System.String)
push rbp
mov rbp,rsp
mov eax,[rsi+8]
cmp eax,4
jge short M00_L00
xor ecx,ecx
jmp short M00_L01
M00_L00:
mov ecx,eax
lea rax,[rsi+rcx*2-8]
mov rcx,20002000200000
or rcx,[rax+0C]
mov rax,7400780074002E
cmp rcx,rax
sete cl
movzx ecx,cl
M00_L01:
movzx eax,cl
pop rbp
ret
; Total bytes of code 61
注意这里根本没有调用 string.EndsWith
。因为 JIT 已经在这里实现了 EndsWith
功能,针对 ".txt"
和 OrdinalIgnoreCase
,只用了几条指令。字符串的地址在 rsi
寄存器中传递,第二条 mov
指令从字符串对象开始的 8 个
LINQ(语言集成查询)
LINQ,即语言集成查询,是.NET的核心组成部分。LINQ实质上是一系列方法的重载规范,用于操作数据,并针对不同类型实现了这些规范。最显著的实现之一来自System.Linq.Enumerable
,有时被称为“LINQ to Objects”,它为IEnumerable<T>
的操作提供了这些方法。这是一组极其有用的操作,被广泛使用,因此常常成为性能优化的目标。在许多.NET版本中,LINQ都会增加一些新的方法或对现有方法进行优化,这些改进是集中和逐步的。但在.NET 9中,它受到了大量的关注,部分改进仅针对特定方法,而其他改进则适用于大部分功能面。
.NET 9中LINQ的更多全面变化与各种优化实施的策略有关。LINQ最早的实现(大约在2007年)中,几乎每个方法都是逻辑上相互独立的。例如,SelectMany
方法接收一个IEnumerable<TSource>
,并不了解这个输入的来源;每个可枚举对象都被同等处理。虽然一些方法会对更易优化的数据类型进行特殊处理,例如ToArray
会检查传入的IEnumerable<TSource>
是否实现了ICollection<TSource>
,如果实现了,会优先使用集合的Count
和CopyTo
方法,以避免整个输入需要通过MoveNext
/Current
。但某些Select
和Where
的重载方法却进行了更有趣的操作。LINQ的大部分实现都利用了C#编译器对迭代器的支持,一个返回IEnumerable<T>
的方法可以使用yield return t;
来生成T
的实例,编译器会负责将这个方法重写为一个实现IEnumerable<T>
的类,并为你处理所有复杂的状态机细节。然而,这几个Select
和Where
的重载并没有使用迭代器,而是由编写这些方法的开发者手动编写了自定义的可枚举类。为什么这样做?在某些情况下,手动编写可能实现得稍微高效一些,但编译器实际上在这方面做得很好,所以这并不是原因。真正的原因是它做到了a)为类型赋予一个可以在代码的其他部分引用的名称,b)允许这个类型暴露出其他代码可以查询的状态。这使得信息可以从一个查询操作传递到下一个。例如,Where
可以返回一个WhereEnumerableIterator<TSource>
实例:
class WhereEnumerableIterator<TSource> : Iterator<TSource>
{
IEnumerable<TSource> source;
Func<TSource, bool> predicate;
...
}
然后Select
可以查找这个类型,或者更准确地说,是其基类型Iterator<TSource>
:
public static IEnumerable<TResult> Select<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, TResult> selector)
{
if (source == null) throw Error.ArgumentNull("source");
if (selector == null) throw Error.ArgumentNull("selector");
if (source is Iterator<TSource>) return ((Iterator<TSource>)source).Select(selector);
...
}
而WhereEnumerableIterator<TSource>
可以覆盖Iterator<TSource>
上的虚拟Select
方法,以专门处理Where
后跟Select
的情况:
public override IEnumerable<TResult> Select<TResult>(Func<TSource, TResult> selector)
{
return new WhereSelectEnumerableIterator<TSource, TResult>(source, predicate, selector);
}
这种优化是有用的,因为它可以避免可枚举对象中主要的性能开销之一。如果没有这种优化,如果我执行source.Where(x => true).Select(x => x)
,结果的可枚举对象会为Select
创建一个可枚举对象,这个可枚举对象又会包裹Where
的可枚举对象,而Where
的可枚举对象又会包裹原始的source
可枚举对象。这意味着当调用MoveNext
方法时,需要依次调用Where
的MoveNext
,然后是source
的MoveNext
,以及Current
。因此,对于每个源元素,我们最终会进行6次接口调用。有了上述优化,Select
和Where
不再有单独的可枚举对象。这两个操作最终合并为一个可枚举对象,完成两个操作的工作,从而减少了一个调用层次,所以每个元素只需要4次接口调用。(参见深入理解LINQ和更深入的LINQ探索,以更深入地了解这一机制是如何工作的。)
在过去的十年中,这些优化在.NET中得到了显著扩展,在某些情况下,带来了远不止是节省几个接口调用的大幅性能提升。例如,在之前的.NET版本中,类似机制被用来专门处理OrderBy
后跟First
的情况。如果没有专门处理,OrderBy
需要复制整个输入源并进行O(N log N)
的排序,所有这些都在First
的第一个MoveNext
调用中完成。但有了优化,First
可以看到其来源是OrderBy
,在这种情况下,它不需要复制或排序,而可以直接在OrderBy
的源上进行O(N)
的搜索以找到最小值。这种差异可以带来巨大的性能提升。
这种额外的专门处理是通过库中的内部接口实现的。IIListProvider<TElement>
接口提供了ToArray
、ToList
和GetCount
方法,而IPartition<TElement>
接口(继承自IIListProvider<TElement>
)提供了额外的如Skip
、Take
和TryGetFirst
的方法。用于支持各种LINQ方法的自定义迭代器可以实
LINQ -2
关于检查空现在和永久适用的声明特别适用于接受和返回可枚举的方法。正是这些方法的惰性使得这一点相关。然而,确实有一组LINQ方法不是惰性的,因为它们产生的不是可枚举的东西,例如ToArray
返回一个数组,Sum
返回一个单一值,Count
返回一个int
,等等。这些方法也受到了关注,多亏了@neon-sunset的dotnet/runtime#102884。
在各种LINQ方法中应用的一种优化是将特别常见的输入类型设为特殊情况,特别是T[]
和List<T>
。这些不仅可以被设为IList<T>
(这通常比通过IEnumerator<T>
枚举输入更有效率),而是可以直接作为ReadOnlySpan<T>
,这可以非常有效地遍历。这个PR将这种优化扩展到大多数这些非可枚举生成方法,特别是Any
、All
、Count
、First
和Single
这些带有谓词的重载方法。这一点特别有用,因为最近的代码分析器的添加导致开发人员被告知有关简化他们LINQ使用的机会。IDE0120会标记代码如source.Where(predicate).First()
,并建议简化为source.First(predicate)
。虽然这是一个很好的简化并且很可能减少分配,但Where
比First(predicate)
更优化,前者有T[]
和List<T>
的特殊情况,而后者历史上没有。这种差异现在在.NET 9中得到了解决。
方法 | 运行时 | 平均值 | 比率 | 分配 | 分配比率 |
---|---|---|---|---|---|
Any | .NET 8.0 | 1,553.3 纳秒 | 1.00 | 40 字节 | 1.00 |
Any | .NET 9.0 | 222.2 纳秒 | 0.14 | - | 0.00 |
All | .NET 8.0 | 1,586.0 纳秒 | 1.00 | 40 字节 | 1.00 |
All | .NET 9.0 | 224.9 纳秒 | 0.14 | - | 0.00 |
Count | .NET 8.0 | 1,535.6 纳秒 | 1.00 | 40 字节 | 1.00 |
Count | .NET 9.0 | 244.6 纳秒 | 0.16 | - | 0.00 |
First | .NET 8.0 | 1,600.7 纳秒 | 1.00 | 40 字节 | 1.00 |
First | .NET 9.0 | 245.4 纳秒 | 0.15 | - | 0.00 |
Single | .NET 8.0 | 1,550.6 纳秒 | 1.00 | 40 字节 | 1.00 |
Single | .NET 9.0 | 239.4 纳秒 | 0.15 | - | 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。
祝编码愉快!
@media (min-width: 1084px) { .social-panel { flex-direction: column; } } @media (max-width: 1083px) { .evo-social-sidebar { margin-bottom: 24px; } .social-panel { width: 100%; justify-content: space-around; } } a.like-button svg { /* width: 24px; */ width: 28px; } .evo-social-sidebar .votes-count { font-size: 14px; color: var(--clr-social-btn-count); height: 20px; } .evo-social-sidebar .sharing-btns .share-post span { color: var(--clr-body-light); } .evo-social-sidebar .sharing-btns .share-post:hover span { color: var(--clr-body); } .evo-social-sidebar .sharing-btns { box-shadow: var(--clr-share-btn-shadow); } .evo-social-sidebar .social-panel a:focus, .evo-social-sidebar .social-panel button:focus { outline-offset: -4px; } .evo-social-sidebar .evo-dropdown-content { position: absolute; bottom: 100%; z-index: 1000; } .evo-social-sidebar .evo-dropdown-content ul { list-style: none; } @media screen and (max-width: 1084px) { .evo-social-sidebar .evo-dropdown-content { top: 100%; bottom: unset; right: 0; } } @media (max-width: 1083px) { .left-sidebar { margin-top: 40px; } }
- 在Facebook上分享
- [在Twitter上分享](https://twitter.com/intent/tweet?url=https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-9/&text=Performance Improvements in .NET 9 "在Twitter上分享")
- 在LinkedIn上分享
作者
Stephen Toub - MSFT
合作伙伴软件工程师
Stephen Toub 是微软 .NET 团队的一名开发人员。