.NET 10 性能优化
.NET 10的性能故事并非迪士尼式的魔法奇想,而是通过在操作中精雕细琢——此处削减纳秒级延迟,彼处压缩数十字节数据——最终优化了万亿次级别的运行操作。
目录
- 基准测试环境
- 即时编译
- 去抽象化
- 边界检查
- 克隆
- 内联
- 常量折叠
- 代码布局
- GC写屏障
- 指令集
- 杂项
- 原生AOT
- 虚拟机
- 线程处理
- 反射
- 基本类型与数值
- 集合
- 枚举
- LINQ
- 冻结集合
- 位数组
- 其他集合
- 输入输出
- 网络通信
- 搜索技术
- 正则表达式
- 搜索值
- 内存扩展
- JSON
- 诊断工具
- 密码学
- 花生酱
- 后续计划
我的孩子们 超爱 《冰雪奇缘》。他们能唱出每个字、重现每个场景,还能详细说明艾莎冰裙的闪耀效果。我看的次数多到数不清,以至于如果你看过我的现场编程演示,很可能见过我的潜意识里冒出个一两个阿伦黛尔的梗。反复观影后,我开始留意更多细节——比如影片开场采冰工人的歌谣,如何巧妙预示了故事的核心冲突、角色成长轨迹,甚至揭示了高潮转折的关键。说来惭愧,直到第十次观影时我才领悟到这种关联,那时我才意识到自己根本不清楚采冰是否真实存在,还是迪士尼编故事的巧妙噱头。后来查证才发现,这竟是真实存在的行业。

十九世纪制冷技术尚未普及时,冰块堪称珍贵商品。美国北部的冬季将池塘湖泊化作季节性金矿。最成功的采冰作业精准高效:工人先清除冰面积雪以促进冰层增厚硬化,再用马犁在冰面划出规整矩形,将湖面变成冰封的棋盘。切割完成后,工人们用长锯切割出每块重达数百磅的均匀冰块。这些冰块沿着开阔水域的通道漂向岸边,随后工人们用长杆将冰块撬上斜坡,拖入储藏室。基本上,电影里展示的就是这个过程。
储藏本身也是一门艺术。巨大的木制冰库有时可储存数万吨冰块,内部通常用稻草等材料进行隔热处理。若隔热得当,冰块可保持数月坚实,甚至抵御盛夏酷暑;若处理不当,开门便见冰渣。对于需长途运输冰块(通常靠船运)的商人而言,每度温差、每处隔热裂缝、每多一天运输时间,都意味着更多融化与损失。
此时,波士顿“冰王”弗雷德里克·图德登场。他痴迷于系统效率优化。当竞争对手视损失为必然时,他却发现可解之题。经反复试验,他最终采用廉价的锯末——这种锯木厂副产品比稻草更具隔热性。将锯末紧密填充于冰块周围,大幅降低了融化损失。为提升采冰效率,他的团队采用纳撒尼尔·贾维斯·怀斯的网格切割系统,生产出可紧密堆叠的规则冰块,最大限度减少了货舱内易导致冰块暴露的空气间隙。为缩短岸上装运至船上的关键时间,都铎在港口附近扩建仓储设施,使船舶装卸效率倍增。从工具革新到冰库设计再到物流优化,每项改进都形成良性循环,将高风险的本地采冰转变为可靠的全球贸易。得益于都铎的改进,哈瓦那、里约热内卢乃至加尔各答(1830年代航程需四个月)都能收到坚实冰块。其效能提升使产品得以完成此前难以想象的长途运输。
让都铎的冰块能跨越半个地球的并非某个重大创新,而是无数微小改进层层叠加产生的倍增效应。软件开发领域同样遵循此则:性能的飞跃性提升鲜少源于单次颠覆性变革,而是由成百上千次精准优化层层叠加而成的变革性突破。.NET 10的性能故事并非迪士尼式的魔法奇想,而是通过在操作中精雕细琢——此处削减纳秒级延迟,彼处压缩数十字节数据——最终优化了万亿次级别的运行操作。
在本篇后续内容中,我们将延续此前在.NET 9、.NET 8、 .NET 7、.NET 6、.NET 5、.NET Core 3.0、.NET Core 2.1 以及 .NET Core 2.0,我们将深入探讨自 .NET 9 以来数百项细微却意义重大且具有累积效应的性能优化,这些改进共同构成了 .NET 10 的核心故事(若您选择继续使用 LTS 版本,因此是从 .NET 8 升级而非从 .NET 9 升级, 您还将看到基于所有.NET 9改进的聚合成果带来的更多提升)。那么,别再犹豫了,去倒一杯您最爱的热饮(或者,考虑到我的开场白,或许来点更冰凉的饮品),放松身心,尽情享受吧!
或者,嗯,或许该把性能推向“未知领域”?
让.NET 10的性能“展现真我”?
“你想建造一个雪人快速服务吗?”
我这就告辞。
基准测试环境§
与往期内容相同,本次巡礼包含大量微基准测试,旨在展示各类性能提升。多数测试采用BenchmarkDotNet 0.15.2实现,每个测试均采用简易配置。
若需跟进操作,请确保已安装.NET 9和.NET 10,因多数基准测试需在两个平台上运行相同测试进行对比。接着在新建的benchmarks目录中创建C#项目:
dotnet new console -o benchmarks
cd benchmarks
这将在benchmarks目录生成两个文件:benchmarks.csproj(包含应用程序编译信息的项目文件)和Program.cs(应用程序代码文件)。最后将benchmarks.csproj中的内容替换为以下代码:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0;net9.0</TargetFrameworks>
<LangVersion>Preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.15.2" />
</ItemGroup>
</Project>
至此准备就绪。除非另有说明,每个基准测试均设计为独立运行:只需将完整内容复制粘贴至 Program.cs 文件覆盖原有内容,即可运行测试。每个测试开头均包含 dotnet 命令的注释说明,通常如下所示:
dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
该命令将在.NET 9和.NET 10环境下以发布模式运行基准测试并显示对比结果。另一种常见变体用于仅在.NET 10环境运行测试(通常用于比较两种方案而非同一方案在不同版本间的差异),格式如下:
dotnet run -c Release -f net10.0 --filter "*"
本文展示了大量基准测试及其运行结果。除非特别说明(例如演示操作系统特有的性能提升),所有结果均基于x64处理器上的Linux系统(Ubuntu 24.04.1)运行所得。
BenchmarkDotNet v0.15.2, Linux Ubuntu 24.04.1 LTS (Noble Numbat)
11th Gen Intel Core i9-11950H 2.60GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 10.0.100-rc.1.25451.107
[Host] : .NET 9.0.9 (9.0.925.41916), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
惯例声明:这些是微基准测试,计时操作短到眨眼间就会错过(但当这类操作运行数百万次时,节省的时间就相当可观了)。您获得的确切数值将取决于您的硬件、操作系统、机器当前处理的其他任务、早餐后喝了多少咖啡,甚至可能取决于水星是否逆行。换言之,别指望你的结果与我完全一致,但我选取的测试在现实场景中仍应具备合理可复现性。
现在,让我们从栈底开始——代码生成环节。
JIT§
在.NET所有领域中,即时编译器(JIT)堪称最具影响力的组件之一。无论是小型控制台工具还是大型企业级服务,所有.NET应用最终都依赖JIT将中间语言(IL)代码转化为优化后的机器码。JIT生成的代码质量提升具有连锁效应——无需开发者修改代码甚至重新编译C#,就能提升整个生态系统的性能。而.NET 10版本中,这类改进可谓层出不穷。
消除抽象§
与许多语言类似,.NET 历史上存在“抽象开销”——使用接口、迭代器和委托等高级语言特性时可能产生的额外分配和间接操作。每年,即时编译器(JIT)都在不断优化抽象层,使开发者能够编写简洁代码并获得卓越性能。.NET 10 延续了这一传统。其结果是,符合惯例的 C# 代码(使用接口、foreach 循环、lambda 表达式等)的运行速度已逼近精心编写并手动调优的代码的原始速度。
对象栈分配
.NET 10 在消除抽象方面的最令人振奋的进展之一,是扩展了逃逸分析的应用范围,从而实现了对象的栈分配。逃逸分析是编译器技术,用于判断方法中分配的对象是否逃逸该方法——即确定对象在方法返回后是否仍可被访问(例如存储在字段中或返回给调用方),或以运行时无法追踪的方式在方法内被使用(如传递给未知调用方)。若编译器能证明对象不存在逃逸,则该对象的生命周期仅限于方法内部,可直接在栈上分配而非堆上分配。栈分配成本更低(仅需指针递增分配,方法退出时自动释放),且能减轻垃圾回收压力——毕竟对象无需被垃圾回收器追踪。.NET 9已引入有限的逃逸分析与栈分配支持;.NET 10在此基础上实现了重大突破。
dotnet/runtime#115172 指导 JIT 执行与委托相关的逃逸分析,特别是指出委托的 Invoke 方法(由运行时实现)不会隐藏 this 引用。当逃逸分析能证明委托的对象引用属于未逃逸资源时,该委托即可被有效消除。请看以下基准测试:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "y")]
public partial class Tests
{
[Benchmark]
[Arguments(42)]
public int Sum(int y)
{
Func<int, int> addY = x => x + y;
return DoubleResult(addY, y);
}
private int DoubleResult(Func<int, int> func, int arg)
{
int result = func(arg);
return result + result;
}
}
若直接运行此基准测试并比较 .NET 9 与 .NET 10 的结果,可立即发现有趣现象。
| Method | Runtime | Mean | Ratio | Code Size | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|
| Sum | .NET 9.0 | 19.530 ns | 1.00 | 118 B | 88 B | 1.00 |
| Sum | .NET 10.0 | 6.685 ns | 0.34 | 32 B | 24 B | 0.27 |
Sum的C#代码背后隐藏着编译器的复杂代码生成机制。它需要创建一个Func<int, int>,该函数会“闭包”作用于局部变量y。这意味着编译器需要将y“提升”为非实际局部变量,转而作为对象的字段存在;委托即可指向该对象的方法,从而访问y。以下是C#编译器生成的IL反编译为C#后的近似形态:
public int Sum(int y)
{
<>c__DisplayClass0_0 c = new();
c.y = y;
Func<int, int> func = new(c.<Sum>b__0);
return DoubleResult(func, c.y);
}
private sealed class <>c__DisplayClass0_0
{
public int y;
internal int <Sum>b__0(int x) => x + y;
}
由此可见,闭包导致了两次内存分配:一次是为“显示类”(C#编译器对这类闭包类型的称谓)分配内存,另一次是为指向该显示类实例上<Sum>b__0方法的委托分配内存。这正是.NET 9结果中88字节分配的来源:显示类占24字节,委托占64字节。但在.NET 10版本中,我们仅看到24字节分配——这是因为JIT成功省略了委托分配。以下是生成的程序集代码:
; .NET 9
; Tests.Sum(Int32)
push rbp
push r15
push rbx
lea rbp,[rsp+10]
mov ebx,esi
mov rdi,offset MT_Tests+<>c__DisplayClass0_0
call CORINFO_HELP_NEWSFAST
mov r15,rax
mov [r15+8],ebx
mov rdi,offset MT_System.Func<System.Int32, System.Int32>
call CORINFO_HELP_NEWSFAST
mov rbx,rax
lea rdi,[rbx+8]
mov rsi,r15
call CORINFO_HELP_ASSIGN_REF
mov rax,offset Tests+<>c__DisplayClass0_0.<Sum>b__0(Int32)
mov [rbx+18],rax
mov esi,[r15+8]
cmp [rbx+18],rax
jne short M00_L01
mov rax,[rbx+8]
add esi,[rax+8]
mov eax,esi
M00_L00:
add eax,eax
pop rbx
pop r15
pop rbp
ret
M00_L01:
mov rdi,[rbx+8]
call qword ptr [rbx+18]
jmp short M00_L00
; Total bytes of code 112
; .NET 10
; Tests.Sum(Int32)
push rbx
mov ebx,esi
mov rdi,offset MT_Tests+<>c__DisplayClass0_0
call CORINFO_HELP_NEWSFAST
mov [rax+8],ebx
mov eax,[rax+8]
mov ecx,eax
add eax,ecx
add eax,eax
pop rbx
ret
; Total bytes of code 32
在.NET 9和.NET 10中,JIT都成功内联了DoubleResult,使得委托未逃逸,但.NET 10进一步实现了栈分配。太棒了!显然仍有优化空间——JIT尚未消除闭包对象的分配,但这应能通过进一步努力解决,希望不久的将来就能实现。
dotnet/runtime#104906 由 @hez2010 提出,及 dotnet/runtime#112250 将此类分析与栈分配扩展至数组场景。你是否曾多次编写过类似代码?
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
[Benchmark]
public void Test()
{
Process(new string[] { "a", "b", "c" });
static void Process(string[] inputs)
{
foreach (string input in inputs)
{
Use(input);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void Use(string input) { }
}
}
}
某些需要调用的方法接受输入数组,并对每个输入执行操作。我必须显式分配数组传递输入,或因使用params参数或集合表达式而隐式分配。理想情况下,未来应提供Process方法的重载版本,使其接受ReadOnlySpan<string>而非string[],从而在构造时避免分配操作。但在所有必须创建数组的场景中,.NET 10 提供了解决方案。
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| Test | .NET 9.0 | 11.580 ns | 1.00 | 48 B | 1.00 |
| Test | .NET 10.0 | 3.960 ns | 0.34 | – | 0.00 |
JIT 编译器能够将 Process 内联,识别出数组始终未离开调用帧,并采用栈分配方式实现。
当然,既然我们现在能够为数组进行栈分配,我们也希望能够处理数组的一种常见用法:通过span进行访问。dotnet/runtime#113977 和 dotnet/runtime#116124 通过逃逸分析来处理结构体中的字段,其中包含 Span<T>,因为它“仅仅”是一个存储 ref T 字段和 int 长度字段的结构体。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private byte[] _buffer = new byte[3];
[Benchmark]
public void Test() => Copy3Bytes(0x12345678, _buffer);
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Copy3Bytes(int value, Span<byte> dest) =>
BitConverter.GetBytes(value).AsSpan(0, 3).CopyTo(dest);
}
此处使用 BitConverter.GetBytes 方法,该方法会分配一个包含输入字节的 byte[] 数组(本例中为存储 int 的四字节数组),随后截取其中三个字节并复制到目标 Span 中。
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| Test | .NET 9.0 | 9.7717 ns | 1.04 | 32 B | 1.00 |
| Test | .NET 10.0 | 0.8718 ns | 0.09 | – | 0.00 |
在 .NET 9 中,GetBytes 中的 byte[] 会按预期分配 32 字节(64 位系统中每个对象至少占用 24 字节,其中包含数组长度的 4 字节,数据部分的 4 字节位于第 24-27 个存储单元, 大小会被补足至下一个字边界,达到32字节)。而在.NET 10中,由于GetBytes和AsSpan被内联,JIT能识别该数组未溢出栈,因此可使用栈分配版本为span初始化,如同从其他栈分配(如stackalloc)创建时一样。(此案例还受益于dotnet/runtime#113093的改进,该改进使JIT理解到某些span操作——如CopyTo内部使用的Memmove——属于非逃逸操作。)
虚拟化消除
接口与虚方法是.NET及其抽象机制的核心要素。能够展开这些抽象并实现“虚拟化消除”,是JIT的重要任务,而.NET 10在此领域取得了显著突破。
尽管数组是C#和.NET的核心特性之一,且JIT投入大量精力优化了数组的诸多方面,但数组的接口实现始终是其痛点所在。运行时为T[]生成大量接口实现,由于其实现方式与.NET中其他所有接口实现截然不同,JIT无法像处理其他接口那样应用去虚拟化技术。对于深入研究微基准测试的人而言,这会导致一些奇怪的现象。以下是使用foreach循环(遍历枚举器)与for循环(逐个索引元素)遍历ReadOnlyCollection<T>的性能对比:
// dotnet run -c Release -f net9.0 --filter "*"
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Collections.ObjectModel;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private ReadOnlyCollection<int> _list = new(Enumerable.Range(1, 1000).ToArray());
[Benchmark]
public int SumEnumerable()
{
int sum = 0;
foreach (var item in _list)
{
sum += item;
}
return sum;
}
[Benchmark]
public int SumForLoop()
{
ReadOnlyCollection<int> list = _list;
int sum = 0;
int count = list.Count;
for (int i = 0; i < count; i++)
{
sum += _list[i];
}
return sum;
}
}
若问“哪种方式更快”,显而易见的答案是“SumForLoop”。毕竟SumEnumerable需要分配枚举器,且每次迭代需执行两倍接口调用(MoveNext+Current vs this[int])。然而事实证明,这个显而易见的答案是错误的。以下是在我的机器上针对 .NET 9 的计时结果:
| Method | Mean |
|---|---|
| SumEnumerable | 949.5 ns |
| SumForLoop | 1,932.7 ns |
怎么回事??然而,如果将 ToArray 改为 ToList,结果就更符合预期了。
| Method | Mean |
|---|---|
| SumEnumerable | 1,542.0 ns |
| SumForLoop | 894.1 ns |
究竟发生了什么?问题极其微妙。首先需明确:ReadOnlyCollection<T> 仅封装任意 IList<T>,其 GetEnumerator() 方法会调用 _list.GetEnumerator() (此处暂不讨论列表为空的特殊情况),而ReadOnlyCollection<T>的索引器本质上是调用IList<T>的索引器。到目前为止,这似乎符合预期。但真正有趣的是JIT编译器能否实现方法反虚化。在 .NET 9 中,JIT 难以对 T[] 上的接口实现调用进行去虚拟化,因此既不会对 _list.GetEnumerator() 调用去虚拟化,也不会对 _list[index] 调用去虚拟化。然而,枚举器本身只是实现IEnumerator<T>的普通类型,JIT对MoveNext和Current成员的反虚化毫无障碍。这意味着通过索引器访问的开销远高于枚举器:当元素数量为 N 时,索引器需执行 N 次接口调用,而枚举器仅需一次 GetEnumerator 接口调用即可完成后续操作。
值得庆幸的是,此问题已在 .NET 10 中得到修复。dotnet/runtime#108153、dotnet/runtime#109209、dotnet/runtime# 109237 和 dotnet/runtime#116771 均使 JIT 能够对数组的接口方法实现进行去虚拟化。现在当我们运行相同的基准测试(恢复使用ToArray)时,结果更符合预期:两个基准测试在.NET 9到.NET 10的升级中均有提升,其中SumForLoop在.NET 10环境下表现最快。
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| SumEnumerable | .NET 9.0 | 968.5 ns | 1.00 |
| SumEnumerable | .NET 10.0 | 775.5 ns | 0.80 |
| SumForLoop | .NET 9.0 | 1,960.5 ns | 1.00 |
| SumForLoop | .NET 10.0 | 624.6 ns | 0.32 |
其中一个真正有趣的现象是,许多库都基于这样一个前提进行实现:使用IList<T>的索引器进行迭代比使用其IEnumerable<T>更快,这包括System.Linq库。多年来,LINQ在可能的情况下始终采用专属代码路径处理IList<T>,虽然多数场景下这是有益的优化,但在 某些 情况下(例如具体类型为ReadOnlyCollection<T>时),反而会导致性能退化。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Collections.ObjectModel;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private ReadOnlyCollection<int> _list = new(Enumerable.Range(1, 1000).ToArray());
[Benchmark]
public int SkipTakeSum() => _list.Skip(100).Take(800).Sum();
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| SkipTakeSum | .NET 9.0 | 3.525 us | 1.00 |
| SkipTakeSum | .NET 10.0 | 1.773 us | 0.50 |
修复数组接口实现的反虚拟化问题,同样会对LINQ产生传递性影响。
.NET 10中还改进了受保护的反虚拟化(GDV),例如dotnet/runtime#116453 和 dotnet/runtime#109256。借助动态PGO,JIT编译器能够对方法编译过程进行仪器化,并利用生成的剖析数据优化方法版本。其剖析功能之一是识别虚拟调用中使用的类型。若某类型占主导地位,代码生成器可将其作为特殊情况处理,生成针对该类型的定制化实现。这便能在专属路径中实现去虚拟化,该路径由相关类型检查“守护”,故称“GDV”。但某些情况下(例如在共享泛型上下文中调用虚函数时),GDV不会生效。如今这一问题已得到解决。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
[Benchmark]
public bool Test() => GenericEquals("abc", "abc");
[MethodImpl(MethodImplOptions.NoInlining)]
private static bool GenericEquals<T>(T a, T b) => EqualityComparer<T>.Default.Equals(a, b);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Test | .NET 9.0 | 2.816 ns | 1.00 |
| Test | .NET 10.0 | 1.511 ns | 0.54 |
dotnet/runtime#110827 提案由 @hez2010 提出,通过在脱虚拟化后期阶段进行额外扫描寻找优化机会,进一步提升方法内联率。JIT 优化被拆分为多个阶段;每个阶段都能带来改进,而这些改进又会揭示新的优化机会。若这些机会只能由已运行的阶段利用,则可能被遗漏。但对于执行成本较低的阶段(如搜索额外内联机会的遍历),当其他优化已足够充分时,这些阶段可重复执行以提升效率。
边界检查§
C#作为内存安全的语言,体现了现代编程语言的重要特性。其核心机制在于禁止越界访问数组、字符串或span的起始/末尾位置。运行时确保任何此类无效尝试都会抛出异常,而非允许执行非法内存访问。通过小型基准测试可观察其工作原理:
// dotnet run -c Release -f net10.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private int[] _array = new int[3];
[Benchmark]
public int Read() => _array[2];
}
这是合法访问:_array包含三个元素,Read方法正在读取其最后一个元素。然而JIT无法100%确定此访问是否在边界内(可能有操作将_array字段改为更短的数组),因此需要生成检查代码以确保不会越界访问数组末尾。以下是Read方法生成的汇编代码:
; .NET 10
; Tests.Read()
push rax
mov rax,[rdi+8]
cmp dword ptr [rax+8],2
jbe short M00_L00
mov eax,[rax+18]
add rsp,8
ret
M00_L00:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 25
this引用通过rdi寄存器传递至Read实例方法,而_array字段位于偏移量8处,因此mov rax,[rdi+8]指令将数组地址加载至rax寄存器。随后cmp指令从该地址加载偏移量8处的值——恰好数组对象的数组长度存储在此处。因此该cmp指令即为边界检查:通过将2与数组长度比较来确保访问合法。若数组长度不足以支持此访问,后续的jbe指令将跳转至M00_L00标签,调用CORINFO_HELP_RNGCHKFAIL辅助函数抛出IndexOutOfRangeException异常。每当在方法末尾看到 call CORINFO_HELP_RNGCHKFAIL/int 3 这对指令时,说明该方法中至少存在一次由 JIT 编译器生成的边界检查。
当然,我们不仅追求安全性,更追求卓越性能。若每次数组(或字符串、span)读取都需额外检查,将严重影响性能。因此,当访问操作在构造上可被证明安全时,JIT会努力避免生成冗余的边界检查。例如,我稍作调整基准测试,将数组从实例字段移至static readonly字段:
// dotnet run -c Release -f net10.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private static readonly int[] s_array = new int[3];
[Benchmark]
public int Read() => s_array[2];
}
此时生成的汇编代码如下:
; .NET 10
; Tests.Read()
mov rax,705D5419FA20
mov eax,[rax+18]
ret
; Total bytes of code 14
static readonly 字段不可变,数组无法调整大小,且 JIT 编译器能确保在生成 Read 方法代码前该字段已初始化。因此在生成 Read 代码时,它能确知数组长度为三,且当前访问的是索引为二的元素。这意味着指定的数组索引必然在边界范围内,无需进行边界检查。我们仅需两条mov指令:第一条mov加载数组地址(得益于前几版改进,数组分配在无需紧凑化的堆上,故地址固定),第二条mov读取索引位置处的int值 (由于是整型数据,索引2的起始位置距数组数据区起始处为2 * sizeof(int) = 8字节。在64位系统中,该数据区本身距数组引用起始处偏移16字节,因此总偏移量为24字节,十六进制表示为0x18,故反汇编代码中出现rax+18)。
每次.NET版本发布,都会发现并实现更多机会来避免生成原先存在的边界检查。.NET 10延续了这一趋势。
首个示例源自dotnet/runtime#109900,其灵感来自BitOperations.Log2的实现。该操作在多数架构上具备硬件原生支持,通常BitOperations.Log2会调用可用的硬件原语实现高效运算(如Lscnt.LeadingZeroCount、ArmBase.LeadingZeroCount或X86Base.BitScanReverse),但其备用实现采用查找表方案。该查找表包含32个元素,其操作流程是计算一个uint值,然后向右移位27位以获取最高5位。任何可能的结果都保证是小于32的非负数,但使用该结果对范围进行索引仍会触发边界检查。由于这是关键路径,因此使用了“不安全”代码(即放弃运行时默认提供的保护机制)来规避边界检查。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "value")]
public partial class Tests
{
[Benchmark]
[Arguments(42)]
public int Log2SoftwareFallback2(uint value)
{
ReadOnlySpan<byte> Log2DeBruijn =
[
00, 09, 01, 10, 13, 21, 02, 29,
11, 14, 16, 18, 22, 25, 03, 30,
08, 12, 20, 28, 15, 17, 24, 07,
19, 27, 23, 06, 26, 05, 04, 31
];
value |= value >> 01;
value |= value >> 02;
value |= value >> 04;
value |= value >> 08;
value |= value >> 16;
return Log2DeBruijn[(int)((value * 0x07C4ACDDu) >> 27)];
}
}
在 .NET 10 中,边界检查已被移除(注意 .NET 9 程序集存在 call CORINFO_HELP_RNGCHKFAIL 调用,而 .NET 10 程序集中已不存在该调用)。
; .NET 9
; Tests.Log2SoftwareFallback2(UInt32)
push rax
mov eax,esi
shr eax,1
or esi,eax
mov eax,esi
shr eax,2
or esi,eax
mov eax,esi
shr eax,4
or esi,eax
mov eax,esi
shr eax,8
or esi,eax
mov eax,esi
shr eax,10
or eax,esi
imul eax,7C4ACDD
shr eax,1B
cmp eax,20
jae short M00_L00
mov rcx,7913CA812E10
movzx eax,byte ptr [rax+rcx]
add rsp,8
ret
M00_L00:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 74
; .NET 10
; Tests.Log2SoftwareFallback2(UInt32)
mov eax,esi
shr eax,1
or esi,eax
mov eax,esi
shr eax,2
or esi,eax
mov eax,esi
shr eax,4
or esi,eax
mov eax,esi
shr eax,8
or esi,eax
mov eax,esi
shr eax,10
or eax,esi
imul eax,7C4ACDD
shr eax,1B
mov rcx,7CA298325E10
movzx eax,byte ptr [rcx+rax]
ret
; Total bytes of code 58
此项改进使dotnet/runtime#118560得以简化实际Log2SoftwareFallback中的代码,避免手动使用不安全构造。
dotnet/runtime#113790 实现了类似场景,即数学运算结果被保证在边界范围内。此处涉及的是 Log2 的运算结果。该变更使JIT编译器能够理解Log2可能产生的最大值,若该最大值在边界范围内,则任何结果都保证在边界内。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "value")]
public partial class Tests
{
[Benchmark]
[Arguments(12345)]
public nint CountDigits(ulong value)
{
ReadOnlySpan<byte> log2ToPow10 =
[
1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5,
6, 6, 6, 7, 7, 7, 7, 8, 8, 8, 9, 9, 9, 10, 10, 10,
10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 13, 14, 14, 14, 15, 15,
15, 16, 16, 16, 16, 17, 17, 17, 18, 18, 18, 19, 19, 19, 19, 20
];
return log2ToPow10[(int)ulong.Log2(value)];
}
}
我们可观察到.NET 9输出中存在边界检查,而.NET 10输出中则缺失:
; .NET 9
; Tests.CountDigits(UInt64)
push rax
or rsi,1
xor eax,eax
lzcnt rax,rsi
xor eax,3F
cmp eax,40
jae short M00_L00
mov rcx,7C2D0A213DF8
movzx eax,byte ptr [rax+rcx]
add rsp,8
ret
M00_L00:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 45
; .NET 10
; Tests.CountDigits(UInt64)
or rsi,1
xor eax,eax
lzcnt rax,rsi
xor eax,3F
mov rcx,71EFA9400DF8
movzx eax,byte ptr [rcx+rax]
ret
; Total bytes of code 29
本次选择该基准测试并非偶然。此模式出现在FormattingHelpers.CountDigits内部方法中,该方法被核心基本类型用于其ToString和TryFormat实现,以确定存储数字渲染位所需的空间。与前例相同,该例程被视为核心功能,因此使用了不安全代码来规避边界检查。修复后,代码得以改回使用简单的span访问,即使代码更简洁,运行速度也更快了。
现在考虑以下代码:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "ids")]
public partial class Tests
{
public IEnumerable<int[]> Ids { get; } = [[1, 2, 3, 4, 5, 1]];
[Benchmark]
[ArgumentsSource(nameof(Ids))]
public bool StartAndEndAreSame(int[] ids) => ids[0] == ids[^1];
}
我有一个方法接受int[]数组,用于检查数组首尾元素是否相同。JIT编译器无法判断该数组是否为空,因此必须进行边界检查;否则访问ids[0]可能导致数组越界。但在.NET 9中我们看到:
; .NET 9
; Tests.StartAndEndAreSame(Int32[])
push rax
mov eax,[rsi+8]
test eax,eax
je short M00_L00
mov ecx,[rsi+10]
lea edx,[rax-1]
cmp edx,eax
jae short M00_L00
mov eax,edx
cmp ecx,[rsi+rax*4+10]
sete al
movzx eax,al
add rsp,8
ret
M00_L00:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 41
注意这里有两次跳转到处理边界检查失败的M00_L00标签…这是因为存在两次边界检查:一次用于起始访问,一次用于末尾访问。但这本不必要。ids[^1]等同于ids[ids.Length - 1]。若代码已成功访问ids[0],则说明数组至少包含一个元素;既然数组长度≥1,那么ids[ids.Length - 1]必然在边界内。因此第二个边界检查实属多余。事实上,得益于dotnet/runtime#116105,.NET 10 现已实现此优化(仅需一条分支跳转至 M00_L00 而非两条):
; .NET 10
; Tests.StartAndEndAreSame(Int32[])
push rax
mov eax,[rsi+8]
test eax,eax
je short M00_L00
mov ecx,[rsi+10]
dec eax
cmp ecx,[rsi+rax*4+10]
sete al
movzx eax,al
add rsp,8
ret
M00_L00:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 34
真正令我感兴趣的是移除边界检查带来的连锁效应。它不仅消除了边界检查典型的cmp/jae指令对,更改变了.NET 9版本的代码结构:
lea edx,[rax-1]
cmp edx,eax
jae short M00_L00
mov eax,edx
在此汇编阶段,rax寄存器存储着数组长度。程序计算ids.Length - 1并将结果存入edx,随后检查ids.Length-1是否在ids.Length范围内(唯一可能超出范围的情况是数组为空导致ids. Length-1 溢出为 uint.MaxValue);若超出范围则跳转至失败处理程序,否则将已计算的 ids.Length - 1 存入 eax。移除边界检查后,可省略中间两条指令,最终代码如下:
lea edx,[rax-1]
mov eax,edx
这段代码略显冗余,因为该序列仅需计算递减操作。只要允许标志位被修改,完全可以简化为:
dec eax
如.NET 10输出所示,这正是.NET 10当前的实现方式。
dotnet/runtime#115980 处理了另一种情况。假设我有这样一个方法:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "start", "text")]
public partial class Tests
{
[Benchmark]
[Arguments("abc", "abc.")]
public bool IsFollowedByPeriod(string start, string text) =>
start.Length < text.Length && text[start.Length] == '.';
}
我们验证一个输入的长度是否小于另一个,然后检查另一个输入中紧随其后的内容。我们知道string.Length是不可变的,因此这里的边界检查是多余的,但在.NET 10之前,JIT无法识别这一点。
; .NET 9
; Tests.IsFollowedByPeriod(System.String, System.String)
push rbp
mov rbp,rsp
mov eax,[rsi+8]
mov ecx,[rdx+8]
cmp eax,ecx
jge short M00_L00
cmp eax,ecx
jae short M00_L01
cmp word ptr [rdx+rax*2+0C],2E
sete al
movzx eax,al
pop rbp
ret
M00_L00:
xor eax,eax
pop rbp
ret
M00_L01:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 42
; .NET 10
; Tests.IsFollowedByPeriod(System.String, System.String)
mov eax,[rsi+8]
mov ecx,[rdx+8]
cmp eax,ecx
jge short M00_L00
cmp word ptr [rdx+rax*2+0C],2E
sete al
movzx eax,al
ret
M00_L00:
xor eax,eax
ret
; Total bytes of code 26
移除边界检查后,函数大小几乎减半。如果不需要进行边界检查,就可以省略cmp/jae指令对。由于不再需要分支跳转,M00_L01指令不再被调用,因此可移除仅为支持边界检查而存在的call/int指令对。当M00_L01(该方法中唯一的call指令)消失后,前置代码和后置代码均可省略,意味着开头的push和结尾的pop指令也变得多余。
dotnet/runtime#113233 改进了对“断言”(JIT 基于其主张事实进行优化的依据)的处理,使其更少依赖顺序。在 .NET 9 中,这段代码:
static bool Test(ReadOnlySpan<char> span, int pos) =>
pos > 0 &&
pos <= span.Length - 42 &&
span[pos - 1] != '\n';
成功移除了对范围访问的边界检查,但以下变体(仅交换了前两个条件的顺序)仍会触发边界检查:
static bool Test(ReadOnlySpan<char> span, int pos) =>
pos <= span.Length - 42 &&
pos > 0 &&
span[pos - 1] != '\n';
需注意两个条件均贡献了需合并的断言(事实),方可确定可省略边界检查。而在 .NET 10 中,无论顺序如何,边界检查均被省略。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private string _s = new string('s', 100);
private int _pos = 10;
[Benchmark]
public bool Test()
{
string s = _s;
int pos = _pos;
return
pos <= s.Length - 42 &&
pos > 0 &&
s[pos - 1] != '\n';
}
}
; .NET 9
; Tests.Test()
push rbp
mov rbp,rsp
mov rax,[rdi+8]
mov ecx,[rdi+10]
mov edx,[rax+8]
lea edi,[rdx-2A]
cmp edi,ecx
jl short M00_L00
test ecx,ecx
jle short M00_L00
dec ecx
cmp ecx,edx
jae short M00_L01
cmp word ptr [rax+rcx*2+0C],0A
setne al
movzx eax,al
pop rbp
ret
M00_L00:
xor eax,eax
pop rbp
ret
M00_L01:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 55
; .NET 10
; Tests.Test()
push rbp
mov rbp,rsp
mov rax,[rdi+8]
mov ecx,[rdi+10]
mov edx,[rax+8]
add edx,0FFFFFFD6
cmp edx,ecx
jl short M00_L00
test ecx,ecx
jle short M00_L00
dec ecx
cmp word ptr [rax+rcx*2+0C],0A
setne al
movzx eax,al
pop rbp
ret
M00_L00:
xor eax,eax
pop rbp
ret
; Total bytes of code 45
dotnet/runtime#113862 修复了类似场景中断言处理不够精确的问题。请看以下代码:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private int[] _arr = Enumerable.Range(0, 10).ToArray();
[Benchmark]
public int Sum()
{
int[] arr = _arr;
int sum = 0;
int i;
for (i = 0; i < arr.Length - 3; i += 4)
{
sum += arr[i + 0];
sum += arr[i + 1];
sum += arr[i + 2];
sum += arr[i + 3];
}
for (; i < arr.Length; i++)
{
sum += arr[i];
}
return sum;
}
}
Sum方法尝试手动展开循环。它跳过对每个元素的分支处理,改为每次迭代处理四个元素。当输入长度无法被四整除时,则通过独立循环处理剩余元素。在.NET 9中,JIT成功省略了主展开循环中的边界检查:
; .NET 9
; Tests.Sum()
push rbp
mov rbp,rsp
mov rax,[rdi+8]
xor ecx,ecx
xor edx,edx
mov edi,[rax+8]
lea esi,[rdi-3]
test esi,esi
jle short M00_L02
M00_L00:
mov r8d,edx
add ecx,[rax+r8*4+10]
lea r8d,[rdx+1]
add ecx,[rax+r8*4+10]
lea r8d,[rdx+2]
add ecx,[rax+r8*4+10]
lea r8d,[rdx+3]
add ecx,[rax+r8*4+10]
add edx,4
cmp esi,edx
jg short M00_L00
jmp short M00_L02
M00_L01:
cmp edx,edi
jae short M00_L03
mov esi,edx
add ecx,[rax+rsi*4+10]
inc edx
M00_L02:
cmp edi,edx
jg short M00_L01
mov eax,ecx
pop rbp
ret
M00_L03:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 92
在M00_L00段中可见五个add指令(四个用于求和元素,一个用于索引)。但末尾仍存在CORINFO_HELP_RNGCHKFAIL标记,表明该方法保留了边界检查。该检查源自最终循环,因JIT未能追踪到i值必然非负的特性。而在.NET 10中,该边界检查同样被移除(再次通过CORINFO_HELP_RNGCHKFAIL调用缺失即可验证)。
; .NET 10
; Tests.Sum()
push rbp
mov rbp,rsp
mov rax,[rdi+8]
xor ecx,ecx
xor edx,edx
mov edi,[rax+8]
lea esi,[rdi-3]
test esi,esi
jle short M00_L01
M00_L00:
mov r8d,edx
add ecx,[rax+r8*4+10]
lea r8d,[rdx+1]
add ecx,[rax+r8*4+10]
lea r8d,[rdx+2]
add ecx,[rax+r8*4+10]
lea r8d,[rdx+3]
add ecx,[rax+r8*4+10]
add edx,4
cmp esi,edx
jg short M00_L00
M00_L01:
cmp edi,edx
jle short M00_L03
test edx,edx
jl short M00_L04
M00_L02:
mov esi,edx
add ecx,[rax+rsi*4+10]
inc edx
cmp edi,edx
jg short M00_L02
M00_L03:
mov eax,ecx
pop rbp
ret
M00_L04:
mov esi,edx
add ecx,[rax+rsi*4+10]
inc edx
cmp edi,edx
jg short M00_L04
jmp short M00_L03
; Total bytes of code 102
另一项优化来自dotnet/runtime#112824,该方案指导JIT将先前检查中获取的事实转化为具体数值范围,进而利用这些范围折叠后续的关系测试和边界检查。请看以下示例:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.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 partial class Tests
{
private int[] _array = new int[10];
[Benchmark]
public void Test() => SetAndSlice(_array);
[MethodImpl(MethodImplOptions.NoInlining)]
private static Span<int> SetAndSlice(Span<int> src)
{
src[5] = 42;
return src.Slice(4);
}
}
由于JIT无法确认src长度至少为六,我们必须对src[5]进行边界检查。但当执行到Slice调用时,我们已知该切片长度至少为六——否则写入src[5]将失败。我们可利用此信息移除Slice调用中的长度检查(注意删除了call qword ptr [7F8DDB3A7810]/int 3序列,该序列对应Slice中手动长度检查及抛出辅助方法的调用)。
; .NET 9
; Tests.SetAndSlice(System.Span`1<Int32>)
push rbp
mov rbp,rsp
cmp esi,5
jbe short M01_L01
mov dword ptr [rdi+14],2A
cmp esi,4
jb short M01_L00
add rdi,10
mov rax,rdi
add esi,0FFFFFFFC
mov edx,esi
pop rbp
ret
M01_L00:
call qword ptr [7F8DDB3A7810]
int 3
M01_L01:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 48
; .NET 10
; Tests.SetAndSlice(System.Span`1<Int32>)
push rax
cmp esi,5
jbe short M01_L00
mov dword ptr [rdi+14],2A
lea rax,[rdi+10]
lea edx,[rsi-4]
add rsp,8
ret
M01_L00:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 31
再看另一个案例,它对边界检查产生了显著影响(尽管技术上该优化范围更广)。dotnet/runtime#113998通过switch目标生成断言。这意味着switch分支语句的执行体能继承case条件对应的切换依据——例如在switch (x)的case 3分支中,该分支执行体将“知晓”x值为3。这种特性对数组、字符串和span等常用模式尤为有益,开发者常通过长度切换条件,再根据分支结果索引访问数据。请看以下示例:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.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 partial class Tests
{
private int[] _array = [1, 2];
[Benchmark]
public int SumArray() => Sum(_array);
[MethodImpl(MethodImplOptions.NoInlining)]
public int Sum(ReadOnlySpan<int> span)
{
switch (span.Length)
{
case 0: return 0;
case 1: return span[0];
case 2: return span[0] + span[1];
case 3: return span[0] + span[1] + span[2];
default: return -1;
}
}
}
在 .NET 9 中,这六个 span 解引用操作最终都会触发边界检查:
; .NET 9
; Tests.Sum(System.ReadOnlySpan`1<Int32>)
push rbp
mov rbp,rsp
M01_L00:
cmp edx,2
jne short M01_L02
test edx,edx
je short M01_L04
mov eax,[rsi]
cmp edx,1
jbe short M01_L04
add eax,[rsi+4]
M01_L01:
pop rbp
ret
M01_L02:
cmp edx,3
ja short M01_L03
mov eax,edx
lea rcx,[783DA42091B8]
mov ecx,[rcx+rax*4]
lea rdi,[M01_L00]
add rcx,rdi
jmp rcx
M01_L03:
mov eax,0FFFFFFFF
pop rbp
ret
test edx,edx
je short M01_L04
mov eax,[rsi]
cmp edx,1
jbe short M01_L04
add eax,[rsi+4]
cmp edx,2
jbe short M01_L04
add eax,[rsi+8]
jmp short M01_L01
test edx,edx
je short M01_L04
mov eax,[rsi]
jmp short M01_L01
xor eax,eax
pop rbp
ret
M01_L04:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 103
在M01_L04标签下可见明显的边界检查标记(CORINFO_HELP_RNGCHKFAIL),且存在不少于六个跳转指令指向该标签——每个span[...]访问对应一次跳转。但在.NET 10中,我们看到:
; .NET 10
; Tests.Sum(System.ReadOnlySpan`1<Int32>)
push rbp
mov rbp,rsp
M01_L00:
cmp edx,2
jne short M01_L02
mov eax,[rsi]
add eax,[rsi+4]
M01_L01:
pop rbp
ret
M01_L02:
cmp edx,3
ja short M01_L03
mov eax,edx
lea rcx,[72C15C0F8FD8]
mov ecx,[rcx+rax*4]
lea rdx,[M01_L00]
add rcx,rdx
jmp rcx
M01_L03:
mov eax,0FFFFFFFF
pop rbp
ret
xor eax,eax
pop rbp
ret
mov eax,[rsi]
jmp short M01_L01
mov eax,[rsi]
add eax,[rsi+4]
add eax,[rsi+8]
jmp short M01_L01
; Total bytes of code 70
CORINFO_HELP_RNGCHKFAIL 标记及其所有跳转指令均已消失。
克隆§
即使无法静态证明每次访问都安全,JIT 仍可通过其他方式移除边界检查。请看以下方法:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private int[] _arr = new int[16];
[Benchmark]
public void Test()
{
int[] arr = _arr;
arr[0] = 2;
arr[1] = 3;
arr[2] = 5;
arr[3] = 8;
arr[4] = 13;
arr[5] = 21;
arr[6] = 34;
arr[7] = 55;
}
}
这是在 .NET 9 上生成的汇编代码:
; .NET 9
; Tests.Test()
push rax
mov rax,[rdi+8]
mov ecx,[rax+8]
test ecx,ecx
je short M00_L00
mov dword ptr [rax+10],2
cmp ecx,1
jbe short M00_L00
mov dword ptr [rax+14],3
cmp ecx,2
jbe short M00_L00
mov dword ptr [rax+18],5
cmp ecx,3
jbe short M00_L00
mov dword ptr [rax+1C],8
cmp ecx,4
jbe short M00_L00
mov dword ptr [rax+20],0D
cmp ecx,5
jbe short M00_L00
mov dword ptr [rax+24],15
cmp ecx,6
jbe short M00_L00
mov dword ptr [rax+28],22
cmp ecx,7
jbe short M00_L00
mov dword ptr [rax+2C],37
add rsp,8
ret
M00_L00:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 114
即使不精通汇编语言,其模式仍清晰可见:C#代码中对数组进行了八次写入操作,汇编代码中则重复了八次相同模式:cmp ecx,LENGTH用于比较数组长度与预设LENGTH值, jbe short M00_L00 在边界检查失败时跳转至辅助函数CORINFO_HELP_RNGCHKFAIL,以及mov dword ptr [rax+OFFSET],VALUE 将VALUE存储至偏移量OFFSET处的数组位置。在Test方法内部,JIT无法预知_arr的实际长度,因此必须包含边界检查。更关键的是,它必须完整保留所有边界检查(而非合并),因为优化过程中禁止引入行为变更。试想若将所有边界检查合并为单次检查,并生成如下等效代码:
if (arr.Length >= 8)
{
arr[0] = 2;
arr[1] = 3;
arr[2] = 5;
arr[3] = 8;
arr[4] = 13;
arr[5] = 21;
arr[6] = 34;
arr[7] = 55;
}
else
{
throw new IndexOutOfRangeException();
}
现在假设数组实际长度为四。原始程序会在抛出异常前将数组填充为[2, 3, 5, 8],但转换后的代码不会(数组不会被写入)。这属于可观察的行为变更。当然,有经验的开发者可以 选择 重写代码来规避部分检查,例如:
arr[7] = 55;
arr[0] = 2;
arr[1] = 3;
arr[2] = 5;
arr[3] = 8;
arr[4] = 13;
arr[5] = 21;
arr[6] = 34;
通过将最后一次存储移至开头,开发者为JIT提供了额外信息。JIT现在能判断: 若 首次存储成功,后续存储必然成功,因此JIT将仅生成单次边界检查。但需强调,这是开发者主动改变程序结构的行为,而JIT编译器本身不应进行此类修改。不过JIT编译器确实具备其他优化手段。假设JIT选择将方法重写为:
if (arr.Length >= 8)
{
arr[0] = 2;
arr[1] = 3;
arr[2] = 5;
arr[3] = 8;
arr[4] = 13;
arr[5] = 21;
arr[6] = 34;
arr[7] = 55;
}
else
{
arr[0] = 2;
arr[1] = 3;
arr[2] = 5;
arr[3] = 8;
arr[4] = 13;
arr[5] = 21;
arr[6] = 34;
arr[7] = 55;
}
从C#编程思维看,这种写法显得过于复杂——if和else代码块包含完全相同的C#语句。但结合我们对JIT利用已知长度信息省略边界检查的理解,这种写法开始显得合理。以下是.NET 9环境下JIT对此变体的生成代码:
; .NET 9
; Tests.Test()
push rbp
mov rbp,rsp
mov rax,[rdi+8]
mov ecx,[rax+8]
cmp ecx,8
jl short M00_L00
mov rcx,300000002
mov [rax+10],rcx
mov rcx,800000005
mov [rax+18],rcx
mov rcx,150000000D
mov [rax+20],rcx
mov rcx,3700000022
mov [rax+28],rcx
pop rbp
ret
M00_L00:
test ecx,ecx
je short M00_L01
mov dword ptr [rax+10],2
cmp ecx,1
jbe short M00_L01
mov dword ptr [rax+14],3
cmp ecx,2
jbe short M00_L01
mov dword ptr [rax+18],5
cmp ecx,3
jbe short M00_L01
mov dword ptr [rax+1C],8
cmp ecx,4
jbe short M00_L01
mov dword ptr [rax+20],0D
cmp ecx,5
jbe short M00_L01
mov dword ptr [rax+24],15
cmp ecx,6
jbe short M00_L01
mov dword ptr [rax+28],22
cmp ecx,7
jbe short M00_L01
mov dword ptr [rax+2C],37
pop rbp
ret
M00_L01:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 177
else代码块编译为M00_L00标签,其中包含之前看到的八个重复代码块。但if代码块(位于M00_L00标签上方)值得关注。该分支仅包含C#代码中初始的array.Length >= 8检查,编译为cmp ecx,8/jl short M00_L00指令对。该代码块其余部分仅包含mov指令(可见数组写入操作实际仅有四次而非八次——JIT将八次四字节写入优化为四次八字节写入)。在我们的重写版本中,我们手动克隆了代码,因此在绝大多数情况下(如果预料到数组写入会失败,我们最初就不会编写这些写入操作),我们只需执行单次长度检查,然后为极少数需要检查的情况准备了“希望永远用不上”的后备方案。当然,您不应(也不需要)进行此类手动克隆。但JIT会为您自动完成此操作。
“克隆”是JIT长期采用的优化策略,当它认为此举能显著优化常见场景时,就会复制此类代码(通常是循环)。如今在 .NET 10 中,得益于 dotnet/runtime#112595,该技术已能应用于此类连续写入操作。回到最初的基准测试,在 .NET 10 环境下我们得到如下结果:
; .NET 10
; Tests.Test()
push rbp
mov rbp,rsp
mov rax,[rdi+8]
mov ecx,[rax+8]
mov edx,ecx
cmp edx,7
jle short M00_L01
mov rdx,300000002
mov [rax+10],rdx
mov rcx,800000005
mov [rax+18],rcx
mov rcx,150000000D
mov [rax+20],rcx
mov rcx,3700000022
mov [rax+28],rcx
M00_L00:
pop rbp
ret
M00_L01:
test edx,edx
je short M00_L02
mov dword ptr [rax+10],2
cmp ecx,1
jbe short M00_L02
mov dword ptr [rax+14],3
cmp ecx,2
jbe short M00_L02
mov dword ptr [rax+18],5
cmp ecx,3
jbe short M00_L02
mov dword ptr [rax+1C],8
cmp ecx,4
jbe short M00_L02
mov dword ptr [rax+20],0D
cmp ecx,5
jbe short M00_L02
mov dword ptr [rax+24],15
cmp ecx,6
jbe short M00_L02
mov dword ptr [rax+28],22
cmp ecx,7
jbe short M00_L02
mov dword ptr [rax+2C],37
jmp short M00_L00
M00_L02:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 179
该结构与手动克隆结果几乎完全一致:JIT 编译器两次生成了相同代码,区别仅在于:一种情况省略了边界检查,另一种则包含所有边界检查,并通过单次长度检查决定执行路径。相当巧妙。
如前所述,JIT 编译器多年来一直执行克隆操作,尤其针对数组循环。然而,越来越多的代码开始使用span替代数组,遗憾的是这项宝贵的优化无法应用于span。如今通过dotnet/runtime#113575,这一问题已得到解决!我们可以通过一个基础循环示例来验证:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private int[] _arr = new int[16];
private int _count = 8;
[Benchmark]
public void WithSpan()
{
Span<int> span = _arr;
int count = _count;
for (int i = 0; i < count; i++)
{
span[i] = i;
}
}
[Benchmark]
public void WithArray()
{
int[] arr = _arr;
int count = _count;
for (int i = 0; i < count; i++)
{
arr[i] = i;
}
}
}
在WithArray和WithSpan中,我们都使用相同的循环,从0迭代到_count,而_count与_arr的长度之间存在未知关系,因此必须生成某种边界检查。以下是在.NET 9中针对WithSpan生成的代码:
; .NET 9
; Tests.WithSpan()
push rbp
mov rbp,rsp
mov rax,[rdi+8]
test rax,rax
je short M00_L03
lea rcx,[rax+10]
mov eax,[rax+8]
M00_L00:
mov edi,[rdi+10]
xor edx,edx
test edi,edi
jle short M00_L02
nop dword ptr [rax]
M00_L01:
cmp edx,eax
jae short M00_L04
mov [rcx+rdx*4],edx
inc edx
cmp edx,edi
jl short M00_L01
M00_L02:
pop rbp
ret
M00_L03:
xor ecx,ecx
xor eax,eax
jmp short M00_L00
M00_L04:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 59
此处存在一些前期汇编操作:将_array加载到span中,加载_count,并检查计数是否为0(若为0则跳过整个循环)。循环核心位于M00_L01指令,该指令反复将edx(存储i)与span长度(存储于eax)进行比较,若发生越界访问则跳转至CORINFO_HELP_RNGCHKFAIL,将edx (即i)写入区间的下一个位置,递增i,若i仍小于count(存储于edi),则跳回M00_L01继续迭代。换言之,每次迭代需执行两项检查:i是否仍在区间范围内,以及i是否小于count。现在来看.NET 9中WithArray的实现:
; .NET 9
; Tests.WithArray()
push rbp
mov rbp,rsp
mov rax,[rdi+8]
mov ecx,[rdi+10]
xor edx,edx
test ecx,ecx
jle short M00_L01
test rax,rax
je short M00_L02
cmp [rax+8],ecx
jl short M00_L02
nop dword ptr [rax+rax]
M00_L00:
mov edi,edx
mov [rax+rdi*4+10],edx
inc edx
cmp edx,ecx
jl short M00_L00
M00_L01:
pop rbp
ret
M00_L02:
cmp edx,[rax+8]
jae short M00_L03
mov edi,edx
mov [rax+rdi*4+10],edx
inc edx
cmp edx,ecx
jl short M00_L02
jmp short M00_L01
M00_L03:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 71
此处标签M00_L02与WithSpan中的循环结构高度相似,每次迭代均执行count校验和边界检查。但请注意M00_L00段落:这是相同循环的克隆版本,仍保留每次迭代通过cmp edx,ecx检查i与count的逻辑,却未见额外的边界检查。JIT 编译器克隆了循环,将其中一个特化为无边界检查版本,并在前置段通过单次数组长度检查(cmp [rax+8],ecx/jl short M00_L02)决定执行路径。现在在 .NET 10 中,WithSpan 的实现如下:
; .NET 10
; Tests.WithSpan()
push rbp
mov rbp,rsp
mov rax,[rdi+8]
test rax,rax
je short M00_L04
lea rcx,[rax+10]
mov eax,[rax+8]
M00_L00:
mov edx,[rdi+10]
xor edi,edi
test edx,edx
jle short M00_L02
cmp edx,eax
jg short M00_L03
M00_L01:
mov eax,edi
mov [rcx+rax*4],edi
inc edi
cmp edi,edx
jl short M00_L01
M00_L02:
pop rbp
ret
M00_L03:
cmp edi,eax
jae short M00_L05
mov esi,edi
mov [rcx+rsi*4],edi
inc edi
cmp edi,edx
jl short M00_L03
jmp short M00_L02
M00_L04:
xor ecx,ecx
xor eax,eax
jmp short M00_L00
M00_L05:
call CORINFO_HELP_RNGCHKFAIL
int 3
; Total bytes of code 75
与.NET 9中的WithArray类似,.NET 10的WithSpan同样采用循环克隆机制:M00_L03代码块在每次迭代中执行边界检查,而M00_L01代码块则省略每次迭代的边界检查。
在.NET 10中,JIT还获得了更强大的克隆能力。dotnet/runtime#110020、dotnet/runtime#108604 以及 dotnet/runtime#110483 使 JIT 能够克隆 try/finally 代码块,而此前遇到此类结构时会立即终止区域克隆。这看似小众的功能,实则价值非凡——当遍历枚举对象时,foreach循环通常会隐含执行try/finally结构,以便finally块调用枚举器的Dispose方法。
这些优化机制往往相互关联。动态PGO会触发特定形式的代码克隆,这属于前文提及的受保护的虚函数消解(GDV)机制:当仪器数据表明某个虚函数调用通常作用于特定类型的实例时,JIT可将生成的代码克隆为两条路径——一条专用于该类型,另一条处理任意类型。这使得特定类型代码路径能够对调用进行反虚拟化处理,并可能将其内联。若实现内联,则为JIT提供了更多机会来识别对象是否逃逸,从而可能将其堆栈分配。dotnet/runtime#111473, dotnet/runtime#116978, dotnet/runtime#116992、dotnet/runtime#117222、 以及 dotnet/runtime#117295 实现了该功能,通过增强逃逸分析来判断对象是否仅在生成的类型测试失败时才发生逃逸(即目标对象不符合预期的通用类型)。
在此需稍作停顿,因我此前描述远不足以彰显此功能的重大意义。dotnet/runtime仓库采用自动化性能分析系统,该系统会标记基准测试的显著提升或退化,并将这些变化追溯至对应的PR。此PR的分析结果如下:

通过简单示例即可理解其价值:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private int[] _values = Enumerable.Range(1, 100).ToArray();
[Benchmark]
public int Sum() => Sum(_values);
[MethodImpl(MethodImplOptions.NoInlining)]
private static int Sum(IEnumerable<int> values)
{
int sum = 0;
foreach (int value in values)
{
sum += value;
}
return sum;
}
}
借助动态PGO,Sum的仪器化代码会发现values通常是int[]类型,从而能在优化后的Sum实现中为该情况生成专用代码路径。借助条件逃逸分析能力,JIT 编译器能识别出常见路径中 GetEnumerator 生成的 IEnumerator<int> 永不逃逸。因此在所有相关方法被去虚化并内联后,枚举器可直接在栈上分配。
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| Sum | .NET 9.0 | 109.86 ns | 1.00 | 32 B | 1.00 |
| Sum | .NET 10.0 | 35.45 ns | 0.32 | – | 0.00 |
试想应用程序和服务的多少场景中存在此类集合枚举操作,便能理解这项改进为何如此令人振奋。需注意这些场景甚至未必需要性能优化(PGO)。例如以下情况:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private static readonly IEnumerable<int> s_values = new int[] { 1, 2, 3, 4, 5 };
[Benchmark]
public int Sum()
{
int sum = 0;
foreach (int value in s_values)
{
sum += value;
}
return sum;
}
}
在此处,JIT 编译器能够识别到:尽管 s_values 的类型声明为 IEnumerable<int>,但其实际始终是 int[] 数组。在这种情况下,dotnet/runtime#111948 允许在 JIT 中将返回类型重新类型化为 int[],从而使枚举器能够在栈上分配。
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| Sum | .NET 9.0 | 16.341 ns | 1.00 | 32 B | 1.00 |
| Sum | .NET 10.0 | 2.059 ns | 0.13 | – | 0.00 |
当然,过度克隆可能带来负面影响,尤其会增加代码体积。dotnet/runtime#108771 采用启发式算法判断可克隆循环是否应被克隆:循环规模越大,克隆概率越低。
内联§
“内联”通过用函数实现的副本替换函数调用,始终是至关重要的优化手段。人们常误以为内联的益处仅在于规避调用开销,虽然这确实有意义(尤其在考虑英特尔控制流强制技术等安全机制时,这类机制会略微增加调用成本),但内联的核心价值通常源于连锁效益。以简单示例说明:
int i = Divide(10, 5);
static int Divide(int n, int d) => n / d;
若Divide未被内联,则调用时需执行实际的idiv操作,这属于相对昂贵的操作。反之,若Divide被内联,调用点将变为:
int i = 10 / 5;
该表达式可在编译时评估,最终简化为:
int i = 2;
在逃逸分析和栈分配的讨论中已出现更具说服力的实例,这些特性高度依赖方法内联能力。鉴于内联的重要性日益提升,.NET 10对此进行了重点强化。
.NET 相关内联工作的部分目标在于支持更多类型的内联。历史上,方法中存在多种结构会直接导致该方法无法被纳入内联考虑范围。其中最著名的当属异常处理:包含异常处理语句(如 try/catch 或 try/finally)的方法无法内联。即使像本例中的 M 这样简单的方法:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private readonly object _o = new();
[Benchmark]
public int Test()
{
M(_o);
return 42;
}
private static void M(object o)
{
Monitor.Enter(o);
try
{
}
finally
{
Monitor.Exit(o);
}
}
}
在 .NET 9 中仍无法实现内联:
; .NET 9
; Tests.Test()
push rax
mov rdi,[rdi+8]
call qword ptr [78F199864EE8]; Tests.M(System.Object)
mov eax,2A
add rsp,8
ret
; Total bytes of code 21
但随着大量PR的提交,特别是dotnet/runtime#112968、dotnet/runtime#113023、 dotnet/runtime#113497 以及 dotnet/runtime#112998 ,包含try/finally的方法不再被阻止内联(try/catch区域仍具挑战性)。在.NET 10上运行相同基准测试时,我们现在得到以下程序集:
; .NET 10
; Tests.Test()
push rbp
push rbx
push rax
lea rbp,[rsp+10]
mov rbx,[rdi+8]
test rbx,rbx
je short M00_L03
mov rdi,rbx
call 00007920A0EE65E0
test eax,eax
je short M00_L02
M00_L00:
mov rdi,rbx
call 00007920A0EE6D50
test eax,eax
jne short M00_L04
M00_L01:
mov eax,2A
add rsp,8
pop rbx
pop rbp
ret
M00_L02:
mov rdi,rbx
call qword ptr [79202393C1F8]
jmp short M00_L00
M00_L03:
xor edi,edi
call qword ptr [79202393C1C8]
int 3
M00_L04:
mov edi,eax
mov rsi,rbx
call qword ptr [79202393C1E0]
jmp short M00_L01
; Total bytes of code 86
程序集的具体细节并不重要,但其规模远超以往,因为我们现在主要关注的是 M 的实现。除了支持内联 try/finally 方法外,异常处理方面也进行了其他改进。例如,dotnet/runtime#110273 和 dotnet/runtime#110464 允许移除 try/catch 和 try/fault 代码块,前提是能证明 try 代码块不可能抛出异常。请看以下示例:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "i")]
public partial class Tests
{
[Benchmark]
[Arguments(42)]
public int Test(int i)
{
try
{
i++;
}
catch
{
Console.WriteLine("Exception caught");
}
return i;
}
}
此处的try代码块无论执行何种操作都不会抛出异常(假设开发者未启用检查算术,否则可能抛出OverflowException),但在.NET 9中生成的程序集如下:
; .NET 9
; Tests.Test(Int32)
push rbp
sub rsp,10
lea rbp,[rsp+10]
mov [rbp-10],rsp
mov [rbp-4],esi
mov eax,[rbp-4]
inc eax
mov [rbp-4],eax
M00_L00:
mov eax,[rbp-4]
add rsp,10
pop rbp
ret
push rbp
sub rsp,10
mov rbp,[rdi]
mov [rsp],rbp
lea rbp,[rbp+10]
mov rdi,784B08950018
call qword ptr [784B0DE44EE8]
lea rax,[M00_L00]
add rsp,10
pop rbp
ret
; Total bytes of code 79
而在 .NET 10 中,JIT 编译器能够省略 catch 块并移除所有与 try 相关的仪式化代码,因为它能识别这些仪式化操作是毫无意义的开销。
; .NET 10
; Tests.Test(Int32)
lea eax,[rsi+1]
ret
; Total bytes of code 4
即使 try 块调用其他方法且这些方法被内联,使内容暴露于 JIT 分析之下,该特性依然成立。
(顺带一提, JIT 此前已能移除空 finally 的 try/finally 结构,但 dotnet/runtime#108003 在多数优化运行后再次检测空 finally 的情况,以防其他优化揭示出更多空代码块。)
另一个例子是“GVM”。此前,任何调用GVM(泛型虚方法,即带泛型参数的虚方法)的方法都会被禁止内联。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private Base _base = new();
[Benchmark]
public int Test()
{
M();
return 42;
}
private void M() => _base.M<object>();
}
class Base
{
public virtual void M<T>() { }
}
在.NET 9中,上述优化生成的程序集如下:
; .NET 9
; Tests.Test()
push rax
call qword ptr [728ED5664FD8]; Tests.M()
mov eax,2A
add rsp,8
ret
; Total bytes of code 17
而在 .NET 10 中,通过 dotnet/runtime#116773,M 现可被内联。
; .NET 10
; Tests.Test()
push rbp
push rbx
push rax
lea rbp,[rsp+10]
mov rbx,[rdi+8]
mov rdi,rbx
mov rsi,offset MT_Base
mov rdx,78034C95D2A0
call System.Runtime.CompilerServices.VirtualDispatchHelpers.VirtualFunctionPointer(System.Object, IntPtr, IntPtr)
mov rdi,rbx
call rax
mov eax,2A
add rsp,8
pop rbx
pop rbp
ret
; Total bytes of code 57
内联技术的另一个投资方向在于确定方法何时应被内联的启发式策略。盲目内联所有方法会带来负面影响:内联会复制代码,导致代码量增加,从而产生显著的负面效应。例如,内联增加的代码体积会给缓存带来更大压力。处理器配备指令缓存——这是CPU内存储近期使用指令的小型高速存储器,能实现指令的快速复用(例如循环迭代时或再次调用相同函数时)。假设存在方法M,且有100个调用点同时访问它。若所有调用点共享相同的M指令集(因为这100个调用点实际都在调用M),指令缓存只需加载一次M的指令集。若这100个调用点各自拥有M指令的独立副本,则所有副本都将通过缓存分别加载,彼此之间以及与其他指令争夺驻留空间。指令在缓存中的概率越低,CPU因等待从内存加载指令而停顿的可能性就越高。
因此,JIT需要谨慎选择内联对象。它竭力避免内联无法获益的代码(例如较大方法的指令不受调用上下文实质影响),同时全力内联能显著获益的代码(例如调用函数所需代码与函数内容体积相当的小型函数、指令可能受调用点信息实质影响的函数等)。作为启发式策略的一部分,JIT引入了“增强”机制:当观察到方法的某些行为时,会提升该方法被内联的概率。dotnet/runtime#114806 为返回固定长度小型数组的方法提供增强;若这些数组能在调用方帧内分配,JIT 便可能发现其不存在逃逸风险,从而启用栈分配机制。dotnet/runtime#110596 同样关注装箱操作,因为调用方可能完全避免装箱。
出于相同目的(同时为最小化编译耗时),JIT还会为方法编译中的内联操作设置预算上限——一旦达到预算上限,它可能停止任何内联操作。该预算机制总体运行良好,但在特定情况下可能在极不恰当的时机耗尽预算——例如在顶级调用点大量进行内联后,却在处理对性能至关重要的小型方法时预算已耗尽。为缓解此类场景,dotnet/runtime#114191 和 dotnet/runtime#118641 将 JIT 的默认内联预算提升至原来的两倍以上。
JIT 还高度关注其追踪的局部变量数量(例如 IL 中显式声明的参数/局部变量、JIT 创建的临时局部变量、提升的结构体字段等)。为避免过度创建,当追踪数量达到512时JIT会停止内联。但随着其他改动使内联策略更激进,这个(奇怪地硬编码的)限制值被触发的频率大幅增加,导致许多有价值的内联对象被排除在外。dotnet/runtime#118515 移除了该固定限制,转而将其与 JIT 可追踪局部变量总数的大比例绑定(默认情况下,这几乎使内联器的限制翻倍)。
常量折叠§
常量折叠是编译器在编译时而非运行时执行运算(通常是数学运算)的能力:当存在多个常量及其明确的关联关系时,编译器可将这些常量“折叠”为新常量。例如,当遇到 C# 代码 int M(int i) => i + 2 * 3; 时,C# 编译器会进行常量折叠,最终生成等效于 int M(int i) => i + 6; 的编译结果。JIT 同样支持常量折叠,尤其当其基于 C# 编译器无法获取的信息时更具价值。例如,JIT 能够将 static readonly 字段、IntPtr.Size 或 Vector128<T>.Count 视为常量。JIT 还能跨内联函数进行折叠。例如:
int M1(int i) => i + M2(2 * 3);
int M2(int j) => j * Environment.ProcessorCount;
C# 编译器仅能折叠 2 * 3,生成等效代码:
int M1(int i) => i + M2(6);
int M2(int j) => j * Environment.ProcessorCount;
但JIT在编译M1时,可将M2内联并视ProcessorCount为常量(本机值为16),最终生成如下M1汇编代码:
// dotnet run -c Release -f net9.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "i")]
public partial class Tests
{
[Benchmark]
[Arguments(42)]
public int M1(int i) => i + M2(6);
private int M2(int j) => j * Environment.ProcessorCount;
}
; .NET 9
; Tests.M1(Int32)
lea eax,[rsi+60]
ret
; Total bytes of code 4
这相当于M1的代码是public int M1(int i) => i + 96;(显示的汇编代码采用十六进制格式,因此60是十六进制0x60,即十进制96)。
或者考虑:
string M() => GetString() ?? throw new Exception();
static string GetString() => "test";
JIT 编译器能够内联 GetString 方法,此时它可识别结果非空,从而消除对 null 常量的检查,进而通过死代码消除法移除 throw 语句。常量折叠本身能避免冗余计算,更常为其他优化(如死代码消除和边界检查消除)创造条件。JIT 引擎在发现常量折叠机会方面已相当出色,并在 .NET 10 中进一步优化。请看这个基准测试:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "s")]
public partial class Tests
{
[Benchmark]
[Arguments("test")]
public ReadOnlySpan<char> Test(string s)
{
s ??= "";
return s.AsSpan();
}
}
以下是为 .NET 9 生成的汇编代码:
; .NET 9
; Tests.Test(System.String)
push rbp
mov rbp,rsp
mov rax,75B5D6200008
test rsi,rsi
cmove rsi,rax
test rsi,rsi
jne short M00_L01
xor eax,eax
xor edx,edx
M00_L00:
pop rbp
ret
M00_L01:
lea rax,[rsi+0C]
mov edx,[rsi+8]
jmp short M00_L00
; Total bytes of code 41
值得注意的是两个test rsi,rsi指令,它们是空值检查。程序首先将“”字符串常量的地址加载到rax寄存器,随后通过test rsi,rsi检查rsi寄存器中传递的s参数是否为空。若为空,cmove rsi,rax指令将其设置为“”字面量的地址。接着…它又执行了第二次test rsi,rsi?这个二次检测正是AsSpan开头的空值检查,其代码如下:
public static ReadOnlySpan<char> AsSpan(this string? text)
{
if (text is null) return default;
return new ReadOnlySpan<char>(ref text.GetRawStringData(), text.Length);
}
而dotnet/runtime#111985提案将使该次null检查(及其他类似检查)得以折叠,最终生成如下代码:
; .NET 10
; Tests.Test(System.String)
mov rax,7C01C4600008
test rsi,rsi
cmove rsi,rax
lea rax,[rsi+0C]
mov edx,[rsi+8]
ret
; Total bytes of code 25
类似效果还来自dotnet/runtime#108420,该提案同样能折叠另一类null检查。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "condition")]
public partial class Tests
{
[Benchmark]
[Arguments(true)]
public bool Test(bool condition)
{
string tmp = condition ? GetString1() : GetString2();
return tmp is not null;
}
private static string GetString1() => "Hello";
private static string GetString2() => "World";
}
在此基准测试中, 我们 可观察到GetString1和GetString2均不会返回null,因此is not null检查实属多余。.NET 9的JIT编译器无法识别此特性,但其升级后的.NET 10版本已具备该能力。
; .NET 9
; Tests.Test(Boolean)
mov rax,7407F000A018
mov rcx,7407F000A050
test sil,sil
cmove rax,rcx
test rax,rax
setne al
movzx eax,al
ret
; Total bytes of code 37
; .NET 10
; Tests.Test(Boolean)
mov eax,1
ret
; Total bytes of code 6
常量折叠同样适用于SIMD(单指令多数据)指令集,这类指令能同时处理多组数据而非逐个元素处理。dotnet/runtime#117099 与 dotnet/runtime#117572 两个提案均扩展了可参与折叠的SIMD比较运算范围。
代码布局§
当JIT编译器将C#编译器生成的中间语言转换为汇编代码时,会将代码组织为“基本块”——即具有单一入口点和出口点、内部无跳转、仅末端存在分支的指令序列。这些代码块可作为整体进行移动,其在内存中的排列顺序称为“代码布局”或“基本代码块布局”。该排序对性能影响显著,因为现代CPU高度依赖指令缓存和分支预测机制来维持高速运行。若高频执行的“热点”代码块紧密相邻且遵循共同执行路径,CPU可减少缓存未命中和跳转预测失误。反之,若布局不佳——热点代码被拆分至相距甚远的位置,或夹杂着低频执行的“冷代码”——CPU将耗费更多时间在跳转和缓存更新上而非实际运算。以百万次执行的紧凑循环为例: 良好的布局会将循环入口、主体及后向边(跳回主体开头执行下一次迭代的跳转指令)紧邻排列,使CPU能直接从缓存中获取数据。而在不良布局中,该循环可能与无关的冷代码块交织(例如循环内try语句的catch块),迫使CPU从不同位置加载指令,从而破坏执行流畅性。类似地,对于if代码块,高概率路径应直接指向下一代码块以避免跳转,而低概率分支则置于短跳转距离之后——这种布局更符合分支预测器的运作逻辑。代码布局启发式算法控制着上述过程,进而决定了最终生成的代码执行效率。
在确定代码块初始布局时(即布局优化前),dotnet/runtime#108903采用“循环感知逆后序遍历”算法。反后序遍历是一种遍历控制流图节点的算法,确保每个代码块出现在其前驱之后。“循环感知”特性意味着遍历会将循环识别为整体单元,在整个循环外创建代码块,并在布局算法调整时尽力保持循环结构完整。此处的设计意图是让大型布局优化从更合理的起点开始,从而减少后期重组操作,避免循环体被拆分的情况发生。
在极端情况下,布局本质上就是旅行推销员问题。即时编译器必须确定基本代码块的执行顺序,使控制转移遵循短而可预测的路径,从而高效利用指令缓存和分支预测机制。正如推销员试图以最小总行程访问所有城市,编译器也在努力排列代码块,使块与块之间的“距离”(可能以字节数、指令获取成本或其他类似指标衡量)最小化。对于任何具有实际意义的代码块集合,精确计算最优排序的成本都极其高昂——因为可能的排序数量会随代码块数量呈阶乘增长。因此JIT只能依赖近似算法而非追求精确解。当前采用的近似方案(见dotnet/runtime#103450并经dotnet/runtime#109741 和dotnet/runtime#109835中进一步调整)采用的“3-opt”策略,本质上是将所有代码块拆分为三组进行优化排序(仅需验证八种可能的排序方案)。JIT 编译器可选择循环遍历三块代码组,直至不再发现优化空间或达到自设限制。特别在处理后向跳转时,通过dotnet/runtime#110277,该机制将“3-opt”扩展为“4-opt”(四块)。
.NET 10 在将 PGO 数据融入布局方面也更胜一筹。借助动态 PGO,JIT 能够从初始编译中收集仪器数据,并利用这些剖析结果影响优化后的重新编译。这些数据可推断出哪些代码块属于热点或冷点,以及分支走向,所有这些信息都对布局优化至关重要。然而这些剖析数据有时存在缺失,因此JIT采用“剖析合成”算法对空白区域进行合理推测以填补缺口(若您看过《侏罗纪公园》,这相当于用现代青蛙DNA填补恐龙DNA序列缺失的操作)。通过dotnet/runtime#111915,配置文件数据修复现已移至布局阶段前执行,从而使布局获得更完整的数据图景。
让我们通过具体示例说明:我从MemoryExtensions.BinarySearch中提取核心函数:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.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 partial class Tests
{
private int[] _values = Enumerable.Range(0, 512).ToArray();
[Benchmark]
public int BinarySearch()
{
int[] values = _values;
return BinarySearch(ref values[0], values.Length, 256);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static int BinarySearch<T, TComparable>(
ref T spanStart, int length, TComparable comparable)
where TComparable : IComparable<T>, allows ref struct
{
int lo = 0;
int hi = length - 1;
while (lo <= hi)
{
int i = (int)(((uint)hi + (uint)lo) >> 1);
int c = comparable.CompareTo(Unsafe.Add(ref spanStart, i));
if (c == 0)
{
return i;
}
else if (c > 0)
{
lo = i + 1;
}
else
{
hi = i - 1;
}
}
return ~lo;
}
}
以下是.NET 9与.NET 10生成的程序集对比(后者为前者差异):
; Tests.BinarySearch[[System.Int32, System.Private.CoreLib],[System.Int32, System.Private.CoreLib]](Int32 ByRef, Int32, Int32)
push rbp
mov rbp,rsp
xor ecx,ecx
dec esi
js short M01_L07
+ jmp short M01_L03
M01_L00:
- lea eax,[rsi+rcx]
- shr eax,1
- movsxd r8,eax
- mov r8d,[rdi+r8*4]
- cmp edx,r8d
- jge short M01_L03
mov r9d,0FFFFFFFF
M01_L01:
test r9d,r9d
je short M01_L06
test r9d,r9d
jg short M01_L05
lea esi,[rax-1]
M01_L02:
cmp ecx,esi
- jle short M01_L00
- jmp short M01_L07
+ jg short M01_L07
M01_L03:
+ lea eax,[rsi+rcx]
+ shr eax,1
+ movsxd r8,eax
+ mov r8d,[rdi+r8*4]
cmp edx,r8d
- jg short M01_L04
- xor r9d,r9d
+ jl short M01_L00
+ cmp edx,r8d
+ jle short M01_L04
+ mov r9d,1
jmp short M01_L01
M01_L04:
- mov r9d,1
+ xor r9d,r9d
jmp short M01_L01
M01_L05:
lea ecx,[rax+1]
jmp short M01_L02
M01_L06:
pop rbp
ret
M01_L07:
mov eax,ecx
not eax
pop rbp
ret
; Total bytes of code 83
可见主要变化在于代码块的移动(M01_L00主体下移至M01_L03)。在 .NET 9 中,lo <= hi 的“循环内存检查”(cmp ecx,esi)采用向后条件分支(jle short M01_L00),除最后一次迭代外,每次循环都会跳回顶部(M01_L00)。而在 .NET 10 中,该逻辑改为正向分支:仅在较少出现的异常情况下跳出循环,其余常见情况则直接进入循环主体,随后无条件跳回。
GC 写屏障§
.NET垃圾回收器(GC)采用分代模型,根据对象存活时间对托管堆进行组织。新分配的内存分配在“第0代”(gen0),至少经历过一次回收的对象会被提升至“第1代”(gen1),而存在更久的对象则最终进入“第2代”(gen2)。这种设计基于两个前提:大多数对象具有临时性,且对象存在一段时间后,很可能还会继续存在较长时间。将堆划分为不同代次,使得清扫gen0对象时只需扫描gen0堆中指向该对象的剩余引用。预期所有或绝大多数指向gen0对象的引用都位于gen0代。当然,若指向gen0对象的引用意外进入gen1或gen2代,清扫gen0时未扫描gen1/gen2代将导致严重问题。为避免此类情况,JIT会协同GC追踪从老代到年轻代的引用关系。每当出现可能跨越代际的引用写入时,JIT会调用辅助函数将信息记录在“卡片表”中,当GC运行时便会查询该表以确定是否需要扫描高代区域。该辅助函数即为“GC写屏障”。由于写屏障可能在每次引用写入时触发,其必须具备极高的执行效率。为此运行时提供了多种变体方案,供JIT根据具体场景选择最优方案。当然,最理想的写屏障是根本无需存在的写屏障——正如边界检查机制,JIT同样投入资源尝试证明写屏障的冗余性,并在可行时予以消除。在.NET 10中,优化力度进一步提升。
运行时术语中称为“byref-like类型”的ref struct永远不会驻留在堆上,这意味着其内部的引用字段同样不会驻留堆区。因此,若JIT能证明某次引用写入操作的目标是ref struct的字段,即可省略写入屏障。请看以下示例:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private object _object = new();
[Benchmark]
public MyRefStruct Test() => new MyRefStruct() { Obj1 = _object, Obj2 = _object, Obj3 = _object };
public ref struct MyRefStruct
{
public object Obj1;
public object Obj2;
public object Obj3;
}
}
在.NET 9程序集中,我们可看到三个写屏障(CORINFO_HELP_CHECKED_ASSIGN_REF),对应基准测试中MyRefStruct的三个字段:
; .NET 9
; Tests.Test()
push r15
push r14
push rbx
mov rbx,rsi
mov r15,[rdi+8]
mov rsi,r15
mov r14,r15
mov rdi,rbx
call CORINFO_HELP_CHECKED_ASSIGN_REF
lea rdi,[rbx+8]
mov rsi,r14
call CORINFO_HELP_CHECKED_ASSIGN_REF
lea rdi,[rbx+10]
mov rsi,r15
call CORINFO_HELP_CHECKED_ASSIGN_REF
mov rax,rbx
pop rbx
pop r14
pop r15
ret
; Total bytes of code 59
通过dotnet/runtime#111576 和 dotnet/runtime#111733 实现后,.NET 10 中所有写屏障均被省略:
; .NET 10
; Tests.Test()
mov rax,[rdi+8]
mov rcx,rax
mov rdx,rax
mov [rsi],rcx
mov [rsi+8],rdx
mov [rsi+10],rax
mov rax,rsi
ret
; Total bytes of code 25
然而更具影响力的变更在于dotnet/runtime#112060和dotnet/runtime#112227 更具影响力的改动,它们涉及“返回缓冲区”机制。当.NET方法声明为返回值时,运行时需决定该值如何从被调用方传递回调用方。对于整数、浮点数、指针或对象引用等小型类型,答案很简单:值可通过预留给返回值的一个或多个CPU寄存器传递,使操作基本不耗费资源。但并非所有值都能完美适配寄存器。对于大型值类型(如含多个字段的结构体),则需采用不同策略。此时调用方会在栈帧中分配一块内存区域作为“返回缓冲区”,并将该缓冲区的指针作为隐式参数传递给方法。方法执行时直接将返回值写入该缓冲区,从而向调用方提供数据。关于写屏障的挑战在于:历史上并未强制要求返回缓冲区位于栈上,技术上它完全可能分配在堆上(尽管这种情况极为罕见)。由于被调用方无法知晓缓冲区位置,任何对象引用的写操作都需要通过GC写屏障进行追踪。通过简单基准测试即可验证:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private string _firstName = "Jane", _lastName = "Smith", _address = "123 Main St", _city = "Anytown";
[Benchmark]
public Person GetPerson() => new(_firstName, _lastName, _address, _city);
public record struct Person(string FirstName, string LastName, string Address, string City);
}
在 .NET 9 中,返回值类型每个字段都会触发 CORINFO_HELP_CHECKED_ASSIGN_REF 写屏障:
; .NET 9
; Tests.GetPerson()
push r15
push r14
push r13
push rbx
mov rbx,rsi
mov rsi,[rdi+8]
mov r15,[rdi+10]
mov r14,[rdi+18]
mov r13,[rdi+20]
mov rdi,rbx
call CORINFO_HELP_CHECKED_ASSIGN_REF
lea rdi,[rbx+8]
mov rsi,r15
call CORINFO_HELP_CHECKED_ASSIGN_REF
lea rdi,[rbx+10]
mov rsi,r14
call CORINFO_HELP_CHECKED_ASSIGN_REF
lea rdi,[rbx+18]
mov rsi,r13
call CORINFO_HELP_CHECKED_ASSIGN_REF
mov rax,rbx
pop rbx
pop r13
pop r14
pop r15
ret
; Total bytes of code 81
而在 .NET 10 中,调用约定已更新为要求返回缓冲区驻留在栈上(若调用方需将数据存放于其他位置,则需自行负责后续复制操作)。由于返回缓冲区现已保证位于栈上,JIT 编译器可省略所有作为返回值处理的 GC 写屏障。
; .NET 10
; Tests.GetPerson()
mov rax,[rdi+8]
mov rcx,[rdi+10]
mov rdx,[rdi+18]
mov rdi,[rdi+20]
mov [rsi],rax
mov [rsi+8],rcx
mov [rsi+10],rdx
mov [rsi+18],rdi
mov rax,rsi
ret
; Total bytes of code 35
dotnet/runtime#111636 来自 @a74nh 的提案在性能层面同样值得关注,因为正如优化中常见的情况,它实现了某种权衡取舍。在此变更之前,Arm64架构为所有GC模式提供统一的写屏障辅助函数。本次变更使Arm64与x64架构保持一致,通过WriteBarrierManager进行路由,该管理器会根据运行时配置在多个JIT_WriteBarrier变体中进行选择。此举通过增加区域检查并采用区域感知卡标记方案,使每个Arm64写屏障的开销略有增加,但相应地减轻了GC的工作量:卡表中被标记的卡减少,GC可进行更精确的扫描。dotnet/runtime#106191 通过优化热路径比较并消除部分可避免的保存/恢复操作,进一步降低了 Arm64 平台写屏障的开销。
指令集§
.NET 在所有支持架构上持续实现显著优化与改进,同时推出多项架构专属增强。以下列举若干示例:
Arm SVE
Arm SVE 接口于 .NET 9 引入。正如去年博文Arm SVE章节所述,启用 SVE 是历时数年的工程,在 .NET 10 中仍处于实验性支持阶段。但该功能持续获得增强与扩展,例如由@snickolls-arm提交的PRdotnet/runtime#115775新增了BitwiseSelect方法,以及@jacob-crawley提交的PRdotnet/runtime# 117711 来自 @jacob-crawley 添加了 MaxPairwise 和 MinPairwise 方法,以及 dotnet/runtime#117051 来自 @jonathandavies-arm,新增 VectorTableLookup 方法。
Arm64
dotnet/runtime#111893 来自 @jonathandavies-arm,dotnet/runtime#111904 来自 @jonathandavies-arm,dotnet/runtime#111452 来自 @jonathandavies-arm,dotnet/runtime#112235 来自 @jonathandavies-arm,以及 dotnet/runtime#111797 来自 @snickolls-arm,以及 [dotnet/runtime#111797] 例如在实现比较分支时,JIT编译器现在可直接生成cbz(“比较零分支”)指令,而非先生成cmp与0的比较指令再接beq指令。
APX
英特尔于2023年发布的先进性能扩展(APX)是x86/x64指令集的扩展。该扩展将通用寄存器数量从16个增至32个,并新增了条件运算等指令,旨在减少内存访问、提升性能并降低功耗。dotnet/runtime#106557 来自 @Ruihan-Yin, dotnet/runtime#108796 来自 @Ruihan-Yin,以及 dotnet/runtime#113237 来自 @Ruihan-Yin,这些更新本质上教会了JIT如何使用新的汇编语言方言(REX和扩展EVEX编码),而dotnet/runtime#108799 来自 @DeepakRajendrakumaran,更新了 JIT 以支持扩展寄存器集;而 dotnet/runtime#116035 来自 @DeepakRajendrakumaran,启用了用于操作此类寄存器的新推入和弹出指令。APX中最具影响力的指令围绕条件比较(ccmp)展开——该概念在JIT支持其他指令集时已存在。由@anthonycanino提交的dotnet/runtime#111072 dotnet/runtime#112153 来自 @anthonycanino,以及 dotnet/runtime#116445 来自 @khushal1996,这些都教会了JIT如何充分利用APX中的新指令。
AVX512
.NET 8 增加了对 AVX512 的广泛支持,而 .NET 9 则在核心库中显著提升了其处理能力和应用范围。.NET 10 包含大量额外的相关优化:
- dotnet/runtime#109258 来自 @saucecontrol 以及 dotnet/runtime#109267 来自 @saucecontrol,扩展了即时编译器 (JIT) 可使用 EVEX 嵌入式广播的位置数量。该功能允许向量指令从内存中读取单个标量元素,并将其隐式复制到向量的所有通道,而无需单独的广播或洗牌操作。
- dotnet/runtime#108824 移除了广播操作中的冗余符号扩展。
- dotnet/runtime#116117 由 @alexcovington 提交,改进在支持 AVX512 时为
Vector.Max和Vector.Min生成的代码。 - dotnet/runtime#109474 由 @saucecontrol 提交,针对 AVX512 宽化内置函数优化了指令“包含性”(即当指令行为被另一指令完全封装时可被消除) (即指令行为可被另一指令完全封装从而消除)机制,适用于AVX512扩展内置函数(类似的包含性改进已通过@saucecontrol提交的dotnet/runtime#110736实现,以及@saucecontrol提交的dotnet/runtime#111778实现)。(https://github.com/saucecontrol) 及 dotnet/runtime#111778 中的 @saucecontrol 均涉及此类改进)。
- dotnet/runtime#111853 来自 @saucecontrol,改进
Vector128/256/512.Dot以更好地加速 AVX512。 - dotnet/runtime#110195、 dotnet/runtime#110307 以及 dotnet/runtime#117118 共同优化了向量掩码的处理机制。在 AVX512 中,掩码是特殊寄存器,可作为各类指令的组成部分来控制应使用向量元素的子集(掩码中的每个位对应向量中的一个元素)。这使得无需额外分支或洗牌操作即可对向量的一部分进行操作。
- dotnet/runtime#115981 改进了 AVX512 环境下的清零操作(JIT 通常在初始化栈帧时生成清零指令)。在用64字节指令尽可能清零后,原先会降级使用16字节指令,而实际可使用32字节指令。
- dotnet/runtime#110662 通过启用EVEX掩码支持,优化了
ExtractMostSignificantBits函数的代码生成(该函数被核心库中众多搜索算法调用),尤其在处理short、ushort(及char类型——因多数核心库实现会将强制转换的char重新解释为上述类型之一)时。 - dotnet/runtime#113864 由 @saucecontrol 提交,改进了未使用掩码寄存器时
ConditionalSelect的代码生成。
AVX10.2
.NET 9 增加了对 AVX10.1 指令集的支持及内置函数。通过@khushal1996提交的dotnet/runtime#111209,.NET 10新增了对AVX10.2指令集的支持及内置函数。@khushal1996 提交的 dotnet/runtime#112535 使用 AVX10.2 指令优化了浮点数最小值/最大值运算,而 @khushal1996 提交的 dotnet/runtime#111775 则优化了 AVX10.2 指令集的浮点数运算。111775 来自 @khushal1996,则启用了浮点数转换对 AVX10.2 的利用。
GFNI
@saucecontrol提交的dotnet/runtime#109537为GFNI(伽罗瓦域新指令集)添加内置函数,可用于加速伽罗瓦域GF (2^8) 上的运算加速。此类运算常见于密码学、错误校正和数据编码领域。
VPCLMULQDQ
VPCLMULQDQ 是 x86 指令集扩展,为旧版 PCLMULQDQ 指令新增向量支持,该指令可执行 64 位整数的无进位乘法运算。dotnet/runtime#109137 由 @saucecontrol 提交,为 VPCLMULQDQ 添加了新的内置 API。
其他更新§
本次发布中,除已提及的PR外,还有大量PR被合并至JIT模块。以下是部分新增内容:
- 消除部分协变性检查。向引用类型的数组写入数据时可能需要进行“协变性检查”。假设存在基类
Base及其两个派生类型Derived1 : Base和Derived2 : Base。由于.NET中的数组具有协变性,我可以创建Derived1[]数组并成功将其转换为Base[],但底层数据仍为Derived1[]。这意味着,例如尝试将Derived2存储到该数组中,即使编译通过,运行时仍会失败。为实现此目标,JIT 需要在写入数组时插入协变性检查,但与边界检查和写屏障类似,当 JIT 能静态证明这些检查不必要时,即可省略它们。密封类型便是典型示例。当JIT遇到类型为T[]的数组且已知T为密封类型时,T[]必然精确对应T[]而非DerivedT[]——因为根本不存在DerivedT。因此在如下基准测试中:// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0 using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args); [DisassemblyDiagnoser] [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")] public partial class Tests { private List<string> _list = new() { "hello" }; [Benchmark] public void Set() => _list[0] = "world"; }只要JIT能识别
List<string>底层数组为string[](string为密封类型),便无需进行协变性检查。在.NET 9中,我们得到如下结果:; .NET 9 ; Tests.Set() push rbx mov rbx,[rdi+8] cmp dword ptr [rbx+10],0 je short M00_L00 mov rdi,[rbx+8] xor esi,esi mov rdx,78914920A038 call System.Runtime.CompilerServices.CastHelpers.StelemRef(System.Object[], IntPtr, System.Object) inc dword ptr [rbx+14] pop rbx ret M00_L00: call qword ptr [78D1F80558A8] int 3 ; Total bytes of code 44注意
CastHelpers.StelemRef调用…该辅助函数正是执行协变性检查写入操作的实现。但在 .NET 10 中,得益于 dotnet/runtime#107116(该提案教会 JIT 如何解析封闭泛型字段的精确类型),我们得到:; .NET 10 ; Tests.Set() push rbp mov rbp,rsp mov rax,[rdi+8] cmp dword ptr [rax+10],0 je short M00_L00 mov rcx,[rax+8] mov edx,[rcx+8] test rdx,rdx je short M00_L01 mov rdx,75E2B9009A40 mov [rcx+10],rdx inc dword ptr [rax+14] pop rbp ret M00_L00: call qword ptr [762368116760] int 3 M00_L01: call CORINFO_HELP_RNGCHKFAIL int 3 ; Total bytes of code 58无需协变性检查,非常感谢。
- 更强的强度降低。“强度降低”是经典编译器优化技术,通过用加法等低成本操作替代乘法等高成本操作。在.NET 9中,该技术被用于将使用乘法偏移量(如
index * elementSize)的索引循环,转换为仅递增指针式偏移量(如offset += elementSize)的循环,从而削减算术开销并提升性能。在 .NET 10 中,强度缩减技术得到扩展,尤其体现在 dotnet/runtime#110222 提案中。该机制使 JIT 能够检测具有不同步长的多重循环归纳变量,并通过利用它们的最大公约数 (GCD) 进行强度缩减。其核心机制是:根据不同步长值的最大公约数创建单一主递归变量,再通过适当缩放恢复原始递归变量。以下示例说明该机制:// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0 using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args); [DisassemblyDiagnoser] [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "numbers")] public partial class Tests { [Benchmark] [Arguments("128514801826028643102849196099776734920914944609068831724328541639470403818631040")] public int[] Parse(string numbers) { int[] results = new int[numbers.Length]; for (int i = 0; i < numbers.Length; i++) { results[i] = numbers[i] - '0'; } return results; } }在此基准测试中,我们遍历输入字符串(由2字节字符元素组成),并将结果存储到4字节整型数组中。 .NET 9程序集的核心循环如下:
; .NET 9 M00_L00: mov edx,ecx movzx edi,word ptr [rbx+rdx*2+0C] add edi,0FFFFFFD0 mov [rax+rdx*4+10],edi inc ecx cmp r15d,ecx jg short M00_L00其中
movzx edi,word ptr [rbx+rdx *2+0C]用于读取numbers[i],而mov [rax+rdx* 4+10],edi则用于向results[i]赋值。此处的rdx即为i,因此每次赋值都需执行i * 2来计算索引i处char的字节偏移量,同样需执行i * 4来计算偏移量i处int的字节偏移量。以下是.NET 10程序集:; .NET 10 M00_L00: movzx edx,word ptr [rbx+rcx+0C] add edx,0FFFFFFD0 mov [rax+rcx*2+10],edx add rcx,2 dec r15d jne short M00_L00numbers[i]读取操作中的乘法被移除。取而代之的是每次迭代仅将rcx加2,将其视为第i个char的偏移量;计算int偏移量时也不再乘以4,而是直接乘以2。 - CSE 与 SSA 的集成。与大多数编译器类似,JIT 采用公共子表达式消除(CSE)技术来识别重复计算并避免冗余运算。dotnet/runtime#106637通过将CSE与静态单赋值(SSA)表示更深度融合,使JIT能以更一致的方式执行此操作。这进而触发更多优化机制,例如.NET 9中针对循环归纳变量的强度降低优化未能充分发挥作用,如今该问题已得到解决。
return someCondition ? true : false。相同逻辑常有多种表达方式,但编译器优化过程中往往只识别特定模式,而忽略等效的其他表达。因此编译器需先将所有表达统一规范为更易识别的形式。其中return someCondition的情况尤为典型:由于 JIT 编译器的内部表示机制,等效的return someCondition ? true : false更易于优化。dotnet/runtime#107499 便规范化为后者。以下基准测试可说明此现象:// dotnet run -c Release -f net9.0 --filter "*" using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args); [DisassemblyDiagnoser] [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "i")] public partial class Tests { [Benchmark] [Arguments(42)] public bool Test1(int i) { if (i > 10 && i < 20) return true; return false; } [Benchmark] [Arguments(42)] public bool Test2(int i) => i > 10 && i < 20; }在 .NET 9 环境下,
Test1的汇编代码如下:; .NET 9 ; Tests.Test1(Int32) sub esi,0B cmp esi,8 setbe al movzx eax,al ret ; Total bytes of code 13JIT 成功识别出可将两个比较操作转换为减法运算和单次比较,如同将
i > 10 && i < 20重写为(uint)(i - 11) <= 8。但对于Test2,.NET 9生成的代码如下:; .NET 9 ; Tests.Test2(Int32) xor eax,eax cmp esi,14 setl cl movzx ecx,cl cmp esi,0A cmovg eax,ecx ret ; Total bytes of code 18由于JIT内部对返回条件的表示方式,该优化未能实现,汇编代码更直接地反映了C#代码的原始逻辑。但在.NET 10中,由于规范化处理,
Test2现在与Test1获得完全相同的优化结果:; .NET 10 ; Tests.Test2(Int32) sub esi,0B cmp esi,8 setbe al movzx eax,al ret ; Total bytes of code 13- 位运算测试。C# 编译器在生成
switch和is表达式时具有高度灵活性。以c is ‘ ’ or ‘\t’ or ‘\r’ or ‘\n’为例,它可能生成等效的级联if/else分支、ILswitch指令、位运算测试,或上述形式的组合。然而C#编译器并不具备JIT编译器拥有的全部信息,例如进程是32位还是64位,或特定硬件上指令的执行成本。通过dotnet/runtime#107831,JIT现在能识别更多可实现为位运算的表达式,并据此生成代码。// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.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", "c")] public partial class Tests { [Benchmark] [Arguments('s')] public void Test(char c) { if (c is ' ' or '\t' or '\r' or '\n' or '.') { Handle(c); } [MethodImpl(MethodImplOptions.NoInlining)] static void Handle(char c) { } } }Method Runtime Mean Ratio Code Size Test .NET 9.0 0.4537 ns 1.02 58 B Test .NET 10.0 0.1304 ns 0.29 44 B C# 中常见基于移位值实现的位测试:先创建一个在不同索引位设置的常量掩码,再通过将待检值的位移至对应索引位,判断其是否与掩码位匹配。例如,
Regex类库通过以下方式检测给定的UnicodeCategory是否属于构成“单词”类别的字符集(\\w):// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0 using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; using System.Globalization; BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args); [DisassemblyDiagnoser] [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "uc")] public partial class Tests { [Benchmark] [Arguments(UnicodeCategory.DashPunctuation)] public bool Test(UnicodeCategory uc) => (WordCategoriesMask & (1 << (int)uc)) != 0; private const int WordCategoriesMask = 1 << (int)UnicodeCategory.UppercaseLetter | 1 << (int)UnicodeCategory.LowercaseLetter | 1 << (int)UnicodeCategory.TitlecaseLetter | 1 << (int)UnicodeCategory.ModifierLetter | 1 << (int)UnicodeCategory.OtherLetter | 1 << (int)UnicodeCategory.NonSpacingMark | 1 << (int)UnicodeCategory.DecimalDigitNumber | 1 << (int)UnicodeCategory.ConnectorPunctuation; }此前JIT会直接生成与代码逻辑一致的指令:先执行移位操作,再进行位测试。如今通过@varelen提交的dotnet/runtime#111979,它能以位测试形式生成代码。
; .NET 9 ; Tests.Test(System.Globalization.UnicodeCategory) mov eax,1 shlx eax,eax,esi test eax,4013F setne al movzx eax,al ret ; Total bytes of code 22 ; .NET 10 ; Tests.Test(System.Globalization.UnicodeCategory) mov eax,4013F bt eax,esi setb al movzx eax,al ret ; Total bytes of code 15 - 冗余符号扩展。通过dotnet/runtime#111305,JIT现在能移除更多冗余的符号扩展(当将较小类型如
int转换为较大类型如long时,同时保留值的符号)。例如在测试代码public ulong Test(int x) => (uint)x < 10 ? (ulong)x << 60 : 0中,JIT 现可使用mov指令(仅复制位)替代movsxd(带符号扩展的移位),因为它通过首次比较得知移位操作仅在x为非负值时执行。 - BMI2 指令集的改进除法。若 BMI2 指令集可用(通过 [dotnet/runtime#116198] 引入 @Daniel-Svensson 的
movsxd替代方案),JIT 现可使用 BMI2 指令集实现更高效的除法运算。 来自 @Daniel-Svensson,JIT 现可使用mulx指令(“不影响标志位的无符号乘法”)实现整数除法,例如:// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0 using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args); [DisassemblyDiagnoser] [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "value")] public partial class Tests { [Benchmark] [Arguments(12345)] public ulong Div10(ulong value) => value / 10; }执行结果:
; .NET 9 ; Tests.Div10(UInt64) mov rdx,0CCCCCCCCCCCCCCCD mov rax,rsi mul rdx mov rax,rdx shr rax,3 ret ; Total bytes of code 24 ; .NET 10 ; Tests.Div10(UInt64) mov rdx,0CCCCCCCCCCCCCCCD mulx rax,rax,rsi shr rax,3 ret ; Total bytes of code 20 - 改进范围比较。当比较
ulong表达式与uint.MaxValue时,不再直接生成比较指令,而是通过 dotnet/runtime#113037 由 @shunkino 提出的方案,更高效地以移位操作处理。// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0 using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args); [DisassemblyDiagnoser] [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "x")] public partial class Tests { [Benchmark] [Arguments(12345)] public bool Test(ulong x) => x <= uint.MaxValue; }最终生成:
; .NET 9 ; Tests.Test(UInt64) mov eax,0FFFFFFFF cmp rsi,rax setbe al movzx eax,al ret ; Total bytes of code 15 ; .NET 10 ; Tests.Test(UInt64) shr rsi,20 sete al movzx eax,al ret ; Total bytes of code 11 - 更优的死分支消除。JIT分支优化器已能利用比较结果的推论静态确定其他分支的执行结果。例如以下代码:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0 using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args); [DisassemblyDiagnoser] [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "x")] public partial class Tests { [Benchmark] [Arguments(42)] public void Test(int x) { if (x > 100) { if (x > 10) { Console.WriteLine(); } } } }在.NET 9中JIT会生成:
; .NET 9 ; Tests.Test(Int32) cmp esi,64 jg short M00_L00 ret M00_L00: jmp qword ptr [7766D3E64FA8] ; Total bytes of code 12注意仅保留了与100(0x64)的单次比较,而与10的比较被省略(因前次比较已隐含该结果)。但实际情况存在多种变体,且并非所有情况都能得到同等优化。考虑以下示例:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0 using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args); [DisassemblyDiagnoser] [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "x")] public partial class Tests { [Benchmark] [Arguments(42)] public void Test(int x) { if (x < 16) return; if (x < 8) Console.WriteLine(); } }此处理想情况下,
Console.WriteLine根本不应出现在生成的程序集代码中,因为它永远无法被调用。遗憾的是,在 .NET 9 中我们得到如下结果(此处的jmp指令是调用WriteLine的尾调用):; .NET 9 ; Tests.Test(Int32) push rbp mov rbp,rsp cmp esi,10 jl short M00_L00 cmp esi,8 jge short M00_L00 pop rbp jmp qword ptr [731ED8054FA8] M00_L00: pop rbp ret ; Total bytes of code 23而.NET 10通过dotnet/runtime#111766成功识别:当执行到
x < 8时,该条件始终为false,可被消除。一旦该条件被消除,初始分支也变得多余。因此整个方法可简化为:; .NET 10 ; Tests.Test(Int32) ret ; Total bytes of code 1 - 改进浮点数转换。dotnet/runtime#114410 来自 @saucecontrol,dotnet/runtime#114597 来自 @saucecontrol,以及 dotnet/runtime#111595 来自 @saucecontrol 均加速了浮点数与整数之间的转换,例如在可用时使用
vcvtusi2s,或在不可用时避免中间double转换。// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0 using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args); [DisassemblyDiagnoser] [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "i")] public partial class Tests { [Benchmark] [Arguments(42)] public float Compute(uint i) => i; }; .NET 9 ; Tests.Compute(UInt32) mov eax,esi vxorps xmm0,xmm0,xmm0 vcvtsi2sd xmm0,xmm0,rax vcvtsd2ss xmm0,xmm0,xmm0 ret ; Total bytes of code 16 ; .NET 10 ; Tests.Compute(UInt32) vxorps xmm0,xmm0,xmm0 vcvtusi2ss xmm0,xmm0,esi ret ; Total bytes of code 11 - 展开处理。当使用
CopyTo(或其他基于“memmove”的操作)处理常量源时,dotnet/runtime#108576 通过避免冗余内存加载来降低开销。dotnet/runtime#109036 在 Arm64 架构上为Equals/StartsWith/EndsWith解除了更多展开限制。而dotnet/runtime#110893启用了非零填充展开(零填充已实现展开)。// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0 using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args); [DisassemblyDiagnoser] [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")] public partial class Tests { private char[] _chars = new char[100]; [Benchmark] public void Fill() => _chars.AsSpan(0, 16).Fill('x'); }; .NET 9 ; Tests.Fill() push rbp mov rbp,rsp mov rdi,[rdi+8] test rdi,rdi je short M00_L00 cmp dword ptr [rdi+8],10 jb short M00_L00 add rdi,10 mov esi,10 mov edx,78 call qword ptr [7F3093FBF1F8]; System.SpanHelpers.Fill[[System.Char, System.Private.CoreLib]](Char ByRef, UIntPtr, Char) nop pop rbp ret M00_L00: call qword ptr [7F3093787810] int 3 ; Total bytes of code 49 ; .NET 10 ; Tests.Fill() push rbp mov rbp,rsp mov rax,[rdi+8] test rax,rax je short M00_L00 cmp dword ptr [rax+8],10 jl short M00_L00 add rax,10 vbroadcastss ymm0,dword ptr [78EFC70C9340] vmovups [rax],ymm0 vzeroupper pop rbp ret M00_L00: call qword ptr [78EFC7447B88] int 3 ; Total bytes of code 48注意 .NET 9 程序集中的
SpanHelpers.Fill调用,而 .NET 10 程序集中不存在该调用。
本机 AOT§
本机AOT指.NET应用程序在构建时直接编译为程序集代码的能力。JIT仍用于代码生成,但仅限于构建阶段;JIT完全不包含在发布应用中,运行时也不进行代码生成。因此,本文前文讨论的大部分JIT优化以及后续内容中的优化同样适用于本机AOT。然而原生AOT也带来独特机遇与挑战。
原生AOT工具链的超级能力在于:能在编译时解释(部分)代码,并直接使用执行结果替代运行时操作。这在静态构造函数中尤为重要:构造函数代码可被解释执行以初始化各类static readonly字段,随后这些字段的内容将被持久化到生成的程序集;运行时只需从程序集中复原这些内容,无需重新计算。该特性还可能使更多代码具备冗余性与可移除性——例如当静态构造函数及其专属引用对象不再需要时。当然,若允许任意代码在构建时执行将极具风险,因此系统采用严格过滤的白名单机制,并针对最常见且适用的构造提供专项支持。dotnet/runtime#107575 扩展了这种“预初始化”能力,支持源自数组的跨度,确保使用.AsSpan()等方法时不会导致预初始化失败。dotnet/runtime#114374 同样优化了预初始化机制,放宽了以下操作的限制:访问其他类型的静态字段、调用具有独立静态构造函数的其他类型方法,以及解引用指针。
反之,原生AOT编译存在独特挑战,其核心问题在于代码体积至关重要且更难控制。借助运行时JIT技术,可将代码生成推迟至运行时,仅生成确切所需的代码。而原生AOT要求 所有 程序集代码生成必须在构建时完成,这意味着原生AOT工具链需要竭力确定最小代码生成量,以支持应用程序运行时可能执行的全部操作。每个版本中针对原生AOT的大部分工作,最终都聚焦于进一步缩减生成的代码体积。例如:
- dotnet/runtime#117411 实现了相同方法泛型实例化主体的折叠功能,通过尽可能复用相同方法的代码来避免冗余。
- dotnet/runtime#117080 同样优化了现有的方法体去重逻辑。
- dotnet/runtime#117345(由@huoyaoyuan提交)调整了反射机制中的部分代码,此前该机制会人为强制保留所有集合类型所有泛型实例化枚举器的代码。
- dotnet/runtime#112782 将非泛型方法中已存在的
MethodTable区分机制(“该方法表是否对用户代码可见”)扩展至泛型方法,从而允许对不可见方法的更多元数据进行优化移除。 - dotnet/runtime#118718 和 dotnet/runtime#118832 实现了与装箱枚举相关的大小缩减。前者通过调整
Thread、GC和CultureInfo中的若干方法,避免对某些枚举类型进行装箱操作,从而省去生成相关代码的步骤。后者则优化了RuntimeHelpers.CreateSpan的实现,该方法被C#编译器用于通过集合表达式等构造创建跨度。CreateSpan作为泛型方法,原生AOT工具链的全程序分析会将其泛型参数视为“需反射处理”,这意味着编译器必须假设任何类型参数都将通过反射使用,从而需要保留相关元数据。当涉及枚举时,编译器需确保保留装箱枚举的支持机制,而System.Console正是这类枚举的典型用例。这导致简单的“hello, world”控制台应用无法移除装箱枚举的反射支持——如今这一限制已被解除。
虚拟机§
.NET 运行时为托管应用程序提供广泛服务,最显著的是垃圾回收器和即时编译器,同时还涵盖众多其他功能:程序集与类型加载、异常处理、虚拟方法分派、互操作性支持、存根生成等。所有这些特性统称为 .NET 虚拟机(VM)的组成部分。
dotnet/runtime#108167 和 dotnet/runtime#109135 将运行时中多个原生C语言实现的辅助函数重写为System.Private.CoreLib中的C#版本,其中包含用于特定场景将object拆箱为值类型的“拆箱”辅助函数。此次重写既规避了本机与托管环境切换带来的开销,又使JIT编译器能在调用上下文中进行优化(如内联处理)。需注意这些拆箱辅助函数仅在特殊场景中使用,因此需通过复杂基准测试才能体现其影响:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[DisassemblyDiagnoser(0)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private object[] _objects = [new GenericStruct<MyStruct, object>()];
[Benchmark]
public void Unbox() => Unbox<GenericStruct<MyStruct, object>>(_objects[0]);
private void Unbox<T>(object o) where T : struct, IStaticMethod<T>
{
T? local = (T?)o;
if (local.HasValue)
{
T localCopy = local.Value;
T.Method(ref localCopy);
}
}
public interface IStaticMethod<T>
{
public static abstract void Method(ref T param);
}
struct MyStruct : IStaticMethod<MyStruct>
{
public static void Method(ref MyStruct param) { }
}
struct GenericStruct<T, V> : IStaticMethod<GenericStruct<T, V>> where T : IStaticMethod<T>
{
public T Value;
[MethodImpl(MethodImplOptions.NoInlining)]
public static void Method(ref GenericStruct<T, V> value) => T.Method(ref value.Value);
}
}
| Method | Runtime | Mean | Ratio | Code Size |
|---|---|---|---|---|
| Unbox | .NET 9.0 | 1.626 ns | 1.00 | 148 B |
| Unbox | .NET 10.0 | 1.379 ns | 0.85 | 148 B |
将实现从本机迁移至托管环境的意义,通过观察生成的程序集最能直观体现。除了一些无关紧要且无影响的寄存器分配变更外,.NET 9 与 .NET 10 之间的唯一实质差异仅在于一条指令:
- call CORINFO_HELP_UNBOX_NULLABLE
+ call System.Runtime.CompilerServices.CastHelpers.Unbox_Nullable(Byte ByRef, System.Runtime.CompilerServices.MethodTable*, System.Object)
dotnet/runtime#115284 优化了运行时在 x64 架构上为实现 catch/finally 而创建的小代码块(“funclets”)的初始化与销毁流程。历史上,这些funclets的行为类似于微型函数,在进入和退出时保存并恢复非易失性CPU寄存器(所谓“非易失性”寄存器,是指调用方可预期其在函数调用前后保持相同值的寄存器)。本次PR修改了契约,使funclets无需自行维护寄存器——转由运行时统一处理。此举缩减了JIT为funclets生成的前置/后置代码段,减少了指令数量和代码体积,同时降低了异常处理程序的进出成本。
通过dotnet/runtime#114462,运行时现采用单一共享“模板”生成多种小型可执行“存根”——这些微型机器代码片段可充当跳转点、调用计数器或可修补的跳板。此前每次为存根分配内存时,相同指令都会被反复生成。新方案将存根代码构建为只读页面的单一副本,再将该物理页面映射至所有需要的位置,同时为每个分配分配独立可写页面以存放运行时动态变化的存根数据。此机制使数百个虚拟存根页面均指向同一物理代码页,从而降低内存占用、减少启动工作量并提升指令缓存局部性。
另值得关注的是dotnet/runtime#117218 与 dotnet/runtime#116031 共同优化了大型多线程应用程序在性能分析时的堆栈跟踪生成机制。
线程处理§
ThreadPool是支撑多数.NET应用与服务的核心组件。作为关键路径组件,它必须高效处理各类工作负载。
dotnet/runtime#109841 实现了一项需主动启用的功能,该功能在 dotnet/runtime#112796 中默认启用,适用于 .NET 10。其设计理念相当直观,但要理解它,我们首先需要了解 .NET 10 的特性。 随后在 .NET 10 中默认启用。其设计理念相当直观,但要理解它,我们首先需要分析线程池如何对队列中的项进行处理。线程池包含多个队列,通常有一个“全局队列”,以及池中每个线程对应的“本地队列”。当池外线程提交任务时,该任务会被放入全局队列;而当线程池内部线程提交任务(特别是Task或与await相关的任务)时,该任务项通常会被放入该线程的本地队列。当线程池中的线程完成当前任务并寻找新工作时,它会优先检查自己的本地队列(将本地队列视为最高优先级),若本地队列为空则检查全局队列,若全局队列也为空,则转而协助池中其他线程,通过搜索它们的队列来获取待处理任务。此机制旨在实现两点:a) 减少全局队列的竞争(若线程主要在本地队列进行入队/出队操作,则不会相互竞争);b) 优先处理逻辑上属于已启动任务的子任务(工作项进入本地队列的唯一途径是该线程正在处理创建该工作项的任务)。通常这种机制运行良好,但有时会出现退化场景,通常发生在应用程序违反最佳实践时——比如阻塞操作。
阻塞线程池线程意味着该线程无法处理进入池的其他任务。若阻塞时间短暂,通常问题不大;若时间较长,线程池会通过注入更多线程来适应,并寻找一个系统平稳运行的稳态。但存在一种特别棘手的阻塞模式:“同步覆盖异步”。在“同步覆盖异步”场景中,某线程因等待异步操作完成而阻塞。若该异步操作需在线程池执行特定操作才能完成,则会形成线程池中一个线程阻塞等待另一个线程拾取特定工作项进行处理的循环。这种情况极易导致整个线程池陷入堵塞状态——尤其当使用线程局部队列时。若线程因依赖本地队列中工作项处理而阻塞,该工作项能否被拾取取决于全局队列耗尽且有其他线程前来“抢夺”。但当全局队列持续接收新工作项时,这种情况永远不会发生——本质上,最高优先级的工作项已沦为最低优先级。
因此回到这些PR方案。核心思路很简单:当线程即将阻塞(特别是等待Task时),应先将整个本地队列清空至全局队列。这样原本对阻塞线程最高优先级的任务,就能获得更公平的被其他线程处理的机会,而非沦为所有线程的最低优先级任务。我们可通过精心设计的负载测试观察其效果:
// dotnet run -c Release -f net9.0 --filter "*"
// dotnet run -c Release -f net10.0 --filter "*"
using System.Diagnostics;
int numThreads = Environment.ProcessorCount;
ThreadPool.SetMaxThreads(numThreads, 1);
ManualResetEventSlim start = new();
CountdownEvent allDone = new(numThreads);
new Thread(() =>
{
while (true)
{
for (int i = 0; i < 10_000; i++)
{
ThreadPool.QueueUserWorkItem(_ => Thread.SpinWait(1));
}
Thread.Yield();
}
}) { IsBackground = true }.Start();
for (int i = 0; i < numThreads; i++)
{
ThreadPool.QueueUserWorkItem(_ =>
{
start.Wait();
TaskCompletionSource tcs = new();
const int LocalItemsPerThread = 4;
var remaining = LocalItemsPerThread;
for (int j = 0; j < LocalItemsPerThread; j++)
{
Task.Run(() =>
{
Thread.SpinWait(100);
if (Interlocked.Decrement(ref remaining) == 0)
{
tcs.SetResult();
}
});
}
tcs.Task.Wait();
allDone.Signal();
});
}
var sw = Stopwatch.StartNew();
start.Set();
Console.WriteLine(allDone.Wait(20_000) ?
$"Completed: {sw.ElapsedMilliseconds}ms" :
$"Timed out after {sw.ElapsedMilliseconds}ms");
具体实现:
- 创建噪声线程持续向全局队列注入新任务
- 向队列添加
Environment.ProcessorCount个工作项,每个工作项再向本地队列添加四个子任务——这些子任务执行少量操作后均会阻塞在Task上直至全部完成 - 等待上述
Environment.ProcessorCount个工作项完成
在 .NET 9 环境下运行时程序会卡死,因为全局队列中堆积了大量任务,导致没有线程能处理那些用于解除主任务阻塞的子任务:
Timed out after 20002ms
而在 .NET 10 环境下通常能近乎即时完成:
Completed: 4ms
此外还对线程池进行了其他优化:
- dotnet/runtime#115402 减少了 Arm 处理器上的自旋等待次数,使其更接近 x64 架构的行为。
- dotnet/runtime#112789 降低了线程池检查 CPU 利用率的频率,因某些情况下该操作会增加明显开销,并使频率可配置。
- dotnet/runtime#108135 由 @AlanLiu90 提交,移除了在高负载下创建新线程池线程时可能发生的锁竞争问题。
关于锁机制,仅适用于那些迫切需要进行低级锁开发的工作者,dotnet/runtime#107843 由 @hamarb123 提出,该提案为 Volatile 类新增两个方法:ReadBarrier 和 WriteBarrier。读屏障具有“加载获取”语义,有时被称为“向下屏障”:它防止指令被重新排序,导致屏障下方的内存访问移至屏障上方。相比之下,写屏障具有“存储释放”语义,有时被称为“向上屏障”:它防止指令重排序导致屏障上方/之前的内存访问移至下方/之后。通过lock的类比有助于理解:
A;
lock (...)
{
B;
}
C;
虽然实际实现可能提供更强的屏障,但规范规定进入lock具有获取语义,退出lock具有释放语义。试想如果上述代码中的指令被重排序为:
A;
B;
lock (...)
{
}
C;
或如下形式:
A;
lock (...)
{
}
B;
C;
这两种情况都将非常糟糕。所幸屏障机制在此发挥作用。进入锁时的获取/读屏障语义构成向下屏障:逻辑上,开启锁的括号对锁内所有内容施加向下压力,防止其移至锁前;结束锁的括号则对锁内内容施加向上压力,防止其移至锁后。值得注意的是,这些屏障的语义本身并不会阻止此类重排发生:
lock (...)
{
A;
B;
C;
}
这类屏障被称为“半屏障”:读屏障阻止后发生的事项移动到前发生的事项之前,但不阻止反向移动;写屏障阻止前发生的事项移动到后发生的事项之后,但不阻止反向移动。(实际上,尽管规范未强制要求,当前lock的实现确实对进入和退出都使用了完整屏障,因此lock前后的事项都无法进入其中。)
在 .NET 10 中,Task 的 Task.WhenAll 进行了若干性能优化改动。dotnet/runtime#110536 避免了从 IEnumerable<Task> 缓冲任务时临时集合的分配。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
[Benchmark]
public Task WhenAllAlloc()
{
AsyncTaskMethodBuilder t = default;
Task whenAll = Task.WhenAll(from i in Enumerable.Range(0, 2) select t.Task);
t.SetResult();
return whenAll;
}
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| WhenAllAlloc | .NET 9.0 | 216.8 ns | 1.00 | 496 B | 1.00 |
| WhenAllAlloc | .NET 10.0 | 181.9 ns | 0.84 | 408 B | 0.82 |
而@CuteLeon提出的dotnet/runtime#117715方案则彻底规避了Task.WhenAll的开销——当输入仅包含单个任务时,该方案直接返回该任务实例。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
[Benchmark]
public Task WhenAllAlloc()
{
AsyncTaskMethodBuilder t = default;
Task whenAll = Task.WhenAll([t.Task]);
t.SetResult();
return whenAll;
}
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| WhenAllAlloc | .NET 9.0 | 72.73 ns | 1.00 | 144 B | 1.00 |
| WhenAllAlloc | .NET 10.0 | 33.06 ns | 0.45 | 72 B | 0.50 |
System.Threading.Channels 是 .NET 中鲜为人知却极具价值的线程领域(可观看Hanselman 与 Toub 的又一场“深度技术讲座”了解更多详情)。若需在生产者与消费者间传递数据的队列机制,Channel<T>值得关注。该库自.NET Core 3.0引入,作为轻量级、稳健且高效的生产者/消费者队列机制; 此后不断演进,新增了ReadAllAsync方法(可将通道内容作为IAsyncEnumerable<T>读取)和PeekAsync方法(可不消耗内容地预览通道内容)。初始版本支持Channel.CreateUnbounded和Channel.CreateBounded方法,而.NET 9又新增了Channel.CreateUnboundedPrioritized。.NET 10 继续扩展通道功能,既包含功能改进(例如通过 dotnet/runtime#116097 添加无缓冲通道实现),也包含性能优化。
.NET 10 通过通道机制帮助降低应用程序的整体内存消耗。通道支持的横切功能之一是取消操作:几乎所有与通道的交互都可被取消,该机制为数据生产和消费都提供了异步方法。当读取器或写入器需要挂起时,会创建(或复用池化实例)一个AsyncOperation对象并加入队列;后续能满足挂起请求的读取器或写入器会从队列中取出该对象并标记为完成。这些队列采用数组实现,若关联操作被取消,则难以从队列中间移除条目。因此,系统采取的策略是:直接将取消的对象保留在队列中,待其最终被出队时直接丢弃,并由出队器重新尝试。理论上,在稳定状态下,任何被取消的操作都会被快速出队,因此不必耗费大量精力试图加速移除它们。事实证明,这种假设在某些场景下存在问题——当工作负载失衡时(例如大量读取者因缺乏写入者而挂起并超时),每个超时的读取者都会在队列中留下被取消的项。虽然后续写入者出现时会清除所有被取消的读取项,但在此期间会导致工作集显著增加。
dotnet/runtime#116021 通过将数组队列替换为链表队列解决了此问题。等待对象本身兼作链表节点,因此额外内存开销仅限于链表中用于存储前/后节点的几个字段。但即便如此微小的增长也难以接受,因此该PR同时尝试通过补偿性优化来平衡影响。通过借鉴先前版本对ManualResetValueTaskSourceCore<T>的优化方案,成功移除了Channel<T>自定义实现的IValueTaskSource<T>中的一个字段: 等待器通过OnCompleted而非UnsafeOnCompleted方法提供ExecutionContext的情况极其罕见, 更罕见的是在同时存在非默认TaskScheduler或SynchronizationContext需要存储时发生这种情况。因此,系统将这两个概念合并为一个字段(这意味着在极其罕见需要同时存储两者的情况下,会额外消耗一次内存分配)。另一个用于存储实例CancellationToken的字段被移除,因为在.NET Core中该令牌可从其他可用状态中获取。这些改动实际导致AsyncOperation等待器实例的大小缩减而非增加。可谓双赢。此变更对吞吐量的影响难以量化,但在退化场景(即取消的操作永不移除)下,更容易观察其对工作集的影响。运行以下代码时:
// dotnet run -c Release -f net9.0 --filter "*"
// dotnet run -c Release -f net10.0 --filter "*"
using System.Threading.Channels;
Channel<int> c = Channel.CreateUnbounded<int>();
for (int i = 0; ; i++)
{
CancellationTokenSource cts = new();
var vt = c.Reader.ReadAsync(cts.Token);
cts.Cancel();
await ((Task)vt.AsTask()).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
if (i % 100_000 == 0)
{
Console.WriteLine($"Working set: {Environment.WorkingSet:N0}b");
}
}
在 .NET 9 中输出如下,工作集持续增长:
Working set: 31,588,352b
Working set: 164,884,480b
Working set: 210,698,240b
Working set: 293,711,872b
Working set: 385,495,040b
Working set: 478,158,848b
Working set: 553,385,984b
Working set: 608,206,848b
Working set: 699,695,104b
Working set: 793,034,752b
Working set: 885,309,440b
Working set: 986,103,808b
Working set: 1,094,234,112b
Working set: 1,156,239,360b
Working set: 1,255,198,720b
Working set: 1,347,604,480b
Working set: 1,439,879,168b
Working set: 1,532,284,928b
而在 .NET 10 中输出如下,工作集达到稳定状态:
Working set: 33,030,144b
Working set: 44,826,624b
Working set: 45,481,984b
Working set: 45,613,056b
Working set: 45,875,200b
Working set: 45,875,200b
Working set: 46,006,272b
Working set: 46,006,272b
Working set: 46,006,272b
Working set: 46,006,272b
Working set: 46,006,272b
Working set: 46,006,272b
Working set: 46,006,272b
Working set: 46,006,272b
Working set: 46,006,272b
Working set: 46,006,272b
Working set: 46,006,272b
Working set: 46,006,272b
反射§
.NET 8新增的[UnsafeAccessor]属性允许开发者编写extern方法,使其与开发者希望使用的某些不可见成员匹配,运行时会修复访问操作,使其效果等同于直接使用目标成员。.NET 9在此基础上扩展了泛型支持。
// dotnet run -c Release -f net10.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")]
public partial class Tests
{
private List<int> _list = new List<int>(16);
private FieldInfo _itemsField = typeof(List<int>).GetField("_items", BindingFlags.NonPublic | BindingFlags.Instance)!;
private static class Accessors<T>
{
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_items")]
public static extern ref T[] GetItems(List<T> list);
}
[Benchmark]
public int[] WithReflection() => (int[])_itemsField.GetValue(_list)!;
[Benchmark]
public int[] WithUnsafeAccessor() => Accessors<int>.GetItems(_list);
}
| Method | Mean |
|---|---|
| WithReflection | 2.6397 ns |
| WithUnsafeAccessor | 0.7300 ns |
但该方案仍存在缺陷:UnsafeAccessor成员的签名需与目标成员完全一致,但若目标成员的参数不可见于编写UnsafeAccessor的代码怎么办?或者目标成员是静态成员呢?开发者无法在UnsafeAccessor中明确目标成员所属的类型。
针对这些场景,dotnet/runtime#114881 通过 [UnsafeAccessorType] 属性扩展了功能。UnsafeAccessor方法可将相关参数类型定义为object,但需附加[UnsafeAccessorType(“...”)]属性,该属性提供目标类型的全限定名称。在dotnet/runtime#115583中提供了大量应用示例,该方案通过[UnsafeAccessor]替代了.NET内部多数跨库反射操作。该特性在处理 System.Net.Http 与 System.Security.Cryptography 之间的循环引用时尤为实用。前者位于后者之上,并通过引用实现 X509Certificate 等关键功能。但System.Security.Cryptography需要通过HTTP请求下载OCSP信息,由于System.Net.Http已引用System.Security.Cryptography,导致后者无法显式引用前者。不过它可通过反射或[UnsafeAccessor]与[UnsafeAccessorType]实现引用,实际也采用了这种方案。早期使用反射实现,而在.NET 10中改用[UnsafeAccessor]。
围绕反射机制还进行了若干优化改进。由@huoyaoyuan提交的dotnet/runtime#105814更新了ActivatorUtilities.CreateFactory方法,移除了多余的委托层级。CreateFactory 返回 ObjectFactory 委托,但底层实现会先创建 Func<...>,再为该委托的 Invoke 方法创建 ObjectFactory 委托。该 PR 将初始化改为直接创建 ObjectFactory,意味着每次调用都可避免一层委托调用。
// dotnet run -c Release -f net9.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.Core90).WithNuGet("Microsoft.Extensions.DependencyInjection.Abstractions", "9.0.9").AsBaseline())
.AddJob(Job.Default.WithRuntime(CoreRuntime.Core10_0).WithNuGet("Microsoft.Extensions.DependencyInjection.Abstractions", "10.0.0-rc.1.25451.107"));
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "NuGetReferences")]
public partial class Tests
{
private IServiceProvider _sp = new ServiceCollection().BuildServiceProvider();
private ObjectFactory _factory = ActivatorUtilities.CreateFactory(typeof(object), Type.EmptyTypes);
[Benchmark]
public object CreateInstance() => _factory(_sp, null);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| CreateInstance | .NET 9.0 | 8.136 ns | 1.00 |
| CreateInstance | .NET 10.0 | 6.676 ns | 0.82 |
dotnet/runtime#112350 通过优化 TypeName 的解析与渲染过程,减少了部分开销和内存分配。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Reflection.Metadata;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "t")]
public partial class Tests
{
[Benchmark]
[Arguments(typeof(Dictionary<List<int[]>[,], List<int?[][][,]>>[]))]
public string ParseAndGetName(Type t) => TypeName.Parse(t.FullName).FullName;
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| ParseAndGetName | .NET 9.0 | 5.930 us | 1.00 | 12.25 KB | 1.00 |
| ParseAndGetName | .NET 10.0 | 4.305 us | 0.73 | 5.75 KB | 0.47 |
而@teo-tsirpanis提交的dotnet/runtime#113803改进System.Reflection. Metadata中DebugDirectoryBuilder使用DeflateStream嵌入PDB的方式。此前代码会将压缩输出缓冲到中间MemoryStream,再将该MemoryStream写入BlobBuilder。本次变更直接将DeflateStream包装在BlobBuilder外,使压缩数据能直接传递至builder.WriteBytes。
基本类型与数值§
每当撰写这类“.NET性能优化”文章时,我总会暗自思忖“下次还能有什么新内容”。核心数据类型尤其如此——这些年它们早已被反复研究透彻。然而在.NET 10时代,我们仍有值得关注的优化空间。
DateTime 和 DateTimeOffset 在 dotnet/runtime#111112 中获得优化,重点在于实例初始化过程的微优化。类似的调整也出现在针对DateOnly、TimeOnly和ISOWeek的dotnet/runtime#111244中。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private DateTimeOffset _dto = new DateTimeOffset(2025, 9, 10, 0, 0, 0, TimeSpan.Zero);
[Benchmark]
public DateTimeOffset GetFutureTime() => _dto + TimeSpan.FromDays(1);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| GetFutureTime | .NET 9.0 | 6.012 ns | 1.00 |
| GetFutureTime | .NET 10.0 | 1.029 ns | 0.17 |
Guid 在 .NET 10 中获得了多项显著性能提升。@SirCxyrtyx 提交的dotnet/runtime#105654 为Guid实现了IUtf8SpanParsable接口。这不仅使 Guid 能用于泛型参数受限于 IUtf8SpanParsable 的场景,还为 Guid 的 Parse 和 TryParse 重载方法提供了基于 UTF8 字节的解析能力。这意味着处理UTF8数据时,无需先转码为UTF16再解析,也无需使用Utf8Parser.TryParse——该方法虽能从长输入开头解析Guid,但优化程度不及Guid.TryParse。
// dotnet run -c Release -f net10.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers.Text;
using System.Text;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private byte[] _utf8 = Encoding.UTF8.GetBytes(Guid.NewGuid().ToString("N"));
[Benchmark(Baseline = true)]
public Guid TranscodeParse()
{
Span<char> scratch = stackalloc char[64];
ReadOnlySpan<char> input = Encoding.UTF8.TryGetChars(_utf8, scratch, out int charsWritten) ?
scratch.Slice(0, charsWritten) :
Encoding.UTF8.GetString(_utf8);
return Guid.Parse(input);
}
[Benchmark]
public Guid Utf8ParserParse() => Utf8Parser.TryParse(_utf8, out Guid result, out _, 'N') ? result : Guid.Empty;
[Benchmark]
public Guid GuidParse() => Guid.Parse(_utf8);
}
| Method | Mean | Ratio |
|---|---|---|
| TranscodeParse | 24.72 ns | 1.00 |
| Utf8ParserParse | 19.34 ns | 0.78 |
| GuidParse | 16.47 ns | 0.67 |
Char、Rune 和 Version 类型也获得了 IUtf8SpanParsable 实现,具体参见 dotnet/runtime#105773 由 @lilinus 提交的提案。对于char和Rune而言,此处的性能提升有限;实现接口主要带来一致性,并能使这些类型与基于该接口参数化的泛型例程配合使用。但Version获得了与Guid相同的性能(及可用性)优势:它现在支持直接从UTF8解析,无需先转码为UTF16再进行解析。
// dotnet run -c Release -f net10.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private byte[] _utf8 = Encoding.UTF8.GetBytes(new Version("123.456.789.10").ToString());
[Benchmark(Baseline = true)]
public Version TranscodeParse()
{
Span<char> scratch = stackalloc char[64];
ReadOnlySpan<char> input = Encoding.UTF8.TryGetChars(_utf8, scratch, out int charsWritten) ?
scratch.Slice(0, charsWritten) :
Encoding.UTF8.GetString(_utf8);
return Version.Parse(input);
}
[Benchmark]
public Version GuidParse() => Version.Parse(_utf8);
}
| Method | Mean | Ratio |
|---|---|---|
| TranscodeParse | 46.48 ns | 1.00 |
| GuidParse | 35.75 ns | 0.77 |
有时性能提升是其他工作的副作用。dotnet/runtime#110923 原本旨在移除 Guid 格式化实现中的部分指针使用,但此举意外地略微提升了(确实很少使用的)“X” 格式的吞吐量。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private char[] _dest = new char[64];
private Guid _g = Guid.NewGuid();
[Benchmark]
public void FormatX() => _g.TryFormat(_dest, out int charsWritten, "X");
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| FormatX | .NET 9.0 | 3.0584 ns | 1.00 |
| FormatX | .NET 10.0 | 0.7873 ns | 0.26 |
Random(及其加密安全的对应类RandomNumberGenerator)在.NET 10中持续优化,新增方法(如dotnet/runtime#112162中引入的Random.GetString和Random.GetHexString) 提升可用性,更重要的是优化了现有方法的性能。在 .NET 8 中,Random 和 RandomNumberGenerator 都新增了便捷的 GetItems 方法;该方法允许调用方提供一组选项和所需项数,使 Random{NumberGenerator} 能执行“有放回抽样”操作,即从该集合中选取指定次数的项。在 .NET 9 中,这些实现针对选择项数为 2 的幂且小于等于 256 的特例进行了优化。此时通过批量请求字节而非逐个请求 int 元素,可大幅减少对底层随机源的调用次数。当选择项数为 2 的幂时,只需对每个字节进行掩码处理即可生成选择项索引,且不会引入偏差。在 .NET 10 中,dotnet/runtime#107988 将此优化扩展至非 2 的幂次方情况。虽然无法像二进制情况那样直接屏蔽位,但可采用“拒绝采样”机制——本质上就是“若随机值超出允许范围则重试”的优雅实现。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.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 partial class Tests
{
private const string Base58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
[Params(30)]
public int Length { get; set; }
[Benchmark]
public char[] WithRandom() => Random.Shared.GetItems<char>(Base58, Length);
[Benchmark]
public char[] WithRandomNumberGenerator() => RandomNumberGenerator.GetItems<char>(Base58, Length);
}
| Method | Runtime | Length | Mean | Ratio |
|---|---|---|---|---|
| WithRandom | .NET 9.0 | 30 | 144.42 ns | 1.00 |
| WithRandom | .NET 10.0 | 30 | 73.68 ns | 0.51 |
| WithRandomNumberGenerator | .NET 9.0 | 30 | 23,179.73 ns | 1.00 |
| WithRandomNumberGenerator | .NET 10.0 | 30 | 853.47 ns | 0.04 |
decimal 运算(特别是乘除法)获得了性能提升,这得益于 @Daniel-Svensson 提交的 dotnet/runtime#99212。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private decimal _n = 9.87654321m;
private decimal _d = 1.23456789m;
[Benchmark]
public decimal Divide() => _n / _d;
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Divide | .NET 9.0 | 27.09 ns | 1.00 |
| Divide | .NET 10.0 | 23.68 ns | 0.87 |
UInt128除法操作同样在dotnet/runtime#99747中获得优化,该方案由@Daniel-Svensson提出,利用X86架构的Div指令实现。,由 @Daniel-Svensson 提出,当对大于 ulong 的数值进行除以可容纳于 ulong 的数值时,会利用 X86 架构的 DivRem 硬件内置函数。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private UInt128 _n = new UInt128(123, 456);
private UInt128 _d = new UInt128(0, 789);
[Benchmark]
public UInt128 Divide() => _n / _d;
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Divide | .NET 9.0 | 27.3112 ns | 1.00 |
| Divide | .NET 10.0 | 0.5522 ns | 0.02 |
BigInteger 亦获得若干改进。dotnet/runtime#115445 由 @Rob-Hague 提交的改动,增强了其 TryWriteBytes 方法,使其在可行时(即数值为非负数且无需进行补码调整时)采用直接内存复制。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Numerics;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private BigInteger _value = BigInteger.Parse(string.Concat(Enumerable.Repeat("1234567890", 20)));
private byte[] _bytes = new byte[256];
[Benchmark]
public bool TryWriteBytes() => _value.TryWriteBytes(_bytes, out _);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| TryWriteBytes | .NET 9.0 | 27.814 ns | 1.00 |
| TryWriteBytes | .NET 10.0 | 5.743 ns | 0.21 |
另一个罕见但有趣的案例:若尝试将int.MinValue的字符串表示形式直接传入BigInteger.Parse,会导致不必要的内存分配。此问题由@kzrnm在dotnet/runtime#104666中修复,该方案调整了此边界情况的处理逻辑,使其能通过int.MinValue的单例实现正确表示。该方案优化了此边界情况的处理逻辑,使其能正确识别为可通过int.MinValue的单例表示的情况(该单例原本就存在,只是在此场景下未被应用。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Numerics;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private string _int32min = int.MinValue.ToString();
[Benchmark]
public BigInteger ParseInt32Min() => BigInteger.Parse(_int32min);
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| ParseInt32Min | .NET 9.0 | 80.54 ns | 1.00 | 32 B | 1.00 |
| ParseInt32Min | .NET 10.0 | 71.59 ns | 0.89 | – | 0.00 |
.NET 10 中备受关注的领域是 System.Numerics.Tensors。该库在.NET 8中首次引入,核心是TensorPrimitives类,为float数组提供各类数值运算。.NET 9进一步扩展了TensorPrimitives,新增运算及泛型版本。而.NET 10中,TensorPrimitives不仅新增更多运算,还针对多种场景优化了现有运算的执行效率。
首先,dotnet/runtime#112933 为 TensorPrimitives 添加了 70 多个新重载方法,包括 StdDev、Average、Clamp、DivRem、IsNaN、IsPow2、Remainder 等运算。其中大部分运算均实现了向量化,通过泛型运算符参数化的共享实现完成。例如Decrement<T>的完整实现如下:
public static void Decrement<T>(ReadOnlySpan<T> x, Span<T> destination) where T : IDecrementOperators<T> =>
InvokeSpanIntoSpan<T, DecrementOperator<T>>(x, destination);
其中InvokeSpanIntoSpan是共享例程,被近60个方法调用,每个方法提供专属运算符,最终在高度优化的例程中使用。此处的DecrementOperator<T>实现仅为:
private readonly struct DecrementOperator<T> : IUnaryOperator<T, T> where T : IDecrementOperators<T>
{
public static bool Vectorizable => true;
public static T Invoke(T x) => --x;
public static Vector128<T> Invoke(Vector128<T> x) => x - Vector128<T>.One;
public static Vector256<T> Invoke(Vector256<T> x) => x - Vector256<T>.One;
public static Vector512<T> Invoke(Vector512<T> x) => x - Vector512<T>.One;
}
凭借这个最小化实现(为 128 位、256 位、512 位向量化宽度及标量提供递减实现),核心例程得以实现极高效的运行。
// Update benchmark.csproj with a package reference to System.Numerics.Tensors.
// dotnet run -c Release -f net10.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 partial class Tests
{
private float[] _src = Enumerable.Range(0, 1000).Select(i => (float)i).ToArray();
private float[] _dest = new float[1000];
[Benchmark(Baseline = true)]
public void DecrementManual()
{
ReadOnlySpan<float> src = _src;
Span<float> dest = _dest;
for (int i = 0; i < src.Length; i++)
{
dest[i] = src[i] - 1f;
}
}
[Benchmark]
public void DecrementTP() => TensorPrimitives.Decrement(_src, _dest);
}
| Method | Mean | Ratio |
|---|---|---|
| DecrementManual | 288.80 ns | 1.00 |
| DecrementTP | 22.46 ns | 0.08 |
在可能的情况下,这些方法还利用了底层Vector128、Vector256和Vector512类型的API,包括在dotnet/runtime#111179中引入的新对应方法 和 dotnet/runtime#115525 中引入的新方法,例如 IsNaN。
现有方法也得到优化。dotnet/runtime#111615(由@BarionLP提交)通过避免不必要的T.Exp重新计算,改进了TensorPrimitives.SoftMax。软最大化函数需为每个元素计算exp值并求和,值为x的元素输出即为exp(x)除以该和。原实现遵循此流程,导致每个元素需计算两次exp。改进方案是:仅对每个元素计算一次exp,在求和过程中将其临时缓存至目标位置,随后复用这些缓存值进行后续除法运算,并用实际结果覆盖原值。最终吞吐量提升近一倍:
// Update benchmark.csproj with a package reference to System.Numerics.Tensors.
// dotnet run -c Release -f net9.0 --filter **
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using System.Numerics.Tensors;
var config = DefaultConfig.Instance
.AddJob(Job.Default.WithRuntime(CoreRuntime.Core90).WithNuGet("System.Numerics.Tensors", "9.0.9").AsBaseline())
.AddJob(Job.Default.WithRuntime(CoreRuntime.Core10_0).WithNuGet("System.Numerics.Tensors", "10.0.0-rc.1.25451.107"));
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "NuGetReferences")]
public partial class Tests
{
private float[] _src, _dst;
[GlobalSetup]
public void Setup()
{
Random r = new(42);
_src = Enumerable.Range(0, 1000).Select(_ => r.NextSingle()).ToArray();
_dst = new float[_src.Length];
}
[Benchmark]
public void SoftMax() => TensorPrimitives.SoftMax(_src, _dst);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| SoftMax | .NET 9.0 | 1,047.9 ns | 1.00 |
| SoftMax | .NET 10.0 | 649.8 ns | 0.62 |
dotnet/runtime#111505 由 @alexcovington 提交,实现了 TensorPrimitives.Divide<T> 对 int 类型的向量化支持。该操作此前已支持float和double类型的向量化(因存在SIMD硬件加速除法支持),但未支持缺乏SIMD硬件加速的int类型。此PR通过将int转换为double执行双精度除法再反转,教会JIT如何模拟SIMD整数除法。
// Update benchmark.csproj with a package reference to System.Numerics.Tensors.
// dotnet run -c Release -f net9.0 --filter **
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using System.Numerics.Tensors;
var config = DefaultConfig.Instance
.AddJob(Job.Default.WithRuntime(CoreRuntime.Core90).WithNuGet("System.Numerics.Tensors", "9.0.9").AsBaseline())
.AddJob(Job.Default.WithRuntime(CoreRuntime.Core10_0).WithNuGet("System.Numerics.Tensors", "10.0.0-rc.1.25451.107"));
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "NuGetReferences")]
public partial class Tests
{
private int[] _n, _d, _dst;
[GlobalSetup]
public void Setup()
{
Random r = new(42);
_n = Enumerable.Range(0, 1000).Select(_ => r.Next(1000, int.MaxValue)).ToArray();
_d = Enumerable.Range(0, 1000).Select(_ => r.Next(1, 1000)).ToArray();
_dst = new int[_n.Length];
}
[Benchmark]
public void Divide() => TensorPrimitives.Divide(_n, _d, _dst);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Divide | .NET 9.0 | 1,293.9 ns | 1.00 |
| Divide | .NET 10.0 | 458.4 ns | 0.35 |
dotnet/runtime#116945 进一步更新了 TensorPrimitives.Divide(同时更新了 TensorPrimitives.Sign 和 TensorPrimitives. ConvertToInteger)的实现,使其在处理nint或nuint时可进行向量化。在32位进程中,nint可视为int;在64位进程中,nint可视为long;nuint分别对应uint和ulong的处理逻辑。因此,凡是在 32 位系统上成功对 int/uint 进行向量化,或在 64 位系统上成功对 long/ulong 进行向量化的场景,均可成功对 nint/nuint 实现向量化。dotnet/runtime#116895 还启用了将 float 转换为 int 或 uint、将 double 转换为 long 或 ulong 时对 TensorPrimitives.ConvertTruncating 的向量化支持。此前未启用向量化是因为底层操作存在未定义行为;该行为已在.NET 9开发周期后期修复,现可启用此向量化功能。
// Update benchmark.csproj with a package reference to System.Numerics.Tensors.
// dotnet run -c Release -f net9.0 --filter **
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using System.Numerics.Tensors;
var config = DefaultConfig.Instance
.AddJob(Job.Default.WithRuntime(CoreRuntime.Core90).WithNuGet("System.Numerics.Tensors", "9.0.9").AsBaseline())
.AddJob(Job.Default.WithRuntime(CoreRuntime.Core10_0).WithNuGet("System.Numerics.Tensors", "10.0.0-rc.1.25451.107"));
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "NuGetReferences")]
public partial class Tests
{
private float[] _src;
private int[] _dst;
[GlobalSetup]
public void Setup()
{
Random r = new(42);
_src = Enumerable.Range(0, 1000).Select(_ => r.NextSingle() * 1000).ToArray();
_dst = new int[_src.Length];
}
[Benchmark]
public void ConvertTruncating() => TensorPrimitives.ConvertTruncating(_src, _dst);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| ConvertTruncating | .NET 9.0 | 933.86 ns | 1.00 |
| ConvertTruncating | .NET 10.0 | 41.99 ns | 0.04 |
同样值得关注的是,TensorPrimitives.LeadingZeroCount 在 dotnet/runtime#110333 中也得到了改进,该改进由 @alexcovington 提出。当 AVX512 可用时,该变更利用 PermuteVar16x8x2 等 AVX512 指令,为 Vector512<T> 支持的所有类型实现 LeadingZeroCount 的向量化。
// Update benchmark.csproj with a package reference to System.Numerics.Tensors.
// dotnet run -c Release -f net9.0 --filter **
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using System.Numerics.Tensors;
var config = DefaultConfig.Instance
.AddJob(Job.Default.WithRuntime(CoreRuntime.Core90).WithNuGet("System.Numerics.Tensors", "9.0.9").AsBaseline())
.AddJob(Job.Default.WithRuntime(CoreRuntime.Core10_0).WithNuGet("System.Numerics.Tensors", "10.0.0-rc.1.25451.107"));
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "NuGetReferences")]
public partial class Tests
{
private byte[] _src, _dst;
[GlobalSetup]
public void Setup()
{
_src = new byte[1000];
_dst = new byte[_src.Length];
new Random(42).NextBytes(_src);
}
[Benchmark]
public void LeadingZeroCount() => TensorPrimitives.LeadingZeroCount(_src, _dst);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| LeadingZeroCount | .NET 9.0 | 401.60 ns | 1.00 |
| LeadingZeroCount | .NET 10.0 | 12.33 ns | 0.03 |
就影响最多运算的变更而言,dotnet/runtime#116898 和 dotnet/runtime#116934 堪称典范。这两个PR共同扩展了近60种独立运算的向量化支持,使其也能加速Half类型运算: Abs、Add、AddMultiply、BitwiseAnd、BitwiseOr、Ceiling、Clamp、CopySign、Cos、CosPi、Cosh、CosineSimilarity、Decrement、DegreesToRadians、Divide、Exp、Exp10、Exp10M1、Exp2、Exp2M1、ExpM1、 Floor, FusedAddMultiply, Hypot, Increment, Lerp, Log, Log10, Log10P1, Log2, Log2P1, LogP1, Max, MaxMagnitude, MaxMagnitudeNumber, MaxNumber, Min, MinMagnitude, MinMagnitudeNumber, MinNumber, 乘法、乘加法、乘加法估计值、取反、单位补码、倒数、余数、舍入、Sigmoid、正弦、π正弦、双曲正弦、开方、减法、正切、π正切、双曲正切、截断、异或。此处的挑战在于Half类型未获得硬件加速支持,甚至当前向量类型也未支持该类型。实际上,即使进行标量运算时,系统也会将其内部转换为float类型执行操作,再转回Half类型。例如以下是Half乘法运算符的实现:
public static Half operator *(Half left, Half right) => (Half)((float)left * (float)right);
对于所有这些TensorPrimitives操作,它们以前会将Half视为任何其他未加速的类型,并仅运行一个标量循环来对每个Half执行操作。这意味着对于每个元素,我们先将其转换为float,执行操作后再转换回来。幸运的是,TensorPrimitives已定义了加速的ConvertToSingle和ConvertToHalf方法。我们可复用这些方法实现与标量操作相同的操作,但采用向量化方式:将Half向量全部转换为float,处理所有float值,再统一转换回Half。当然,我之前提到向量类型不支持Half,那么如何“取向量中的Half”?通过重新解释将Span<Half>转换为Span<short>(或Span<ushort>),从而实现Half的隐蔽传递。事实上,即便是对标量类型,Half的float转换运算符首步操作就是将其转换为short。
最终效果是大量Half运算得以加速执行。
// Update benchmark.csproj with a package reference to System.Numerics.Tensors.
// dotnet run -c Release -f net9.0 --filter **
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using System.Numerics.Tensors;
var config = DefaultConfig.Instance
.AddJob(Job.Default.WithRuntime(CoreRuntime.Core90).WithNuGet("System.Numerics.Tensors", "9.0.9").AsBaseline())
.AddJob(Job.Default.WithRuntime(CoreRuntime.Core10_0).WithNuGet("System.Numerics.Tensors", "10.0.0-rc.1.25451.107"));
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "NuGetReferences")]
public partial class Tests
{
private Half[] _x, _y, _dest;
[GlobalSetup]
public void Setup()
{
_x = new Half[1000];
_y = new Half[_x.Length];
_dest = new Half[_x.Length];
var random = new Random(42);
for (int i = 0; i < _x.Length; i++)
{
_x[i] = (Half)random.NextSingle();
_y[i] = (Half)random.NextSingle();
}
}
[Benchmark]
public void Add() => TensorPrimitives.Add(_x, _y, _dest);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Add | .NET 9.0 | 5,984.3 ns | 1.00 |
| Add | .NET 10.0 | 481.7 ns | 0.08 |
.NET 10 中的 System.Numerics.Tensors 库现已包含张量类型的稳定 API(其实现使用 TensorPrimitives)。这包括 Tensor<T>、ITensor<,>、TensorSpan<T> 和 ReadOnlyTensorSpan<T>。这些类型最有趣的特性之一是它们充分利用了C# 14的新复合运算符功能,从而获得显著的性能提升。在之前的C#版本中,开发者可以编写自定义运算符,例如加法运算符:
public class C
{
public int Value;
public static C operator +(C left, C right) => new() { Value = left.Value + right.Value };
}
使用该类型时,我可以编写如下代码:
C a = new() { Value = 42 };
C b = new() { Value = 84 };
C c = a + b;
Console.WriteLine(c.Value);
这段代码将输出126。若改用复合运算符+=,代码如下:
C a = new() { Value = 42 };
C b = new() { Value = 84 };
a += b;
Console.WriteLine(a.Value);
同样输出126,因为a += b始终等同于a = a + b…至少在过去是这样。在 C# 14 中,类型不仅能定义 + 运算符,还能定义 += 运算符。若类型定义了 += 运算符,系统将优先使用该运算符,而非将 a += b 展开为 a = a + b 的简写形式。这将带来性能影响。
张量本质上是多维数组,如同数组般可能庞大——极其庞大。假设存在如下操作序列:
Tensor<int> t1 = ...;
Tensor<int> t2 = ...;
for (int i = 0; i < 3; i++)
{
t1 += t2;
}
若每个 t1 += t2 都展开为 t1 = t1 + t2,则每次操作都会分配全新张量。当张量体积庞大时,这种操作成本将迅速攀升。但C# 14新增的用户自定义复合运算符(最初通过dotnet/roslyn#78400添加至编译器)支持对目标张量的就地修改。
public class C
{
public int Value;
public static C operator +(C left, C right) => new() { Value = left.Value + right.Value };
public static void operator +=(C other) => left.Value += other.Value;
}
这意味着张量类型的复合运算符可就地更新目标张量,而非每次计算都分配全新(且可能庞大)的数据结构。dotnet/runtime#117997 为张量类型添加了所有这些复合运算符。(这些操作不仅采用了C# 14的用户自定义复合运算符,更以扩展运算符的形式实现,运用了C# 14全新的扩展类型特性。真有趣!)
集合§
数据集合的处理是任何应用程序的生命线,因此每次.NET版本发布都致力于从集合及其处理中榨取更多性能。
枚举§
遍历集合是开发者最常执行的操作之一。为实现最高效率,.NET 中最主流的集合类型(如 List<T>)提供了基于结构体的枚举器(如 List<T>.Enumerator),其公共 GetEnumerator() 方法以强类型方式返回枚举器:
public Enumerator GetEnumerator() => new Enumerator(this);
这与它们的 IEnumerable<T>.GetEnumerator() 实现互为补充,后者通过“显式”接口实现完成(“显式”指相关方法提供接口方法实现,但不会在类型本身显示为公共方法),例如 List<T> 的实现:
IEnumerator<T> IEnumerable<T>.GetEnumerator() =>
Count == 0 ? SZGenericArrayEnumerator<T>.Empty :
GetEnumerator();
直接对集合使用 foreach 循环时,C# 编译器会绑定到基于结构体的枚举器,从而避免枚举器分配,并能直接访问枚举器上的非虚方法,而非通过 IEnumerator<T> 接口调用方法所需的接口分派机制。然而当集合以 IEnumerable<T> 形式多态使用时,这种机制便失效了。此时会调用 IEnumerable<T>.GetEnumerator(),该方法必须分配新的枚举器实例(特殊情况除外,如上文 List<T> 实现中空集合时返回单例枚举器的情形)。
值得庆幸的是,正如JIT部分前文所述,JIT在动态PGO、逃逸分析和栈分配方面正不断获得强大能力。这意味着在许多情况下,JIT现在能够识别出特定调用点最常出现的具体类型是某个枚举类型,并针对该类型生成专属代码:取消虚函数调用、可能进行内联处理,并在条件允许时为枚举类型分配栈空间。随着.NET 10的进展,此机制现已频繁应用于数组和List<T>。虽然JIT可普遍实现此功能(不受对象类型限制),但枚举的普遍性使得IEnumerator<T>的优化尤为关键,因此dotnet/runtime#116978 将 IEnumerator<T> 标记为 [Intrinsic],使 JIT 能更准确地推断其行为。
然而某些枚举器仍需额外优化。除T[]外,List<T>是.NET中最常用的集合类型。随着JIT的变更,许多实际为List<T>的IEnumerable<T>在foreach循环中将成功分配枚举器栈空间。这很棒。但当尝试不同大小的列表时,这种优势逐渐减弱。下图展示了将List<T>类型化为IEnumerable<T>后,不同长度列表的枚举测试结果,以及2025年8月初(约.NET 10 Preview 7版本)的基准测试数据。
// dotnet run -c Release -f net10.0 --filter **
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private IEnumerable<int> _enumerable;
[Params(500, 5000, 15000)]
public int Count { get; set; }
[GlobalSetup]
public void Setup() => _enumerable = Enumerable.Range(0, Count).ToList();
[Benchmark]
public int Sum()
{
int sum = 0;
foreach (int item in _enumerable) sum += item;
return sum;
}
}
| Method | Count | Mean | Allocated |
|---|---|---|---|
| Sum | 500 | 214.1 ns | – |
| Sum | 5000 | 4,767.1 ns | 40 B |
| Sum | 15000 | 13,824.4 ns | 40 B |
请注意,对于包含500个元素的List<T>,分配列显示堆上未分配任何内存——枚举器成功实现了栈分配。这非常棒。但随后仅需增加列表大小,栈分配机制便不再生效。原因何在?从500到5000的分配变化,源于动态PGO(程序优化)机制与List<T>枚举器多年以前的实现方式。
List<T>枚举器的MoveNext方法结构如下:
public bool MoveNext()
{
if (_version == _list._version && ((uint)_index < (uint)_list._size))
{
... // handle successfully getting next element
return true;
}
return MoveNextRare();
}
private bool MoveNextRare()
{
... // handle version mismatch and/or returning false for completed enumeration
}
名称中的Rare暗示了这种拆分逻辑。MoveNext方法在常见场景下被设计得极其精简——即所有返回true的成功调用;除枚举器被误用外,MoveNextRare仅在所有元素已全部输出后的最终调用中才被需要。对MoveNext本身的精简旨在使其可内联。然而自代码编写以来情况已发生诸多变化,该优化重要性降低,而拆分出的MoveNextRare与动态PGO(性能优化)产生了极具趣味性的交互。动态PGO关注的核心指标之一是代码的热度(高频使用)与冷度(低频使用),该数据直接影响方法是否被纳入内联候选。对于短列表,动态PGO会检测到MoveNextRare被合理次数调用,从而将其纳入内联候选。若枚举器的所有调用均被内联,枚举器实例即可避免逃逸调用帧,从而实现栈分配。但当列表长度大幅增长时,MoveNextRare方法将呈现极低使用频率,难以被内联,进而导致枚举器实例逃逸,阻碍其栈分配机制。dotnet/runtime#118425 指出当前环境已与枚举器初始设计时不同,内联启发式算法、PGO等机制均有重大改进;该提案撤销了MoveNextRare的分离处理,并简化了枚举器结构。在当前系统机制下,无论是否启用PGO,重组后的MoveNext仍可实现内联,且我们能够以更大尺寸进行栈分配。
| Method | Count | Mean | Allocated |
|---|---|---|---|
| Sum | 500 | 221.2 ns | – |
| Sum | 5000 | 2,153.6 ns | – |
| Sum | 15000 | 14,724.9 ns | 40 B |
尽管进行了修复,我们仍存在一个问题。目前我们已避免在长度为500和5000时分配内存,但在长度为15,000时,枚举器仍会被分配。原因何在?这与OSR(栈上替换)机制相关,该机制在.NET 7中作为关键功能被引入,旨在支持含循环的方法进行分层编译。OSR 允许方法在执行过程中重新编译并应用优化,使方法调用能从未优化的代码跳转至新优化方法的对应位置。尽管 OSR 功能强大,但在此处却引发了某些复杂问题。当列表足够长时,对第 0 层(未优化)方法的调用将过渡到 OSR 优化方法…但 OSR 方法不包含动态 PGO 仪器(以前包含,但后来被移除,因为如果仪器代码从未被重新编译,就会导致问题,从而因仪器探针永久存在而遭受回归)。缺乏仪器化支持——尤其是方法尾部(调用枚举器Dispose方法的位置)的仪器化——即使List<T>.Dispose本身是空操作,JIT也可能无法执行受保护的反虚化操作,从而无法实现IEnumerator<T>.Dispose的反虚化和内联。讽刺的是,这种空操作的Dispose反而会触发逃逸分析,导致枚举器实例被识别为逃逸对象,从而无法进行栈分配。呼。
所幸dotnet/runtime#118461已在JIT中修复此问题。针对枚举器,该PR通过动态PGO机制,基于其他枚举方法的探测结果推断缺失的仪器化代码,从而成功实现Dispose的去虚拟化和内联。因此在.NET 10平台运行相同基准测试时,我们终于看到这般美妙景象:
| Method | Count | Mean | Allocated |
|---|---|---|---|
| Sum | 500 | 216.5 ns | – |
| Sum | 5000 | 2,082.4 ns | – |
| Sum | 15000 | 6,525.3 ns | – |
其他类型也需要稍作调整。dotnet/runtime#118467 解决了 PriorityQueue<TElement, TPriority> 的枚举器问题——其枚举器是 List<T> 的移植版本,因此进行了类似修改。
另外,dotnet/runtime#117328 优化了 Stack<T> 的枚举器类型,删减了约一半的代码行。旧版枚举器的 MoveNext 在获取多数后续元素时需执行五次分支操作:
- 首先执行版本检查:将堆栈版本号与枚举器捕获的版本号对比,确保枚举器获取后堆栈未被修改。
- 接着检查是否为首次调用枚举器:若为首次则走一条路径进行懒加载初始化状态,否则走另一条路径处理已初始化状态。
- 若非首次调用,则检查枚举是否已结束。
- 若未结束,则检查是否仍有元素可枚举。
- 最后解引用底层数组时,会触发边界检查。
新实现将上述步骤缩减一半。它依赖枚举器的构造函数将当前索引初始化为栈长度,使得每次调用MoveNext仅递减该值。当数据耗尽时,计数值将变为负数。这意味着我们可以将大量检查合并为单一检查:
if ((uint)index < (uint)array.Length)
读取任意元素时仅需两条分支路径:版本检查与索引边界验证。这种简化不仅减少了需要处理的代码量和可能预测错误的分支数量,还缩小了成员变量的规模,使其更可能被内联处理。这反过来又大大提高了枚举器对象在栈上分配的可能性。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private Stack<int> _direct = new Stack<int>(Enumerable.Range(0, 10));
private IEnumerable<int> _enumerable = new Stack<int>(Enumerable.Range(0, 10));
[Benchmark]
public int SumDirect()
{
int sum = 0;
foreach (int item in _direct) sum += item;
return sum;
}
[Benchmark]
public int SumEnumerable()
{
int sum = 0;
foreach (int item in _enumerable) sum += item;
return sum;
}
}
| Method | Runtime | Mean | Ratio | Code Size | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|
| SumDirect | .NET 9.0 | 23.317 ns | 1.00 | 331 B | – | NA |
| SumDirect | .NET 10.0 | 4.502 ns | 0.19 | 55 B | – | NA |
| SumEnumerable | .NET 9.0 | 30.893 ns | 1.00 | 642 B | 40 B | 1.00 |
| SumEnumerable | .NET 10.0 | 7.906 ns | 0.26 | 381 B | – | 0.00 |
dotnet/runtime#117341 实现了类似功能,但针对的是 Queue<T>。相较于 Stack<T>,Queue<T> 存在一个有趣的复杂性:它能够绕过底层数组的长度进行循环。对于Stack<T>,我们总能从特定索引开始倒数至0,直接将该索引作为数组偏移量使用;而Queue<T>的起始索引可能位于数组任意位置,从该索引遍历至末尾时可能需要循环回到开头。这种循环操作可通过% array. Length 实现(.NET Framework 中 Queue<T> 即采用此法),但除法运算开销较大。鉴于元素个数绝不会超过数组长度,可采用替代方案:检查是否已遍历至数组末尾,若已遍历则减去数组长度即可获得数组起始位置对应的偏移量。.NET 9 的现有实现正是如此:
if (index >= array.Length)
{
index -= array.Length; // wrap around if needed
}
_currentElement = array[index];
这里包含两条分支:一条用于检查数组长度,另一条用于边界检查。边界检查无法省略,因为即时编译器无法验证索引是否实际在边界内,因此需要采取防御性措施。但我们可以这样重写:
if ((uint)index < (uint)array.Length)
{
_currentElement = array[index];
}
else
{
index -= array.Length;
_currentElement = array[index];
}
队列枚举逻辑上可分为两部分:从头部索引到数组末尾的元素,以及从数组开头到尾部的元素。前者现全部归入首段代码块,仅产生一次分支——JIT可利用比较结果消除边界检查。仅在枚举第二部分时才会触发边界检查。
通过使用benchmarkdotnet的HardwareCounters诊断工具追踪HardwareCounter.BranchInstructions(该诊断工具仅适用于Windows系统),可更直观地呈现分支指令节省效果。需特别说明的是,这些改动不仅提升了吞吐量,还使包装枚举器能够进行栈分配。
// This benchmark was run on Windows for the HardwareCounters diagnoser to work.
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using BenchmarkDotNet.Diagnosers;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HardwareCounters(HardwareCounter.BranchInstructions)]
[MemoryDiagnoser(displayGenColumns: false)]
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private Queue<int> _direct;
private IEnumerable<int> _enumerable;
[GlobalSetup]
public void Setup()
{
_direct = new Queue<int>(Enumerable.Range(0, 10));
for (int i = 0; i < 5; i++)
{
_direct.Enqueue(_direct.Dequeue());
}
_enumerable = _direct;
}
[Benchmark]
public int SumDirect()
{
int sum = 0;
foreach (int item in _direct) sum += item;
return sum;
}
[Benchmark]
public int SumEnumerable()
{
int sum = 0;
foreach (int item in _enumerable) sum += item;
return sum;
}
}
| Method | Runtime | Mean | Ratio | BranchInstructions/Op | Code Size | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|
| SumDirect | .NET 9.0 | 24.340 ns | 1.00 | 79 | 251 B | – | NA |
| SumDirect | .NET 10.0 | 7.192 ns | 0.30 | 37 | 96 B | – | NA |
| SumEnumerable | .NET 9.0 | 30.695 ns | 1.00 | 103 | 531 B | 40 B | 1.00 |
| SumEnumerable | .NET 10.0 | 8.672 ns | 0.28 | 50 | 324 B | – | 0.00 |
ConcurrentDictionary<TKey, TValue> 也参与了优化。该字典通过“桶”集合实现,每个桶包含一个条目链表。其枚举器曾采用相当复杂的结构处理方式,依赖于在switch语句的多个分支间跳转,例如:
switch (_state)
{
case StateUninitialized:
... // Initialize on first MoveNext.
goto case StateOuterloop;
case StateOuterloop:
// Check if there are more buckets in the dictionary to enumerate.
if ((uint)i < (uint)buckets.Length)
{
// Move to the next bucket.
...
goto case StateInnerLoop;
}
goto default;
case StateInnerLoop:
... // Yield elements from the current bucket.
goto case StateOuterloop;
default:
// Done iterating.
...
}
仔细观察可发现这里存在嵌套循环:我们先遍历每个桶,再遍历每个桶的内容。但从JIT编译器的角度看,由于这种结构设计,当前_state的值决定了可能从任意case进入循环。这会形成所谓的“不可约循环”——即具有多个潜在入口点的循环。设想如下情况:
A:
if (someCondition) goto B;
...
B:
if (someOtherCondition) goto A;
标签A和B构成一个循环,但该循环既可通过跳转至A进入,也可通过跳转至B进入。若编译器能证明该循环仅能从A或仅能从B进入,则该循环属于“可化简循环”。不可约循环对编译器而言比可约循环复杂得多,因为它们具有更复杂的控制流和数据流,通常更难分析。dotnet/runtime#116949 将MoveNext方法重写为更典型的while循环,这不仅更易于阅读和维护,还具有可简化性与更高效率。由于代码更精简,该版本支持内联处理并可能启用栈分配。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Collections.Concurrent;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private ConcurrentDictionary<int, int> _ints = new(Enumerable.Range(0, 1000).ToDictionary(i => i, i => i));
[Benchmark]
public int EnumerateInts()
{
int sum = 0;
foreach (var kvp in _ints) sum += kvp.Value;
return sum;
}
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| EnumerateInts | .NET 9.0 | 4,232.8 ns | 1.00 | 56 B | 1.00 |
| EnumerateInts | .NET 10.0 | 664.2 ns | 0.16 | – | 0.00 |
LINQ§
上述示例均展示了使用foreach循环枚举集合的方法,这种做法固然极其常见,但使用LINQ(语言集成查询)来枚举和处理集合同样普遍。对于内存集合,LINQ提供了数百种扩展方法,可对可枚举对象执行映射、过滤、排序等海量操作。其便捷性使其被 无处不在 地使用,因此优化至关重要。.NET每次发布都会改进LINQ,这一趋势在.NET 10中持续延续。
本次版本中,从性能角度最显著的变化是Contains方法的改进。正如在深度解析.NET:与Stephen Toub和Scott Hanselman共探LINQ以及深度解析.NET: 与Stephen Toub和Scott Hanselman深入探讨LINQ中深入探讨过,LINQ方法能够通过专用的内部IEnumerable<T>实现相互传递信息。当调用Select时,可能返回ArraySelectIterator<TSource, TResult>、IListSelectIterator<TSource, TResult>、IListSkipTakeSelectIterator<TSource, TResult>或其他任意类型。每种类型都包含承载源信息的字段(例如IListSkipTakeSelectIterator<TSource, TResult>不仅有IList<TSource>源和Func<TSource, TResult> 选择器,还包含基于先前 Skip 和 Take 调用的最小/最大边界追踪字段),并重写了虚拟方法以支持各类操作的特殊化实现。这意味着 LINQ 方法序列可被优化。例如 source.Where(...). Select(...) 经过双重优化:a) 将过滤器与映射委托合并为单一 IEnumerable<T>,从而消除额外接口分派层的开销;b) 执行针对原始源数据类型的特定操作(例如当 source 为数组时,可直接对数组进行处理而非通过 IEnumerator<T> 间接操作)。
当方法返回恰好是 LINQ 查询结果的 IEnumerable<T> 时,这些优化往往最有效。方法生产者无法预知消费者如何使用数据,消费者也无从知晓生产者的具体实现细节。但由于 LINQ 方法通过 IEnumerable<T> 的具体实现传递上下文,当生产者与消费者方法组合得当,便能实现显著优化。例如某个 IEnumerable<T> 生产者希望始终按升序返回数据,于是编写:
public static IEnumerable<T> GetData()
{
...
return data.OrderBy(s => s.CreatedAt);
}
然而实际情况是,消费者并不需要所有元素,仅需获取首个元素:
T value = GetData().First();
LINQ通过让OrderBy返回的可枚举对象提供专用的First/FirstOrDefault实现来优化此场景:它无需执行O(N log N)排序(或分配大量内存存储所有键值),只需进行O(N)的搜索即可找到源数据中最小的元素——因为该元素恰好是OrderBy最先输出的元素。
Contains同样具备此类优化潜力。例如OrderBy、Distinct和Reverse等操作均涉及非平凡的处理和/或内存分配,但若后续跟随Contains操作,所有这些工作都可被跳过——因为Contains只需直接搜索源数据即可。通过dotnet/runtime#112684,这套优化方案已扩展至Contains,在各类迭代器特化实现中部署了近30个专属版本。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private IEnumerable<int> _source = Enumerable.Range(0, 1000).ToArray();
[Benchmark]
public bool AppendContains() => _source.Append(100).Contains(999);
[Benchmark]
public bool ConcatContains() => _source.Concat(_source).Contains(999);
[Benchmark]
public bool DefaultIfEmptyContains() => _source.DefaultIfEmpty(42).Contains(999);
[Benchmark]
public bool DistinctContains() => _source.Distinct().Contains(999);
[Benchmark]
public bool OrderByContains() => _source.OrderBy(x => x).Contains(999);
[Benchmark]
public bool ReverseContains() => _source.Reverse().Contains(999);
[Benchmark]
public bool UnionContains() => _source.Union(_source).Contains(999);
[Benchmark]
public bool SelectManyContains() => _source.SelectMany(x => _source).Contains(999);
[Benchmark]
public bool WhereSelectContains() => _source.Where(x => true).Select(x => x).Contains(999);
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| AppendContains | .NET 9.0 | 2,931.97 ns | 1.00 | 88 B | 1.00 |
| AppendContains | .NET 10.0 | 52.06 ns | 0.02 | 56 B | 0.64 |
| ConcatContains | .NET 9.0 | 3,065.17 ns | 1.00 | 88 B | 1.00 |
| ConcatContains | .NET 10.0 | 54.58 ns | 0.02 | 56 B | 0.64 |
| DefaultIfEmptyContains | .NET 9.0 | 39.21 ns | 1.00 | – | NA |
| DefaultIfEmptyContains | .NET 10.0 | 32.89 ns | 0.84 | – | NA |
| DistinctContains | .NET 9.0 | 16,967.31 ns | 1.000 | 58656 B | 1.000 |
| DistinctContains | .NET 10.0 | 46.72 ns | 0.003 | 64 B | 0.001 |
| OrderByContains | .NET 9.0 | 12,884.28 ns | 1.000 | 12280 B | 1.000 |
| OrderByContains | .NET 10.0 | 50.14 ns | 0.004 | 88 B | 0.007 |
| ReverseContains | .NET 9.0 | 479.59 ns | 1.00 | 4072 B | 1.00 |
| ReverseContains | .NET 10.0 | 51.80 ns | 0.11 | 48 B | 0.01 |
| UnionContains | .NET 9.0 | 16,910.57 ns | 1.000 | 58664 B | 1.000 |
| UnionContains | .NET 10.0 | 55.56 ns | 0.003 | 72 B | 0.001 |
| SelectManyContains | .NET 9.0 | 2,950.64 ns | 1.00 | 192 B | 1.00 |
| SelectManyContains | .NET 10.0 | 60.42 ns | 0.02 | 128 B | 0.67 |
| WhereSelectContains | .NET 9.0 | 1,782.05 ns | 1.00 | 104 B | 1.00 |
| WhereSelectContains | .NET 10.0 | 260.25 ns | 0.15 | 104 B | 1.00 |
.NET 10 中的 LINQ 还新增了若干方法,包括 Sequence 和 Shuffle。尽管这些新方法的主要目的并非性能优化,但由于其实现方式及与 LINQ 其他优化的协同作用,它们仍能显著提升性能。以 Sequence 为例: Sequence 与 Range 类似,都是数字序列的生成源:
public static IEnumerable<T> Sequence<T>(T start, T endInclusive, T step) where T : INumber<T>
Range仅支持int类型,生成从初始值开始的连续非溢出数列;而Sequence支持任意INumber<>类型,允许使用1以外的步长值(包括负值),并支持在T的最大值或最小值处进行循环。但当条件允许时(例如步长为1),Sequence会尝试利用Range的实现——尽管其公开API仍绑定于int类型,但内部已更新为支持任意T : INumber<T>类型。这意味着Range<T>的所有优化特性均会传递至Sequence<T>。
// dotnet run -c Release -f net10.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private List<short> _values = new();
[Benchmark(Baseline = true)]
public void Fill1()
{
_values.Clear();
for (short i = 42; i <= 1042; i++)
{
_values.Add(i);
}
}
[Benchmark]
public void Fill2()
{
_values.Clear();
_values.AddRange(Enumerable.Sequence<short>(42, 1042, 1));
}
}
| Method | Mean | Ratio |
|---|---|---|
| Fill1 | 1,479.99 ns | 1.00 |
| Fill2 | 37.42 ns | 0.03 |
不过我最喜欢的新 LINQ 方法是 Shuffle(见 dotnet/runtime#112173),一方面因其实用性,另一方面则源于其实现与性能优化。Shuffle 的核心功能是随机化源输入,其逻辑实现本质上相当简单:
public static IEnumerable<T> Shuffle<T>(IEnumerable<T> source)
{
T[] arr = source.ToArray();
Random.Shared.Shuffle(arr);
foreach (T item in arr) yield return item;
}
最坏情况下,该实现本质上与LINQ中的实现相同。正如OrderBy在最坏情况下需要缓冲整个输入——因为任何项都可能最小而需要优先输出——Shuffle同样需要支持概率性地优先输出最后一个元素的可能性。然而,该实现包含多种特殊情况处理机制,使其性能远超当前可能使用的自定义Shuffle实现。
首先,Shuffle与OrderBy具有相似特性——二者均对输入生成排列组合。这意味着针对OrderBy结果的多种优化策略同样适用于Shuffle。例如:对 IList<T> 调用 Shuffle.First 只需随机选取列表元素;Shuffle.Count 直接统计底层源数据即可,因元素顺序不影响结果;Shuffle.Contains 直接在底层源数据中执行包含判断。但最令我称道的当属 Shuffle.Take 与 Shuffle.Take.Contains。
Shuffle.Take提供了有趣的优化空间:单纯使用Shuffle需构建完整洗牌序列,而先调用Shuffle再立即执行Take(N)时,只需从源数据中抽取N个元素。我们仍需确保这N个元素具有均匀随机分布特性——这类似于先执行缓冲区洗牌再从结果数组中选取前N项的效果,但可通过避免全量缓冲的算法实现。我们需要一种仅需遍历源数据一次的算法,在遍历过程中实时筛选元素,且每次仅缓冲N个元素。此时“水库采样”应运而生。我在《.NET 8性能优化》中曾探讨过该技术——JIT编译器将其用于动态PGO实现,而我们同样可将其应用于Shuffle算法。水库采样恰好提供了我们所需的单遍低内存路径:初始化一个“水库”(数组)存放前N个元素,随后在扫描序列剩余部分时,以概率方式将当前元素覆盖水库中的某个元素。该算法确保每个元素进入水库的概率相等,其分布效果等同于完全洗牌后取N个元素,但仅需O(N)空间且仅需遍历一次未知长度的源序列。
// dotnet run -c Release -f net10.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private IEnumerable<int> _source = Enumerable.Range(1, 1000).ToList();
[Benchmark(Baseline = true)]
public List<int> ShuffleTakeManual() => ShuffleManual(_source).Take(10).ToList();
[Benchmark]
public List<int> ShuffleTakeLinq() => _source.Shuffle().Take(10).ToList();
private static IEnumerable<int> ShuffleManual(IEnumerable<int> source)
{
int[] arr = source.ToArray();
Random.Shared.Shuffle(arr);
foreach (var item in arr)
{
yield return item;
}
}
}
| Method | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|
| ShuffleTakeManual | 4.150 us | 1.00 | 4232 B | 1.00 |
| ShuffleTakeLinq | 3.801 us | 0.92 | 192 B | 0.05 |
Shuffle.Take.Contains 更具趣味性。我们面临的概率问题如同脑筋急转弯或SAT题: “我有 totalCount 个物品,其中 equalCount 个与目标值匹配。现在要随机抽取 takeCount 个物品,其中至少有一个是匹配项的概率是多少?”这被称为超几何分布,我们可以利用其实现来解决 Shuffle.Take.Contains 问题。
为便于理解,我们用糖果举例。假设你有一罐100颗果冻豆,其中20颗是你最爱的西瓜味,现在要从100颗中随机抽取5颗;至少抽中一颗西瓜味的概率是多少?要解答这个问题,我们可以逐一推演获得1、2、3、4或5颗西瓜豆的所有可能组合。但不妨换个思路,先分析完全抽不到西瓜豆的概率(悲伤熊猫):
- 首次抽取非西瓜豆的概率等于非西瓜豆数量除以豆子总数,即
(100-20)/100。 - 豆子取出后不会放回罐中,因此第二次抽到非西瓜的概率变为
(99-20)/99(豆子减少一颗,但首次抽中非西瓜,故西瓜数量与先前相同)。 - 第三次抽取时概率变为
(98-20)/98。 - 如此类推。
经过五轮筛选后,概率最终为 (80/100) * (79/99) * (78/98) * (77/97) * (76/96),即约32%。若不抽中西瓜的概率约为32%,则抽中西瓜的概率约为68%。抛开果冻豆不谈,这就是我们的算法:
double probOfDrawingZeroMatches = 1;
for (long i = 0; i < _takeCount; i++)
{
probOfDrawingZeroMatches *= (double)(totalCount - i - equalCount) / (totalCount - i);
}
return Random.Shared.NextDouble() > probOfDrawingZeroMatches;
其净效应是:相较于先洗牌再分别取样和分别存放的朴素实现,我们能更高效地计算结果。
// dotnet run -c Release -f net10.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private IEnumerable<int> _source = Enumerable.Range(1, 1000).ToList();
[Benchmark(Baseline = true)]
public bool ShuffleTakeContainsManual() => ShuffleManual(_source).Take(10).Contains(2000);
[Benchmark]
public bool ShuffleTakeContainsLinq() => _source.Shuffle().Take(10).Contains(2000);
private static IEnumerable<int> ShuffleManual(IEnumerable<int> source)
{
int[] arr = source.ToArray();
Random.Shared.Shuffle(arr);
foreach (var item in arr)
{
yield return item;
}
}
}
| Method | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|
| ShuffleTakeContainsManual | 3,900.99 ns | 1.00 | 4136 B | 1.00 |
| ShuffleTakeContainsLinq | 79.12 ns | 0.02 | 96 B | 0.02 |
.NET 10中的LINQ还新增了若干与性能相关的(至少部分如此)方法,特别是源自dotnet/runtime#110872的LeftJoin和RightJoin。之所以说这些方法涉及性能优化,是因为现有 LINQ 接口已能实现左连接和右连接语义,而新方法能更高效地完成此操作。
Enumerable.Join 实现的是“内连接”,即仅输出两个集合中匹配的元素对。例如以下代码基于字符串的首字母进行连接:
IEnumerable<string> left = ["apple", "banana", "cherry", "date", "grape", "honeydew"];
IEnumerable<string> right = ["aardvark", "dog", "elephant", "goat", "gorilla", "hippopotamus"];
foreach (string result in left.Join(right, s => s[0], s => s[0], (s1, s2) => $"{s1} {s2}"))
{
Console.WriteLine(result);
}
输出结果:
apple aardvark
date dog
grape goat
grape gorilla
honeydew hippopotamus
相比之下,“左连接”(也称为“左外连接”)将产生以下结果:
apple aardvark
banana
cherry
date dog
grape goat
grape gorilla
honeydew hippopotamus
请注意,其输出与“内连接”完全相同,但每个left元素至少对应一行,即使right行中没有匹配元素也是如此。而“右连接”(也称为“右外连接”)将产生以下结果:
apple aardvark
date dog
elephant
grape goat
grape gorilla
honeydew hippopotamus
同样包含与“内连接”相同的输出,但每行至少包含一个right元素对应的行,即使left行中没有匹配项。
在.NET 10之前,没有LeftJoin或RightJoin方法,但可通过组合使用GroupJoin、SelectMany和DefaultIfEmpty实现其语义:
public static IEnumerable<TResult> LeftJoin<TOuter, TInner, TKey, TResult>(
this IEnumerable<TOuter> outer, IEnumerable<TInner> inner,
Func<TOuter, TKey> outerKeySelector, Func<TInner, TKey> innerKeySelector,
Func<TOuter, TInner?, TResult> resultSelector) =>
outer
.GroupJoin(inner, outerKeySelector, innerKeySelector, (o, inners) => (o, inners))
.SelectMany(x => x.inners.DefaultIfEmpty(), (x, i) => resultSelector(x.o, i));
GroupJoin 为每个 outer(“左”)元素创建一个组,该组包含来自 inner(“右”)的所有匹配项。通过SelectMany可将结果展平,最终为每对元素生成输出,并借助DefaultIfEmpty确保始终存在至少一个默认的内侧元素进行配对。RightJoin的实现原理完全相同:实际上只需调用左连接并翻转所有参数即可实现右连接:
public static IEnumerable<TResult> RightJoin<TOuter, TInner, TKey, TResult>(
this IEnumerable<TOuter> outer, IEnumerable<TInner> inner,
Func<TOuter, TKey> outerKeySelector, Func<TInner, TKey> innerKeySelector,
Func<TOuter, TInner?, TResult> resultSelector) =>
inner.LeftJoin(outer, innerKeySelector, outerKeySelector, (i, o) => resultSelector(o, i));
值得庆幸的是,您无需再自行实现这些操作——.NET 10中全新的LeftJoin和RightJoin方法并非如此设计。通过基准测试可直观对比差异:
// dotnet run -c Release -f net10.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Linq;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private IEnumerable<int> Outer { get; } = Enumerable.Sequence(0, 1000, 2);
private IEnumerable<int> Inner { get; } = Enumerable.Sequence(0, 1000, 3);
[Benchmark(Baseline = true)]
public void LeftJoin_Manual() =>
ManualLeftJoin(Outer, Inner, o => o, i => i, (o, i) => o + i).Count();
[Benchmark]
public int LeftJoin_Linq() =>
Outer.LeftJoin(Inner, o => o, i => i, (o, i) => o + i).Count();
private static IEnumerable<TResult> ManualLeftJoin<TOuter, TInner, TKey, TResult>(
IEnumerable<TOuter> outer, IEnumerable<TInner> inner,
Func<TOuter, TKey> outerKeySelector, Func<TInner, TKey> innerKeySelector,
Func<TOuter, TInner?, TResult> resultSelector) =>
outer
.GroupJoin(inner, outerKeySelector, innerKeySelector, (o, inners) => (o, inners))
.SelectMany(x => x.inners.DefaultIfEmpty(), (x, i) => resultSelector(x.o, i));
}
| Method | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|
| LeftJoin_Manual | 29.02 us | 1.00 | 65.84 KB | 1.00 |
| LeftJoin_Linq | 15.23 us | 0.53 | 36.95 KB | 0.56 |
除新增方法外,现有方法也通过其他方式得到优化。dotnet/runtime#112401 由 @miyaji255 提交的改动,优化了在调用 Skip 和/或 Take 之后执行 ToArray 和 ToList 的性能。在Take和Skip专用的迭代器实现中,该PR仅需在ToList和ToArray实现中检查源数据是否易于获取ReadOnlySpan<T>(即T[]或List<T>)。若满足条件,则无需逐个复制元素至目标容器,而是切片获取的范围并调用其CopyTo方法——根据T类型不同,该方法甚至可能实现向量化处理。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private readonly IEnumerable<string> _source = Enumerable.Range(0, 1000).Select(i => i.ToString()).ToArray();
[Benchmark]
public List<string> SkipTakeToList() => _source.Skip(200).Take(200).ToList();
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| SkipTakeToList | .NET 9.0 | 1,218.9 ns | 1.00 |
| SkipTakeToList | .NET 10.0 | 257.4 ns | 0.21 |
.NET 10中的LINQ还针对原生AOT编译进行了若干显著增强。随着各类特殊化实现逐步融入代码库,LINQ相关代码量持续增长。这些优化通常通过从基类Iterator<T>派生专用迭代器实现,该基类包含大量用于执行后续操作(如Contains)的抽象或虚方法。采用原生AOT后,任何对Enumerable.Contains等方法的调用都会导致所有特化实现无法被精简掉,从而显著增加程序集代码体积。因此早在数年前,dotnet/runtime构建系统就引入了System.Linq.dll的多版本构建:一个侧重速度,一个侧重体积。当为 coreclr 构建 System.Linq.dll 时,最终会获得包含所有特化的速度优化版本。而为其他平台(如原生 AOT)构建时,则会得到大小优化版本——该版本舍弃了过去十年间添加的许多 LINQ 优化功能。由于这是构建时做出的决策,使用这些平台的开发者别无选择——正如幼儿园教导的那样:“你得到什么就得接受什么,别抱怨。”但在.NET 10中,若你忘记了幼儿园的教诲而心生不满,现在有了补救措施:感谢dotnet/runtime#111743 和 dotnet/runtime#109978 的推动,该设置现已从构建时配置转变为功能开关。因此,特别是当您为原生AOT发布程序并希望启用所有速度优化时,只需在项目文件中添加<UseSizeOptimizedLinq>false</UseSizeOptimizedLinq>即可如愿以偿。
然而,由于dotnet/runtime#118156的引入,现在对该切换的需求也大幅降低。此前在System.Linq.dll构建中引入大小/速度取舍机制时,所有这些特殊化实现均被舍弃,且未充分分析其权衡关系——由于当时专注于优化体积,无论实际节省多少空间,所有特殊化重写均被移除。然而实际验证显示,多数空间节省微乎其微,而在多种场景下吞吐量代价却相当显著。本次PR重新引入部分影响显著的特殊化实现,这些实现带来的吞吐量提升远超其相对较小的体积代价。
冻结集合§
FrozenDictionary<TKey, TValue> 和 FrozenSet<T> 集合类型在 .NET 8 中引入,专为创建长期存在的集合并进行高频读取的常见场景优化。它们通过延长构造时间来换取更快的读取操作。底层实现通过针对不同数据类型或输入结构的优化化实现来达成此目标。 .NET 9 改进了这些实现,而 .NET 10 则进一步深化了优化。
FrozenDictionary<TKey, TValue> 为 TKey 为 string 的场景投入了大量优化精力,因其是常见用例。该实现还针对TKey为Int32的情况提供特化版本。dotnet/runtime#111886和dotnet/runtime#112298 进一步扩展了该特性,新增了当 TKey 为任意原始整数类型(大小等于或小于 int,如 byte、char、ushort 等)以及由这类原始类型支持的枚举类型(实际应用中绝大多数枚举都属于此类)时的特化方案。特别地,它们处理了这些值密集排列的常见情况:此时字典以数组形式实现,可根据整数值进行索引。这种设计在不消耗过多额外空间的前提下实现了高效查找——仅在值密集排列时启用,因此不会在数组中浪费大量空槽位。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Collections.Frozen;
using System.Net;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "status")]
public partial class Tests
{
private static readonly FrozenDictionary<HttpStatusCode, string> s_statusDescriptions =
Enum.GetValues<HttpStatusCode>().Distinct()
.ToFrozenDictionary(status => status, status => status.ToString());
[Benchmark]
[Arguments(HttpStatusCode.OK)]
public string Get(HttpStatusCode status) => s_statusDescriptions[status];
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Get | .NET 9.0 | 2.0660 ns | 1.00 |
| Get | .NET 10.0 | 0.8735 ns | 0.42 |
FrozenDictionary<TKey, TValue> 和 FrozenSet<T> 在 .NET 9 引入的替代查找功能方面也得到增强。替代查找机制允许获取以不同于 TKey 的键(最常见的是当 TKey 为 string 时使用 ReadOnlySpan<char>)作为键的字典或集合的代理。如前所述,FrozenDictionary<TKey, TValue> 和 FrozenSet<T> 通过根据索引数据特性采用不同实现来达成目标,这种特化通过派生实现覆盖的虚方法实现。JIT 通常能最大限度降低此类虚方法的开销,尤其当集合存储在 static readonly 字段中时。然而,替代查找机制引入了泛型方法参数(即替代键类型)的虚方法(GVM),反而增加了复杂性。在性能优化领域,“GVM”堪称禁忌词汇——运行时难以对其进行优化。虽然替代查找的初衷是提升性能,但GVM的使用大幅削弱了这种性能优势。dotnet/runtime#108732 由@andrewjsaid 提出,通过调整GVM调用频率来解决此问题。该PR将查找操作本身改为泛型虚拟方法,同时引入独立的泛型虚拟方法用于获取执行查找的委托。虽然委托获取仍会产生GVM开销,但获取后的委托可被缓存,后续调用便无需额外开销。此方案显著提升了吞吐量。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Collections.Frozen;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private static readonly FrozenDictionary<string, int> s_d = new Dictionary<string, int>
{
["one"] = 1, ["two"] = 2, ["three"] = 3, ["four"] = 4, ["five"] = 5, ["six"] = 6,
["seven"] = 7, ["eight"] = 8, ["nine"] = 9, ["ten"] = 10, ["eleven"] = 11, ["twelve"] = 12,
}.ToFrozenDictionary();
[Benchmark]
public int Get()
{
var alternate = s_d.GetAlternateLookup<ReadOnlySpan<char>>();
return
alternate["one"] + alternate["two"] + alternate["three"] + alternate["four"] + alternate["five"] +
alternate["six"] + alternate["seven"] + alternate["eight"] + alternate["nine"] + alternate["ten"] +
alternate["eleven"] + alternate["twelve"];
}
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Get | .NET 9.0 | 133.46 ns | 1.00 |
| Get | .NET 10.0 | 81.39 ns | 0.61 |
BitArray§
BitArray 提供精确符合其名称的位数组支持。创建时需指定值的数量,随后可读写每个索引的 bool 值,将对应位设置为 1 或 0。该类型还提供多种辅助操作用于处理整个位数组,例如 And 和 Not 等布尔逻辑运算。在可能的情况下,这些操作会进行向量化处理,利用 SIMD 技术实现每条指令处理多个位。
然而,当需要对位进行自定义操作时,你只有两种选择:使用索引器(或对应的Get和Set方法),这意味着处理每个位都需要多条指令;或者使用CopyTo将所有位提取到单独数组中,这意味着你需要分配(或至少租用)该数组,并在操作位之前承担内存复制的开销。若需就地操作BitArray,目前也缺乏高效的位元回写方案。
dotnet/runtime#116308 添加了 CollectionsMarshal.AsBytes(BitArray) 方法,该方法返回直接引用 BitArray 底层存储的 Span<byte>。这为访问所有位提供了高效途径,从而能够编写(或复用)向量化算法。例如,若需用BitArray表示二进制嵌入(“嵌入”即数据语义的向量化表示,本质上是数字数组,每个数字对应数据的某个维度;二进制嵌入则用单比特表示每个数字)。要判断两个输入的语义相似度,需分别获取其嵌入表示,再计算两者间的距离或相似度。对于二进制嵌入,常用距离度量是“汉明距离”——该算法将位序列对齐后统计不同值的位置数量,例如0b1100与0b1010的汉明距离为2。值得庆幸的是,TensorPrimitives.HammingBitDistance 提供了该算法的实现,接受两个 ReadOnlySpan<T> 参数并计算二者差异位数。借助 CollectionsMarshal.AsBytes,我们现在可直接将该辅助函数应用于 BitArray 内容,既省去了手动编写的麻烦,又能受益于 HammingBitDistance 本身的优化。
// Update benchmark.csproj with a package reference to System.Numerics.Tensors.
// dotnet run -c Release -f net10.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Collections;
using System.Numerics.Tensors;
using System.Runtime.InteropServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private BitArray _bits1, _bits2;
[GlobalSetup]
public void Setup()
{
Random r = new(42);
byte[] bytes = new byte[128];
r.NextBytes(bytes);
_bits1 = new BitArray(bytes);
r.NextBytes(bytes);
_bits2 = new BitArray(bytes);
}
[Benchmark(Baseline = true)]
public long HammingDistanceManual()
{
long distance = 0;
for (int i = 0; i < _bits1.Length; i++)
{
if (_bits1[i] != _bits2[i])
{
distance++;
}
}
return distance;
}
[Benchmark]
public long HammingDistanceTensorPrimitives() =>
TensorPrimitives.HammingBitDistance(
CollectionsMarshal.AsBytes(_bits1),
CollectionsMarshal.AsBytes(_bits2));
}
| Method | Mean | Ratio |
|---|---|---|
| HammingDistanceManual | 1,256.72 ns | 1.00 |
| HammingDistanceTensorPrimitives | 63.29 ns | 0.05 |
本次PR的核心动机是添加AsBytes方法,但此举引发了一系列其他改动,这些改动本身也有助于提升性能。例如:BitArray 的底层存储从原先的 int[] 改为 byte[];在基于 byte[] 的构造函数中,不再逐个读取元素,而是采用了向量化复制操作(基于 int[] 的构造函数已采用且继续采用该机制)。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Collections;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private byte[] _byteData = Enumerable.Range(0, 512).Select(i => (byte)i).ToArray();
[Benchmark]
public BitArray ByteCtor() => new BitArray(_byteData);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| ByteCtor | .NET 9.0 | 160.10 ns | 1.00 |
| ByteCtor | .NET 10.0 | 83.07 ns | 0.52 |
其他集合§
集合类中还有多项值得关注的改进:
List<T>。dotnet/runtime#107683由@karakasa提出,基于.NET 9中的一项变更,旨在提升List<T>使用InsertRange插入ReadOnlySpan<T>的性能。当向已满的List<T>追加元素时,典型流程是分配更大数组、复制所有现有元素(一次数组复制),然后将新元素存储到数组中下一个可用位置。若在 插入 而非 追加 元素时采用相同增长机制,可能导致部分元素被重复复制:首先将所有元素复制到新数组,随后为处理插入操作,可能需要再次复制部分已复制的元素——通过位移它们来为新插入位置腾出空间。极端情况下,若在索引0处插入,需先将所有元素复制到新数组,再将所有元素再次复制以实现位移操作。批量插入元素时同样存在此问题。因此本次PR中,List<T>不再先复制全部元素再移动子集,而是通过复制目标插入区间上下方的元素至正确位置实现扩展,最后将插入元素填入目标区间。// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0 using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args); [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")] public partial class Tests { private readonly int[] _data = [1, 2, 3, 4]; [Benchmark] public List<int> Test() { List<int> list = new(4); list.AddRange(_data); list.InsertRange(0, _data); return list; } }Method Runtime Mean Ratio Test .NET 9.0 48.65 ns 1.00 Test .NET 10.0 30.07 ns 0.62 ConcurrentDictionary<TKey, TValue>。dotnet/runtime#108065 由 @koenigst 提交的改动,调整了ConcurrentDictionary清空时的底层数组大小计算方式。ConcurrentDictionary采用链表数组实现,构造时可通过构造函数参数预设数组大小。由于字典的并发特性及其实现机制,Clear操作需要创建新数组而非复用旧数组部分空间。此前创建新数组时,系统会重置为默认容量。本次PR调整机制,使其记住用户初始请求的容量,并在构建新数组时沿用该初始大小。// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0 using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; using System.Collections.Concurrent; BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args); [MemoryDiagnoser(displayGenColumns: false)] [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")] public partial class Tests { private ConcurrentDictionary<int, int> _data = new(concurrencyLevel: 1, capacity: 1024); [Benchmark] public void ClearAndAdd() { _data.Clear(); for (int i = 0; i < 1024; i++) { _data.TryAdd(i, i); } } }Method Runtime Mean Ratio Allocated Alloc Ratio ClearAndAdd .NET 9.0 51.95 us 1.00 134.36 KB 1.00 ClearAndAdd .NET 10.0 30.32 us 0.58 48.73 KB 0.36 Dictionary<TKey, TValue>。Dictionary是 .NET 中最受欢迎的集合类型之一,而TKey==string则是其中最常用(若非唯一)的形式。dotnet/runtime#117427 使常量字符串的字典查找速度大幅提升。您或许以为这需要复杂的改动,但实际仅需几处关键调整。JIT 编译器已预先识别多种字符串操作方法,并针对常量处理提供了优化实现。本次PR仅需修改Dictionary<TKey, TValue>在优化版TryGetValue查找路径中调用的方法。由于该路径常被内联,TryGetValue的常量参数便可直接作为常量传递给辅助方法(如string.Equals)。// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0 using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args); [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")] public partial class Tests { private Dictionary<string, int> _data = new() { ["a"] = 1, ["b"] = 2, ["c"] = 3, ["d"] = 4, ["e"] = 5 }; [Benchmark] public int Get() => _data["a"] + _data["b"] + _data["c"] + _data["d"] + _data["e"]; }Method Runtime Mean Ratio Get .NET 9.0 33.81 ns 1.00 Get .NET 10.0 14.02 ns 0.41 OrderedDictionary<TKey, TValue>。dotnet/runtime#109324 为TryAdd和TryGetValue新增重载,可返回集合中添加或检索元素的索引。该索引可用于后续字典操作中访问相同存储单元。例如,若要在OrderedDictionary基础上实现AddOrUpdate操作,需执行一到两次操作:先尝试添加项,若发现已存在则更新该项。此时更新操作可直接定位包含元素的精确索引,无需再次进行键值查找。// dotnet run -c Release -f net10.0 --filter "*" using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args); [HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")] public partial class Tests { private OrderedDictionary<string, int> _dictionary = new(); [Benchmark(Baseline = true)] public void Old() => AddOrUpdate_Old(_dictionary, "key", k => 1, (k, v) => v + 1); [Benchmark] public void New() => AddOrUpdate_New(_dictionary, "key", k => 1, (k, v) => v + 1); private static void AddOrUpdate_Old(OrderedDictionary<string, int> d, string key, Func<string, int> addFunc, Func<string, int, int> updateFunc) { if (d.TryGetValue(key, out int existing)) { d[key] = updateFunc(key, existing); } else { d.Add(key, addFunc(key)); } } private static void AddOrUpdate_New(OrderedDictionary<string, int> d, string key, Func<string, int> addFunc, Func<string, int, int> updateFunc) { if (d.TryGetValue(key, out int existing, out int index)) { d.SetAt(index, updateFunc(key, existing)); } else { d.Add(key, addFunc(key)); } } }Method Mean Ratio Old 6.961 ns 1.00 New 4.201 ns 0.60 ImmutableArray<T>。ImmutableCollectionsMarshal类已提供AsArray方法,可从ImmutableArray<T>中获取底层T[]数组。但若使用ImmutableArray<T>.Builder时,此前无法访问其底层存储。通过 dotnet/runtime#112177 引入的AsMemory方法,现可将底层存储以Memory<T>形式获取。InlineArray。 .NET 8 引入了InlineArrayAttribute,可用于修饰仅含单个字段的结构体;该属性接受一个计数参数,运行时会按该次数复制结构体的字段,效果如同逻辑上反复复制粘贴该字段。运行时还会确保存储区域连续且对齐正确,使得当索引集合指向结构体起始位置时,即可将其作为数组使用。恰巧存在这样的集合类型:Span<T>。C# 12 进一步简化了将此类带属性的结构体视为 Span 的操作,例如:Copy[InlineArray(8)] internal struct EightStrings { private string _field; } ... EightStrings strings = default; Span<string> span = strings;C#编译器会自动生成利用此特性的代码。例如使用集合表达式初始化Span时,编译器很可能生成
InlineArray。当我编写以下代码:Copy
public void M(int a, int b, int c, int d) { Span<int> span = [a, b, c, d]; }编译器会生成类似以下等效代码:
Copy
public void M(int a, int b, int c, int d) { <>y__InlineArray4<int> buffer = default(<>y__InlineArray4<int>); <PrivateImplementationDetails>.InlineArrayElementRef<<>y__InlineArray4<int>, int>(ref buffer, 0) = a; <PrivateImplementationDetails>.InlineArrayElementRef<<>y__InlineArray4<int>, int>(ref buffer, 1) = b; <PrivateImplementationDetails>.InlineArrayElementRef<<>y__InlineArray4<int>, int>(ref buffer, 2) = c; <PrivateImplementationDetails>.InlineArrayElementRef<<>y__InlineArray4<int>, int>(ref buffer, 3) = d; <PrivateImplementationDetails>.InlineArrayAsSpan<<>y__InlineArray4<int>, int>(ref buffer, 4); }其中将
<>y__InlineArray4定义为:Copy
[StructLayout(LayoutKind.Auto)] [InlineArray(4)] internal struct <>y__InlineArray4<T> { [CompilerGenerated] private T _element0; }此机制在其他场景同样适用。例如,C# 13引入了对数组以外集合(包括span)使用
params的支持,因此现在可以这样编写:Copy
public void Caller(int a, int b, int c, int d) => M(a, b, c, d); public void M(params ReadOnlySpan<int> span) { }对于
Caller,编译器生成的代码与之前展示的非常相似,它同样会构造这样的InlineArray类型。正如你所料,由于编译器生成此类型的功能广受欢迎,导致大量此类类型被生成。每个类型都对应特定长度,因此虽然编译器会复用它们,但:a) 为覆盖不同长度仍需生成大量类型;b) 这些类型作为内部类型生成在每个需要它们的程序集内,最终可能造成大量冗余。仅以.NET 9的共享框架(即随运行时发布的核心库如System.Private.CoreLib)为例,此类类型约有140种…且全部适用于不超过8的大小。针对.NET 10,dotnet/runtime#113403新增了一组公共类型InlineArray2<T>、InlineArray3<T>等,可覆盖编译器原本需要生成的绝大多数尺寸类型。近期 C# 编译器将更新为优先使用这些新类型(当可用时),从而显著节省内存占用。
I/O§
在之前的 .NET 版本中,我们曾集中投入大量资源改进特定领域的 I/O 性能,例如在 .NET 6 中完全重写了 FileStream。虽然 .NET 10 并未进行如此全面的 I/O 优化,但仍有若干针对性改进措施,在特定场景下仍能产生可量化的性能提升。
在 Unix 系统中,当创建 MemoryMappedFile 且其未关联特定 FileStream 时,需要为 MMF 的数据创建某种后备内存。在 Linux 上,系统会尝试使用 shm_open 创建具有相应语义的共享内存对象。然而自 MemoryMappedFile 最初在 Linux 上启用以来,Linux 内核已新增对匿名文件的支持,并引入了创建此类文件的 memfd_create 函数。这些机制更适合MemoryMappedFile且效率显著提升,因此@am11在dotnet/runtime#105178中提出,当可用时应切换至memfd_create。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.IO.MemoryMappedFiles;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
[Benchmark]
public void MMF()
{
using MemoryMappedFile mff = MemoryMappedFile.CreateNew(null, 12345);
using MemoryMappedViewAccessor accessor = mff.CreateViewAccessor();
}
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| MMF | .NET 9.0 | 9.916 us | 1.00 |
| MMF | .NET 10.0 | 6.358 us | 0.64 |
FileSystemWatcher 在 dotnet/runtime#116830 中得到改进。此PR的主要目的是修复内存泄漏问题:在Windows系统中,若在FileSystemWatcher使用过程中释放其引用,可能导致部分对象泄漏。同时该PR还解决了Windows特有的性能问题。FileSystemWatcher需要向操作系统传递缓冲区以便系统填充文件变更信息。这意味着FileSystemWatcher会分配一个托管数组,随即将其缓冲区固定以便向本机代码传递指针。在特定使用场景下(尤其创建大量实例时),这种固定操作会导致堆内存碎片化问题。有趣的是,该数组实际上从未以数组形式被使用:所有写入操作均通过传递给操作系统的指针在原生代码中完成,而托管代码中读取事件的操作则完全通过span实现。这意味着数组特性并不重要,直接分配原生缓冲区(无需固定)反而更优。
// Run on Windows.
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
[Benchmark]
public void FSW()
{
using FileSystemWatcher fsw = new(Environment.CurrentDirectory);
fsw.EnableRaisingEvents = true;
}
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| FSW | .NET 9 | 61.46 us | 1.00 | 8944 B | 1.00 |
| FSW | .NET 10 | 61.21 us | 1.00 | 744 B | 0.08 |
BufferedStream 因 @ANahr 提交的 dotnet/runtime#104822 提案获得性能提升。BufferedStream 存在一个令人费解且棘手的矛盾,据我所知这个矛盾自诞生以来就一直存在。显然过去曾有人尝试解决,但由于 .NET Framework 对向后兼容性的极端重视(其核心特性就是框架不改变),这个问题始终未能修复。代码中甚至存在相关注释:
// We should not be flushing here, but only writing to the underlying stream, but previous version flushed, so we keep this.
BufferedStream 顾名思义,它会封装底层的 Stream 并缓冲对其的访问。例如,若配置缓冲区大小为 1000,每次向 BufferedStream 写入 100 字节时,前 10 次写入仅会填充缓冲区,底层 Stream 完全不会被触及。直到第11次写入时缓冲区才满,此时才需要将缓冲区内容刷新(即写入)到底层Stream。目前为止都很好。此外,刷新到底层流与刷新底层流本身存在区别。两者看似相似实则不同:前者实质上是调用_stream.Write(buffer)将缓冲区写入该流;后者则是调用_stream.Flush()强制将该流缓冲的内容传播至其底层目标。当向BufferedStream执行Write操作时,该类本不应承担后者职责——通常确实如此,但存在一个例外:尽管多数写入相关方法不会调用_stream.Flush(),WriteByte却因特殊原因会执行此操作。尤其当 BufferedStream 配置了小缓冲区,且底层流的刷新操作成本较高时(例如 DeflateStream.Flush 会强制压缩并输出所有缓冲字节),这种行为不仅导致不一致,更会严重影响性能。本次变更仅修复了不一致性,使 WriteByte 不再强制刷新底层流。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.IO.Compression;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private byte[] _bytes;
[GlobalSetup]
public void Setup()
{
_bytes = new byte[1024 * 1024];
new Random(42).NextBytes(_bytes);
}
[Benchmark]
public void WriteByte()
{
using Stream s = new BufferedStream(new DeflateStream(Stream.Null, CompressionLevel.SmallestSize), 256);
foreach (byte b in _bytes)
{
s.WriteByte(b);
}
}
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| WriteByte | .NET 9.0 | 73.87 ms | 1.00 |
| WriteByte | .NET 10.0 | 17.77 ms | 0.24 |
在讨论压缩技术时,值得一提的是.NET 10中System.IO.Compression库的若干改进。正如《.NET 9性能改进》所述,DeflateStream/GZipStream/ZLibStream是围绕底层原生zlib库封装的托管封装器。长期以来,底层使用的都是原始的zlib库(madler/zlib)。后来改用英特尔的zlib-intel分支(intel/zlib),但该分支现已存档且不再维护。在 .NET 9 中,库切换为使用 zlib-ng (zlib-ng/zlib-ng)——这是经过现代化改造的分支,维护良好且针对大量硬件架构进行了优化。 .NET 9 基于 zlib-ng 2.2.1 版本。dotnet/runtime#118457 将其更新至 zlib-ng 2.2.5 版本。相较于2.2.1版本,zlib-ng本身实现了多项性能优化,这些改进被.NET 10继承,例如对AVX2和AVX512指令集的更高效利用。但最重要的是,本次更新包含一项回滚,撤销了2.2.0版本中一项清理变更; 该变更原本移除了某个函数的临时解决方案——该函数曾存在性能问题,但后来发现已不再缓慢。然而实际情况是,在某些场景下(处理长且 高度 可压缩的数据时),该函数仍会导致吞吐量下降。2.2.5版本的修复方案重新启用了临时解决方案以解决此退化问题。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.IO.Compression;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private byte[] _data = new HttpClient().GetByteArrayAsync(@"https://raw.githubusercontent.com/dotnet/runtime-assets/8d362e624cde837ec896e7fff04f2167af68cba0/src/System.IO.Compression.TestData/DeflateTestData/xargs.1").Result;
[Benchmark]
public void Compress()
{
using ZLibStream z = new(Stream.Null, CompressionMode.Compress);
for (int i = 0; i < 100; i++)
{
z.Write(_data);
}
}
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Compress | .NET 9.0 | 202.79 us | 1.00 |
| Compress | .NET 10.0 | 70.45 us | 0.35 |
zlib的托管封装也获得改进。dotnet/runtime#113587由@edwardneal提交,优化了从底层Stream读取多个gzip有效负载的情形。基于其特性,多个完整的gzip负载可连续写入,此时单个GZipStream即可将它们视为单一数据进行解压。此前每次遇到负载边界时,托管封装层都会丢弃旧的互操作句柄并创建新句柄。而本次更新利用底层zlib库的重置功能,省去了释放和重新分配底层数据结构的开销。以下是一个极端偏颇的微基准测试(包含1000个gzip负载的流,每个解压后仅生成单字节数据),虽突出最坏情况,却精准揭示了问题本质:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.IO.Compression;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private MemoryStream _data;
[GlobalSetup]
public void Setup()
{
_data = new MemoryStream();
for (int i = 0; i < 1000; i++)
{
using GZipStream gzip = new(_data, CompressionMode.Compress, leaveOpen: true);
gzip.WriteByte(42);
}
}
[Benchmark]
public void Decompress()
{
_data.Position = 0;
using GZipStream gzip = new(_data, CompressionMode.Decompress, leaveOpen: true);
gzip.CopyTo(Stream.Null);
}
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Decompress | .NET 9.0 | 331.3 us | 1.00 |
| Decompress | .NET 10.0 | 104.3 us | 0.31 |
位于这些流之上的其他组件(如ZipArchive)也得到了优化。dotnet/runtime#103153由@edwardneal 提交的更新 将 ZipArchive 改为不依赖 BinaryReader 和 BinaryWriter,从而规避其底层缓冲区分配问题,并能更精细地控制数据编码/解码及读写的时机与方式。而dotnet/runtime#102704由@edwardneal提交,在更新ZipArchive时减少内存消耗和分配。过去更新ZipArchive相当于“重写整个世界”:它会将每个条目的数据加载到内存中,并重写所有文件头、条目数据以及“中央目录”(zip格式中用于记录归档内所有条目的目录)。大型归档文件会产生成比例的巨大内存分配。此PR引入变更追踪与条目排序机制,仅重写首个实际受影响条目(或变长元数据/数据发生变更的条目)对应的文件片段,而非全盘重写。此优化效果显著。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.IO.Compression;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private Stream _zip = new MemoryStream();
[GlobalSetup]
public void Setup()
{
using ZipArchive zip = new(_zip, ZipArchiveMode.Create, leaveOpen: true);
Random r = new(42);
for (int i = 0; i < 1000; i++)
{
byte[] fileBytes = new byte[r.Next(512, 2048)];
r.NextBytes(fileBytes);
using Stream s = zip.CreateEntry($"file{i}.txt").Open();
s.Write(fileBytes);
}
}
[Benchmark]
public void Update()
{
_zip.Position = 0;
using ZipArchive zip = new(_zip, ZipArchiveMode.Update, leaveOpen: true);
zip.GetEntry("file987.txt")?.Delete();
}
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| Update | .NET 9.0 | 987.8 us | 1.00 | 2173.9 KB | 1.00 |
| Update | .NET 10.0 | 354.7 us | 0.36 | 682.22 KB | 0.31 |
(ZipArchive与ZipFile还通过dotnet/runtime#114421新增异步API,这项长期待办功能支持在加载、操作和保存zip文件时使用异步I/O。)
最后,在性能与可靠性之间取得平衡,@mpidash 提交的 dotnet/roslyn-analyzers#7390 为 StreamReader.EndOfStream 添加了新的分析器。StreamReader.EndOfStream看似无害,实则暗藏玄机。其本意是判断读取器是否到达底层Stream的末尾。逻辑看似简单:若StreamReader仍缓存着先前读取的数据,显然未达末尾。若读取器曾遇到文件结束标志(例如Read返回0),则显然已到达末尾。但在其他所有情况下,若不执行读取操作就无法确定是否到达流末尾(至少在一般情况下如此),这意味着该属性做了属性永远不该做的事:执行I/O操作。更糟的是,该读取操作可能阻塞进程——例如当Stream表示Socket的网络流时,读取操作将阻塞直至接收到数据。而最恶劣的情况出现在异步方法中,例如:
while (!reader.EndOfStream)
{
string? line = await reader.ReadLineAsync();
...
}
此时EndOfStream不仅可能执行I/O并阻塞,更在应完全异步等待的方法中引发阻塞。
更令人沮丧的是,EndOfStream在上述循环中根本毫无用处。当到达流尾时,ReadLineAsync会返回空字符串,因此更优的循环实现应为:
while (await reader.ReadLineAsync() is string line)
{
...
}
更简洁、更高效,且消除了同步I/O的定时炸弹隐患。得益于此新分析器,任何在异步方法中使用EndOfStream的行为都将触发CA2024:

网络通信§
网络相关操作几乎存在于所有现代工作负载中。过去的.NET版本投入大量精力削减网络开销,因为这些组件在关键路径中反复使用,累积开销不容忽视。.NET 10延续了优化趋势。
正如先前核心原语所示,得益于@edwardneal提交的dotnet/runtime#102144,IPAddress与IPNetwork均已具备UTF8解析能力。与核心库中多数同类类型相同,基于 UTF8 的实现与基于 UTF16 的实现基本相同,通过以 byte 与 char 为参数的泛型方法共享大部分代码。由于重点支持 UTF8,不仅可直接解析 UTF8 字节而无需转码,现有代码的运行速度也略有提升。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Net;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "s")]
public partial class Tests
{
[Benchmark]
[Arguments("Fe08::1%13542")]
public IPAddress Parse(string s) => IPAddress.Parse(s);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Parse | .NET 9.0 | 71.35 ns | 1.00 |
| Parse | .NET 10.0 | 54.60 ns | 0.77 |
得益于dotnet/runtime#111433,IPAddress类型现已集成IsValid和IsValidUtf8方法。此前虽可通过TryParse验证地址有效性,但成功时会分配IPAddress对象;若仅需判断有效性而不需要结果对象,这种额外分配便显得浪费。
// dotnet run -c Release -f net10.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Net;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private string _address = "123.123.123.123";
[Benchmark(Baseline = true)]
public bool TryParse() => IPAddress.TryParse(_address, out _);
[Benchmark]
public bool IsValid() => IPAddress.IsValid(_address);
}
| Method | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|
| TryParse | 26.26 ns | 1.00 | 40 B | 1.00 |
| IsValid | 21.88 ns | 0.83 | – | 0.00 |
上述基准测试中使用的Uri也获得显著改进。事实上,.NET 10中我最青睐的改进之一就在于Uri。该特性本身并非性能优化,但其带来了一些有趣的性能相关影响。具体而言,由于实现细节限制,Uri历来存在长度限制。Uri会追踪输入中的各种偏移量,例如主机名起始位置、路径起始位置、查询字符串起始位置等。实现者选择使用ushort而非int来存储这些值。这意味着Uri的最大长度被限制在ushort能描述的范围——即65,535个字符。这听起来像是荒谬冗长的Uri,似乎无人需要突破此限制……直到你考虑到数据URI。数据URI将任意字节序列(通常采用Base64编码)直接嵌入URI本身。这种机制允许文件通过链接直接呈现,已成为AI相关服务传输图像等数据负载的常见方式。然而即使是中等尺寸的图像,加上Base64编码约33%的增量,也极易突破65K字符限制。dotnet/runtime#117287 终于移除了该限制,现在 Uri 可用于表示超大数据 URI(如需使用)。但此举存在性能影响(除Uri大小因额外ushort到int字节转换增加数个百分点外)。具体而言,Uri实现了路径压缩机制,例如:
Console.WriteLine(new Uri("http://test/hello/../hello/../hello"));
将输出:
http://test/hello
事实证明,实现路径压缩的算法是O(N^2)。哎呀。在65K字符的限制下,这种二次复杂度并不构成安全隐患(尽管O(N^2)操作有时会引发风险——若N无界,攻击者可通过执行N次操作迫使目标执行不成比例的更多工作)。但若完全取消限制,风险便会显现。因此dotnet/runtime#117820通过将路径压缩优化为O(N)复杂度来弥补这一缺陷。虽然在常规情况下,路径压缩对Uri构造的影响有限,但在特殊情况下,即使在旧限制下,该变更仍能带来可测量的性能提升。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private string _input = $"http://host/{string.Concat(Enumerable.Repeat("a/../", 10_000))}{new string('a', 10_000)}";
[Benchmark]
public Uri Ctor() => new Uri(_input);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Ctor | .NET 9.0 | 18.989 us | 1.00 |
| Ctor | .NET 10.0 | 2.228 us | 0.12 |
同理,URI越长,构造函数中所需的验证工作量就越大。Uri构造函数需要检查输入中是否存在需要特殊处理的Unicode字符。通过dotnet/runtime#107357,Uri现可借助SearchValues替代逐字符检查机制,更高效地排除或定位需深度分析的字符位置。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private string _uri;
[GlobalSetup]
public void Setup()
{
byte[] bytes = new byte[40_000];
new Random(42).NextBytes(bytes);
_uri = $"data:application/octet-stream;base64,{Convert.ToBase64String(bytes)}";
}
[Benchmark]
public Uri Ctor() => new Uri(_uri);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Ctor | .NET 9.0 | 19.354 us | 1.00 |
| Ctor | .NET 10.0 | 2.041 us | 0.11 |
Uri还进行了其他改动,在多种场景下进一步降低了构造成本。当URI主机为IPv6地址时(例如http:// [2603:1020:201:10::10f],dotnet/runtime#117292 识别出作用域标识符相对罕见,通过降低无作用域标识符场景的开销,换取略微增加含作用域标识符场景的开销。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
[Benchmark]
public string CtorHost() => new Uri("http://[2603:1020:201:10::10f]").Host;
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| CtorHost | .NET 9.0 | 304.9 ns | 1.00 | 208 B | 1.00 |
| CtorHost | .NET 10.0 | 254.2 ns | 0.83 | 216 B | 1.04 |
(需注意,由于前文所述取消长度限制所需的额外空间,.NET 10的分配比.NET 9大8字节。)
dotnet/runtime#117289 还优化了需要规范化处理的 URI 构造逻辑,通过在跨度上应用规范化例程(该功能由dotnet/runtime#110465引入)替代输入字符串的分配操作,从而节省部分内存分配。
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
[Benchmark]
public Uri Ctor() => new("http://some.host.with.ümlauts/");
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| Ctor | .NET 9.0 | 377.6 ns | 1.00 | 440 B | 1.00 |
| Ctor | .NET 10.0 | 322.0 ns | 0.85 | 376 B | 0.85 |
HTTP 协议栈也进行了多项优化。首先,HttpClient 和 HttpContent 的下载辅助方法得到增强。这些类型提供了针对最常见数据获取场景的辅助方法:虽然开发者可直接获取响应流并高效处理,但对于“直接获取完整响应字符串”或“直接获取完整响应字节数组”这类简单常见需求,GetStringAsync和GetByteArrayAsync能实现极简操作。dotnet/runtime#109642 修改了这些方法的运作方式,以更有效地管理所需的临时缓冲区——尤其当服务器未声明 Content-Length 时,客户端无法预先知晓数据量大小,从而无法合理分配缓冲空间。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Net;
using System.Net.Sockets;
using System.Text;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private HttpClient _client = new();
private Uri _uri;
[GlobalSetup]
public void Setup()
{
Socket listener = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
listener.Bind(new IPEndPoint(IPAddress.Loopback, 0));
listener.Listen(int.MaxValue);
_ = Task.Run(async () =>
{
byte[] header = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"u8.ToArray();
byte[] chunkData = Enumerable.Range(0, 100).SelectMany(_ => "abcdefghijklmnopqrstuvwxyz").Select(c => (byte)c).ToArray();
byte[] chunkHeader = Encoding.UTF8.GetBytes($"{chunkData.Length:X}\r\n");
byte[] chunkFooter = "\r\n"u8.ToArray();
byte[] footer = "0\r\n\r\n"u8.ToArray();
while (true)
{
var server = await listener.AcceptAsync();
server.NoDelay = true;
using StreamReader reader = new(new NetworkStream(server), Encoding.ASCII);
while (true)
{
while (!string.IsNullOrEmpty(await reader.ReadLineAsync())) ;
await server.SendAsync(header);
for (int i = 0; i < 100; i++)
{
await server.SendAsync(chunkHeader);
await server.SendAsync(chunkData);
await server.SendAsync(chunkFooter);
}
await server.SendAsync(footer);
}
}
});
var ep = (IPEndPoint)listener.LocalEndPoint!;
_uri = new Uri($"http://{ep.Address}:{ep.Port}/");
}
[Benchmark]
public async Task<byte[]> ResponseContentRead_ReadAsByteArrayAsync()
{
using HttpResponseMessage resp = await _client.GetAsync(_uri);
return await resp.Content.ReadAsByteArrayAsync();
}
[Benchmark]
public async Task<string> ResponseHeadersRead_ReadAsStringAsync()
{
using HttpResponseMessage resp = await _client.GetAsync(_uri, HttpCompletionOption.ResponseHeadersRead);
return await resp.Content.ReadAsStringAsync();
}
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| ResponseContentRead_ReadAsByteArrayAsync | .NET 9.0 | 1.438 ms | 1.00 | 912.71 KB | 1.00 |
| ResponseContentRead_ReadAsByteArrayAsync | .NET 10.0 | 1.166 ms | 0.81 | 519.12 KB | 0.57 |
| ResponseHeadersRead_ReadAsStringAsync | .NET 9.0 | 1.528 ms | 1.00 | 1166.77 KB | 1.00 |
| ResponseHeadersRead_ReadAsStringAsync | .NET 10.0 | 1.306 ms | 0.86 | 773.3 KB | 0.66 |
dotnet/runtime#117071 减少了与 HTTP 头验证相关的开销。在 System.Net.Http 实现中,部分标头配备专用解析器,而多数标头(尤其是服务自定义的标头)则未配备。此 PR 指出:对于后者,所需验证仅限于检查是否存在禁止的换行符,因此无需为所有标头创建对象。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Net.Http.Headers;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private readonly HttpResponseHeaders _headers = new HttpResponseMessage().Headers;
[Benchmark]
public void Add()
{
_headers.Clear();
_headers.Add("X-Custom", "Value");
}
[Benchmark]
public object GetValues()
{
_headers.Clear();
_headers.TryAddWithoutValidation("X-Custom", "Value");
return _headers.GetValues("X-Custom");
}
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| Add | .NET 9.0 | 28.04 ns | 1.00 | 32 B | 1.00 |
| Add | .NET 10.0 | 12.61 ns | 0.45 | – | 0.00 |
| GetValues | .NET 9.0 | 82.57 ns | 1.00 | 64 B | 1.00 |
| GetValues | .NET 10.0 | 23.97 ns | 0.29 | 32 B | 0.50 |
针对HTTP/2用户,dotnet/runtime#112719通过调整HPackDecoder的缓冲区增长策略,将初始缓冲区大小设定为预期场景而非最坏情况,从而降低了每连接内存消耗。(注:“HPACK”是HTTP/2采用的头部压缩算法,通过客户端与服务器共享的表来管理高频传输的头部信息。)微基准测试中难以精确量化该优化效果,因为实际应用中连接会复用(且此处效益并非源于临时分配,而是体现在连接密度和整体工作集优化上), 但通过采取不推荐的做法——为每次请求创建新的HttpClient(更准确地说是不应为每次请求创建新处理器,因为这会清空连接池及其所含连接…这对应用有害,却恰好符合微基准测试需求)——我们仍能窥见其效果。
// For this benchmark, change the benchmark.csproj to start with:
// <Project Sdk="Microsoft.NET.Sdk.Web">
// instead of:
// <Project Sdk="Microsoft.NET.Sdk">
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using System.Net;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Microsoft.AspNetCore.Server.Kestrel.Core;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private WebApplication _app;
[GlobalSetup]
public async Task Setup()
{
var builder = WebApplication.CreateBuilder();
builder.Logging.SetMinimumLevel(LogLevel.Warning);
builder.WebHost.ConfigureKestrel(o => o.ListenLocalhost(5000, listen => listen.Protocols = HttpProtocols.Http2));
_app = builder.Build();
_app.MapGet("/hello", () => Results.Text("hi from kestrel over h2c\n"));
var serverTask = _app.RunAsync();
await Task.Delay(300);
}
[GlobalCleanup]
public async Task Cleanup()
{
await _app.StopAsync();
await _app.DisposeAsync();
}
[Benchmark]
public async Task Get()
{
using var client = new HttpClient()
{
DefaultRequestVersion = HttpVersion.Version20,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
};
var response = await client.GetAsync("http://localhost:5000/hello");
}
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| Get | .NET 9.0 | 485.9 us | 1.00 | 83.19 KB | 1.00 |
| Get | .NET 10.0 | 445.0 us | 0.92 | 51.79 KB | 0.62 |
此外,在 Linux 和 macOS 系统上,所有 HTTP 操作(更广泛地说,所有套接字交互)都因 dotnet/runtime#109052 而略微提速——该修复消除了每次在 Socket 上完成的异步操作中对 ConcurrentDictionary<> 的查找。
对于所有原生AOT爱好者,dotnet/runtime#117012还新增了一个功能开关,可从HttpClient中剔除HTTP/3实现。若您完全不使用HTTP/3,这将带来相当可观的“免费”空间节省。
搜索§
有人曾告诉我计算机科学“本质上就是排序与搜索”。这话不假。搜索功能以各种形式存在于众多应用与服务中,已成为不可或缺的组成部分。
正则表达式§
无论你钟爱还是厌恶其简洁的语法,正则表达式始终是软件开发不可或缺的组成部分,既应用于软件本身,也融入开发流程。因此自平台早期起,.NET 就为其提供了强大的支持,System.Text.RegularExpressions 命名空间便提供了功能丰富的正则表达式能力。Regex 的性能在 .NET 5 中得到显著提升(参见.NET 5 中的正则表达式性能改进),并在 .NET 7 中再度优化,同时新增大量功能(详见.NET 7 中的正则表达式改进)。此后每个版本都持续改进,.NET 10 亦不例外。
正如我在先前关于正则表达式与性能的博文中所述,正则引擎主要有两种实现方式:支持回溯与不支持回溯。无回溯引擎通常通过创建某种有限自动机来表示模式,然后在处理输入字符时,在确定性有限自动机(DFA,即每次只能处于单一状态)或非确定性有限自动机(NFA,即可同时处于多个状态)中移动,实现状态转换。非回溯引擎的核心优势在于能提供线性时间复杂度保证:长度为N的输入字符串在最坏情况下仅需O(N)时间处理。其主要缺陷是无法支持现代正则表达式引擎中常见的功能,例如后向引用。回溯引擎之所以得名,在于其能进行“回溯”操作——尝试一种匹配方案后若失败,便回溯至先前状态尝试其他方案。例如当正则表达式模式为\w * \d(匹配任意数量的单词字符后跟单个数字)时,若输入字符串为“12”, 回溯引擎通常会先尝试将‘1’和‘2’都视为字符,随后发现无法满足\d的匹配条件,于是回溯至仅将‘1’视为\w* 的匹配结果,并将‘2’留给\d进行匹配。回溯机制支撑着引擎实现后向引用、可变长度环绕匹配、条件表达式等功能。其性能表现通常优异,尤其在平均和最佳情况下。但关键缺陷在于最坏情况:某些模式可能遭遇“灾难性回溯”——当回溯导致对同一输入反复探索时,可能消耗远超线性时间的资源。
自 .NET 7 起,.NET 引入了可选的非回溯引擎,当使用 RegexOptions. NonBacktracking 时默认启用。否则无论使用默认解释器、编译为 IL 的正则表达式(RegexOptions.Compiled),还是通过正则表达式源代码生成器生成的自定义 C# 实现([GeneratedRegex(...)]),均采用回溯引擎。这些回溯引擎虽能实现卓越性能,但因其回溯特性,在最坏情况下可能导致性能严重下降。因此通常建议为Regex指定超时限制,尤其在使用来源不明的模式时。不过回溯引擎仍可采取措施缓解部分回溯问题,特别是从源头避免某些回溯操作的发生。
后退引擎提供的主要后退减少工具之一是“原子”构造。某些正则表达式语法通过“占有量词”实现此功能,而包括.NET在内的其他语法则通过“原子组”实现。它们本质相同,只是语法表达方式不同。在.NET正则表达式语法中,原子组是指永远不会被后退进入的分组。以先前 \w * \d 的示例为例,我们可以将 \w* 循环包裹在原子组中:(?>\w * )\d。这样做后,无论 \w* 消耗了什么内容,在退出该组并继续匹配模式后续内容时,都不会因回溯而改变。因此若尝试用此模式匹配“12”,将导致失败:\w *会消耗两个字符,导致\d无匹配对象,且由于\w* 被原子组包裹而无回溯空间,故不会触发回溯机制。
在此示例中,将\w* 包裹为原子组改变了模式的含义,因此正则引擎无法自动选择此操作。但实际存在大量情况:将可能引发回溯的构造包裹为原子组后,行为并无明显变化——因为任何可能发生的回溯都可证明永远不会产生有效结果。以模式 a * b 为例,其与 (?>a* )b 具有可观察的等效性——后者表明 a* 不应被回溯。这是因为 a* 无法“回退”任何元素(仅能回退 a)来匹配后续模式(仅匹配 b)。因此,回溯引擎将a * b的处理方式转换为等效于(?>a* )b的处理逻辑是合理的。自.NET 5起,.NET正则表达式引擎便具备此类转换能力。这能带来显著的吞吐量提升。采用回溯机制时,我们需要为每个可能的回溯位置执行回溯构造之后的所有操作。例如在 \w * SOMEPATTERN 中,若 w* 初始成功消耗 100 个字符,我们可能需要尝试匹配 SOMEPATTERN 多达 100 次——因为每次回溯时都需重新评估 SOMEPATTERN,且最多可能回溯 100 次。若改用 (?>\w*) 则可消除其中所有重复匹配!这种将回溯构造自动转换为非回溯的能力,可能带来巨大的性能提升。自 .NET 5 以来,几乎每次 .NET 版本更新都会扩大自动转换的模式集,包括 .NET 10 在内。
让我们从dotnet/runtime#117869开始,该提案向正则表达式优化器传授了更多关于“不相交”集合的知识。回顾前文提到的 a * b 示例,我曾指出可将 a* 循环设为原子操作,因为 a* 阶段不可能匹配到后续 b 阶段的内容。这正是自动原子性的核心原理:当循环末尾的匹配结果必然与后续内容不存在交集时,该循环即可被设为原子操作。因此,对于[abc]+[def]表达式,其循环可被设为原子,因为[abc]部分不可能匹配到[def]也能匹配的内容。反之,若表达式改为[abc]+[cef],则该循环绝不能自动设为原子,否则将改变行为。这两个模式确实存在重叠,因为它们都能匹配‘c’。例如,当输入仅为“cc”时,原始表达式应能匹配([abc]* 循环通过一次迭代匹配‘c’,随后第二个‘c’将满足[cef]模式),但若表达式改为(?> [abc]+)[cef],则不再匹配——因为[abc]+会消耗两个‘c’,导致[cef]集合无匹配对象。两个完全不重叠的集合称为“不相交”,因此优化器需要能够证明集合的不相交性,才能执行此类自动原子性优化。优化器已能处理多数集合,特别是纯字符或字符范围组成的集合(如[ace]或[a-zA-Z0-9])。但许多集合由整个Unicode类别构成。例如当编写\d时(除非指定RegexOptions.ECMAScript),其等效于\p{Nd},表示“匹配Unicode十进制数字类别中的任意字符”,即所有通过char.GetUnicodeCategory返回UnicodeCategory.DecimalDigitNumber的字符。而优化器无法处理此类集合间的重叠关系。例如表达式\w * \p{Sm}匹配任意数量的单词字符后接数学符号(UnicodeCategory.MathSymbol)。\w实际上只是八个特定Unicode类别的集合,因此上述表达式与[\p{Ll}\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{Mn}\p{Nd}\p{Pc}]*\p{Sm}的行为完全相同。(\w 由 UnicodeCategory.UppercaseLetter、UnicodeCategory.LowercaseLetter、UnicodeCategory.TitlecaseLetter、UnicodeCategory.ModiferLetter、UnicodeCategory.OtherLetter、UnicodeCategory.NonSpacingMark、UnicodeCategory.ModiferLetter、UnicodeCategory.DecimalDigitNumber 及 UnicodeCategory.ConnectorPunctuation 组成)。需注意这八个类别均不同于 \p{Sm}。修饰字母、UnicodeCategory.十进制数字和UnicodeCategory.连接标点组成)。请注意,这八个类别均不同于\p{Sm}`,这意味着它们互不相交,因此我们可以安全地将该循环改为原子操作而不影响行为;这仅能提升速度。最直观的验证方式是观察正则表达式源代码生成器的输出。修改前查看该表达式生成的XML注释:
/// ○ Match a word character greedily any number of times.
/// ○ Match a character in the set [\p{Sm}].
修改后则显示:
/// ○ Match a word character atomically any number of times.
/// ○ Match a character in the set [\p{Sm}].
首句中仅一个单词的变更便产生了巨大差异。以下是修改前源代码生成器为匹配例程生成的C#代码片段:
// Match a word character greedily any number of times.
//{
charloop_starting_pos = pos;
int iteration = 0;
while ((uint)iteration < (uint)slice.Length && Utilities.IsWordChar(slice[iteration]))
{
iteration++;
}
slice = slice.Slice(iteration);
pos += iteration;
charloop_ending_pos = pos;
goto CharLoopEnd;
CharLoopBacktrack:
if (Utilities.s_hasTimeout)
{
base.CheckTimeout();
}
if (charloop_starting_pos >= charloop_ending_pos)
{
return false; // The input didn't match.
}
pos = --charloop_ending_pos;
slice = inputSpan.Slice(pos);
CharLoopEnd:
//}
由此可见回溯机制如何影响生成的代码。核心循环会遍历所有可匹配的单词字符,但在继续前进前会记录当前位置信息。它还会设置跳转标签,以便后续代码需要回溯时跳转至该位置;该代码会撤销已匹配的某个字符,然后重新尝试匹配后续所有内容。若需再次回溯,则继续撤销字符并重试。如此循环往复。修改后的代码如下:
// Match a word character atomically any number of times.
{
int iteration = 0;
while ((uint)iteration < (uint)slice.Length && Utilities.IsWordChar(slice[iteration]))
{
iteration++;
}
slice = slice.Slice(iteration);
pos += iteration;
}
所有冗余的回溯逻辑已消除;循环尽可能匹配最大长度后即结束。通过如下微基准测试可观察其效果:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private static readonly string s_input = new string(' ', 100);
private static readonly Regex s_regex = new Regex(@"\s+\S+", RegexOptions.Compiled);
[Benchmark]
public int Count() => s_regex.Count(s_input);
}
这是一个简单测试:尝试匹配任意正数个空格字符后接任意正数个非空格字符,输入却全是空格。若无原子性,引擎会将所有空格都消耗在\s+匹配中,随后发现没有可用非空格字符匹配\S+。此时它会怎么做?它会回溯,释放被\s+消耗的数百个空格之一,并再次尝试匹配\S+。由于无法匹配,它将再次回溯。如此反复。持续上百次,直至无匹配项可寻而放弃。启用原子性后,所有回溯操作将消失,使匹配更快失败。
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Count | .NET 9.0 | 183.31 ns | 1.00 |
| Count | .NET 10.0 | 69.23 ns | 0.38 |
dotnet/runtime#117892 是相关改进。在正则表达式中,\b 被称为“词边界”;它检查前一个字符的词性(即前一个字符是否匹配 \w)是否与后一个字符的词性匹配,若两者不同则判定为边界。可在引擎的 IsBoundary 辅助函数实现中看到此逻辑(注意根据 TR18,字符是否被视为边界字符的判定规则与 \w 几乎完全一致,仅额外包含两个零宽度 Unicode 字符):
internal static bool IsBoundary(ReadOnlySpan<char> inputSpan, int index)
{
int indexM1 = index - 1;
return ((uint)indexM1 < (uint)inputSpan.Length && RegexCharClass.IsBoundaryWordChar(inputSpan[indexM1])) !=
((uint)index < (uint)inputSpan.Length && RegexCharClass.IsBoundaryWordChar(inputSpan[index]));
}
优化器在自动原子性逻辑中已存在特殊情况处理,其明确掌握边界字符与\w、\d的对应关系。因此,当遇到 \w+\b 时,优化器会识别到:为使 \b 匹配成功,\w+ 匹配内容之后的部分必然不能匹配 \w(否则将失去边界属性),从而可将 \w+ 设为原子操作。同理,遇到\d+\b模式时,它会识别后续内容必须不属于\d,从而使循环原子化。但该机制并未推广应用。如今在.NET 10中,这一功能得以实现。此PR教会优化器识别\w的子集,因为如同\d的特殊情况,任何\w子集都能获得同等收益:若\b前为单词字符,则其后必须不是。因此通过此PR,[a-zA-Z]+\b这类表达式现在也能使循环变为原子操作。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private static readonly string s_input = "Supercalifragilisticexpialidocious1";
private static readonly Regex s_regex = new Regex(@"^[A-Za-z]+\b", RegexOptions.Compiled);
[Benchmark]
public int Count() => s_regex.Count(s_input);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Count | .NET 9.0 | 116.57 ns | 1.00 |
| Count | .NET 10.0 | 21.74 ns | 0.19 |
仅仅改进集合不相交性分析是有帮助的,但更重要的是能够识别出全新类别的可原子化对象。在早期版本中,自动原子性优化仅适用于单字符循环,例如a* 、[abc] * ?、[^abc]* 。这显然只是循环的子集,因为许多循环包含不止一个字符;循环可以包裹任何正则表达式构造。即使混入捕获组也会破坏自动原子性行为。如今通过dotnet/runtime#117943,大量涉及复杂构造的循环均可实现原子化。不过单字符以上的循环较为棘手,因为在验证原子性时需要考虑更多因素。单字符情况下,只需证明该字符与其后内容互斥即可。但考虑表达式([a-z][0-9])+a1,此循环能否实现原子性?循环后缀(‘a’)与循环结束符([0-9])可证明互不相交,但若自动将其原子化将改变行为模式,这是不可取的。假设输入为“b2a1”。该表达式匹配成功;若按常规处理,循环将匹配单次迭代,消耗“b2”,随后循环后的a1将匹配输入中的对应a1。但若将循环设为原子操作(如 (?>([a-z][0-9])+)a1),循环将执行两次迭代,同时消耗 “b2” 和 “a1”,导致模式中的 a1 无法匹配。事实证明,我们不仅需要确保结束循环的元素与后续内容互斥,还需确保开始循环的元素与后续内容互斥。但这还不是全部。现在考虑表达式^(a|ab)+$,它能匹配由“a”和“ab”组成的完整输入。对于输入字符串 “aba”,该表达式能成功匹配:它将通过交替的第二分支消耗 “ab”,然后在循环下一次迭代时通过交替的第一个分支消耗剩余的 a。但现在考虑将循环原子化后的情况:^(?>(a|ab)+)$。在相同输入下,开头的a会被交替表达式的首支消耗,满足循环的最小迭代次数(1次)从而退出循环。随后验证字符串末尾时会失败,但由于循环已原子化,无法回溯至任何分支,导致整个匹配失败。哎呀。问题在于:循环结尾不仅需与后续内容互斥,循环起始处同样需与后续内容互斥。由于存在循环特性,后续内容可能重复自身,这意味着循环起始与结尾必须彼此互斥。这些限制条件极大缩小了可适用的模式范围,但即便如此,此类情况仍出人意料地常见:dotnet/runtime-assets (用于dotnet/runtime的测试资源库)包含一个正则表达式模式数据库,这些模式源自获得适当许可的NuGet包,共计近20,000个独特模式,其中超过7%因此次优化获得显著提升。
以下示例在《古腾堡计划完整马克·吐温作品集》中搜索符合以下条件的序列:所有小写ASCII单词,每个单词后跟一个空格,且所有单词末尾均以大写ASCII字母结尾。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private static readonly string s_input = new HttpClient().GetStringAsync(@"https://www.gutenberg.org/cache/epub/3200/pg3200.txt").Result;
private static readonly Regex s_regex = new Regex(@"([a-z]+ )+[A-Z]", RegexOptions.Compiled);
[Benchmark]
public int Count() => s_regex.Count(s_input);
}
在早期版本中,该内部循环会被设为原子操作,但外部循环仍采用贪婪(回溯)策略。从源代码生成器生成的XML注释中可看到:
/// ○ Loop greedily at least once.
/// ○ 1st capture group.
/// ○ Match a character in the set [a-z] atomically at least once.
/// ○ Match ' '.
/// ○ Match a character in the set [A-Z].
而在.NET 10中,我们看到:
/// ○ Loop atomically at least once.
/// ○ 1st capture group.
/// ○ Match a character in the set [a-z] atomically at least once.
/// ○ Match ' '.
/// ○ Match a character in the set [A-Z].
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Count | .NET 9.0 | 573.4 ms | 1.00 |
| Count | .NET 10.0 | 504.6 ms | 0.88 |
与所有优化机制相同,自动原子性绝不应改变可观察行为,仅需提升运行效率。因此每次自动应用原子性时,都需经过逻辑推演以确保优化合理。某些场景下,由于此前未进行充分逻辑验证,优化方案被设计得较为保守。dotnet/runtime#118191 处理的案例即属此类,该方案对自动原子性逻辑中的边界处理机制进行了若干调整,移除了若干已实施但实属多余的约束条件。实现原子性分析的核心逻辑是一个类似于以下形式的方法:
private static bool CanBeMadeAtomic(RegexNode node, RegexNode subsequent, ...)
node 表示正则表达式中正在考虑成为原子部分的片段(例如循环),而 subsequent 表示模式中紧接在 node 之后的部分;该方法随后会将 node 与 subsequent 进行验证,以确认即使将 node 设为原子部分,也不会导致行为变化。然而仅通过验证subsequent本身并不能处理所有情况。以模式 a * b * \w 为例,其中 node 代表 a* ,subsequent 代表 b* 。a 和 b 显然互不相交,因此 node 可相对于 subsequent 实现原子化,但…此时 subsequent 同时具有“可为空”特性,即可能成功匹配 0 个字符(循环下限为 0)。这种情况下,a* 之后的内容未必是b,也可能是b* 之后的内容——即\w,而该字符与a存在重叠。因此将其改写为(?>a * )b * \w将构成行为变更。以输入“a”为例: 原始模式下,a* 会以0次迭代成功匹配空字符串,b* 同样以0次迭代匹配空字符串,随后\w成功匹配输入‘a’。但原子化模式中,(?>a*)仅需一次迭代即可匹配输入‘a’,导致后续\w无匹配对象。因此当CanBeMadeAtomic检测到subsequent可能为空且能匹配空字符串时,它需要进行迭代以验证subsequent之后的内容(若后续内容本身也持续为空,则可能需要反复迭代)。
CanBeMadeAtomic 虽已考虑边界(\b 和 \B),但其采用保守逻辑:因边界为“零宽度”(即不消耗输入),故必须检查后续内容。但实际情况并非如此。尽管边界为零宽度,它仍对后续内容作出保证:若前置字符为单词字符,则成功匹配时后续字符必然不是单词字符。因此我们可以放宽限制,无需检查后续内容。
最后这个例子还揭示了自动原子性优化的一个有趣特性:该优化提供的所有功能,开发者在编写正则表达式时本可自行实现。开发者完全可以将a * b改写为(?>a*)b,将[a-z]+(?= )改写为(?>[a-z]+)(?= ),以此类推。但你上次在自己编写的正则表达式中显式添加原子组是什么时候?在前述从NuGet获取的近20,000个真实正则表达式模式数据库中,猜猜有多少包含显式编写的原子组?答案是:约100个。这本就不是开发者惯常考虑的事项。因此尽管优化将用户模式转换为他们本可自行编写的表达,但这仍是极具价值的优化——尤其在.NET 10中,超过70%的模式至少有一个构造被提升为原子性。
自动原子性优化正是优化器消除冗余计算的典型案例。虽然这是关键示例,但绝非唯一。在.NET 10中还有多个PR通过不同方式消除了不必要的计算。
dotnet/runtime#118084 提供了一个有趣的案例,但理解它需要先掌握“环视”机制。环视是一种使内容零宽度的正则表达式构造。当类似“[abc]”的集合匹配时,它会消耗输入中的单个字符;当类似“[abc]{3,5}”的循环匹配时,它会消耗输入中的3-5个字符。而环顾(与锚点等其他零宽度构造类似)则不会消耗任何内容。将环视结构包裹在正则表达式周围时,它实质上使消耗行为暂时化。例如,若将[abc]{3,5}用正向环视包裹为(?=[abc]{3,5}),则会执行完整匹配以寻找3-5个字符的组合,但这些字符在环视结构结束时不会被永久消耗; 环视仅执行匹配测试,退出时会重置输入位置。观察正则表达式源代码生成器对(?=[abc]{3,5})abc模式生成的代码即可直观理解:
// Zero-width positive lookahead.
{
int positivelookahead_starting_pos = pos;
// Match a character in the set [a-c] atomically at least 3 and at most 5 times.
{
int iteration = 0;
while (iteration < 5 && (uint)iteration < (uint)slice.Length && char.IsBetween(slice[iteration], 'a', 'c'))
{
iteration++;
}
if (iteration < 3)
{
return false; // The input didn't match.
}
slice = slice.Slice(iteration);
pos += iteration;
}
pos = positivelookahead_starting_pos;
slice = inputSpan.Slice(pos);
}
// Match the string "abc".
if (!slice.StartsWith("abc"))
{
return false; // The input didn't match.
}
可见环顾结构会缓存起始位置,随后尝试匹配其内部循环模式。若匹配成功,则将匹配位置重置为进入环顾时的原始位置,继而继续处理环顾后的后续内容。
这些示例展示了一种特定类型的环视模式,称为正向环视。环视模式包含两种选择组合,共四种变体:正向与负向,以及前向与后向。前向环视从当前位置开始向前验证模式(通常匹配方向),而后向环视则从当前位置之前开始向后验证模式。正向表示模式应匹配,负向表示模式不应匹配。例如,负向后向查找 (?<!\w) 将在当前位置之前不存在字符时匹配。
负向环视结构尤为特殊,因为与其他所有正则表达式构造不同,它们保证其所含模式 不会 匹配。这在其他方面也使其具有特殊性,尤其涉及捕获组时。对于正向环顾,即使其宽度为零,环顾内部捕获组的内容仍会保留在环顾之外,例如^(?=(abc))\1$——该表达式通过正向环顾内部捕获组的后向引用成功匹配输入“abc”。但由于 否定 环顾保证其内容不会匹配,若否定环顾内部捕获的内容能延续到环顾之外则违背直觉——因此不会发生这种情况。负向环视中的捕获组仍可能具有意义,尤其当同一环视内部存在指向该捕获组的后向引用时。例如模式^(?!(ab)\1cd)ababc正在验证输入是否不以ababcd开头但以ababc开头。但若不存在后向引用,捕获组便毫无用处,在正则表达式处理过程中无需为其执行任何操作(如记录捕获位置)。在优化阶段,这类捕获组可从节点树中完全移除,这正是dotnet/runtime#118084所实现的功能。正如开发者常不假思索地使用回溯结构来实现原子性操作,他们也常将捕获组纯粹作为分组机制使用,而未考虑将其改为非捕获组的可能性。由于捕获内容通常需要保留以便由Regex返回的Match对象进行检查,我们不能简单地移除所有未在模式内部使用的捕获组,但对于这些否定环视表达式却可以做到。考虑模式 (?<!(access|auth)\s)token,其匹配条件是单词 “token” 前不存在 “access ” 或 “auth ” 前缀; 开发者(此处即本人)采取了相当自然的做法:将交替项包裹在分组中,以便将跟随任一单词的\s提取出来(若改写为access|auth\s,空格模式仅存在于交替项的第二分支,无法应用于第一分支)。但这种“简单”分组默认是捕获分组;要实现非捕获效果,需改写为非捕获分组(如(?<!(?:access|auth)\s)token),或使用RegexOptions.ExplicitCapture将所有未命名的捕获分组转换为非捕获分组。
类似地,我们可以移除其他与环顾相关的操作。如前所述,正向环顾的存在是为了将任何模式转换为零宽度模式,即不消耗任何内容。这就是它们的全部作用。如果被正向环顾包裹的模式本身已是零宽度模式,则环顾对表达式行为毫无贡献,可以被移除。例如,(?=$) 可直接转换为 $。这正是 dotnet/runtime#118091 实现的核心逻辑。
dotnet/runtime#118079 和 dotnet/runtime#118111 处理了与零宽断言相关的其他转换,特别是涉及循环的情况。出于各种原因,开发者常将零宽断言包裹在循环中,要么使其可选(如 \b?),要么设置较大上界(如 (?=abc)* )。但这些零宽断言不会消耗任何内容,其唯一目的是标记当前位置的断言是否成立。若将零宽断言设为可选,等同于宣告“先验证真伪,随即忽略结果——因两种结果皆有效”;因此整个表达式可视为无操作指令予以移除。同理,若用大于1的上限循环包裹此类表达式,等同于说“先判断真伪,接着不作任何改变地反复检查,再检查,再检查”。英语中有句谚语:“疯狂就是重复相同行为却期待不同结果”,此处正应此理。虽然零宽断言首次调用可能存在行为上的益处,但重复调用纯属浪费:若注定失败,首次就会失败。不过存在一个特殊情况:.NET 正则表达式的有趣特性使得差异可被观察到——捕获组会追踪 所有 匹配结果,而非仅最后一项。请看以下程序:
// dotnet run -c Release -f net10.0
using System.Diagnostics;
using System.Text.RegularExpressions;
Match m = Regex.Match("abc", "^(?=(\\w+)){3}abc$");
Debug.Assert(m.Success);
foreach (Group g in m.Groups)
{
foreach (Capture c in g.Captures)
{
Console.WriteLine($"Group: {g.Name}, Capture: {c.Value}");
}
}
运行后你会惊讶地发现,捕获组#1(即我显式设置在向前查找中的组)竟提供了三个捕获值:
Group: 0, Capture: abc
Group: 1, Capture: abc
Group: 1, Capture: abc
Group: 1, Capture: abc
这是因为正向预览的循环执行了三次迭代,每次迭代都匹配到“abc”,每次成功的捕获都会通过Regex API被持久化以供后续检索。因此,我们无法通过将零宽断言循环的上界从大于1降至1来优化循环;只有当循环不包含任何捕获时才能实现优化。而这些PR正是为此而生。对于包裹零宽断言且不含捕获的循环,若循环下限为0,则整个循环及其内容可被移除;若循环上限大于1,则可移除循环本身,仅保留其内容。
每当此类操作被消除时,便容易构造出怪诞且具有误导性的微基准测试……但这过程充满乐趣,因此这次我允许自己尝试。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private static readonly string s_input = new HttpClient().GetStringAsync(@"https://www.gutenberg.org/cache/epub/3200/pg3200.txt").Result;
private static readonly Regex s_regex = new Regex(@"(?=.*\bTwain\b.*\bConnecticut\b)*.*Mark", RegexOptions.Compiled);
[Benchmark]
public int Count() => s_regex.Count(s_input);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Count | .NET 9.0 | 3,226.024 ms | 1.000 |
| Count | .NET 10.0 | 6.605 ms | 0.002 |
dotnet/runtime#118083 涉及类似机制。“重复器”是限定上下界相同的正则表达式循环的统称,其循环内容会按固定次数“重复”。通常使用{N}语法表示,例如[abc]{3}表示需要三个字符的重复器,其中任意字符可以是‘a’、‘b’或‘c’。当然也可以采用长形式手动重复内容,例如[abc][abc][abc]。正如我们看到在循环形式中如何通过零宽断言简化循环,手动书写时同样可以实现相同效果。例如 \b\b 等同于 \b{2},而 \b{2} 又等同于 \b。
另一个消除冗余操作的典型案例见于dotnet/runtime#118105。边界断言在众多表达式中广泛应用,例如常见的\b\w+\b模式就试图匹配完整单词。当正则引擎遇到此类断言时,传统做法是委托给前文提到的IsBoundary辅助函数。但这里存在一些微妙的冗余工作,当查看正则表达式源代码生成器对\b\w+\b这类表达式的输出时,这种冗余就更为明显。以下是.NET 9环境下的输出示例:
// Match if at a word boundary.
if (!Utilities.IsBoundary(inputSpan, pos))
{
return false; // The input didn't match.
}
// Match a word character atomically at least once.
{
int iteration = 0;
while ((uint)iteration < (uint)slice.Length && Utilities.IsWordChar(slice[iteration]))
{
iteration++;
}
if (iteration == 0)
{
return false; // The input didn't match.
}
slice = slice.Slice(iteration);
pos += iteration;
}
// Match if at a word boundary.
if (!Utilities.IsBoundary(inputSpan, pos))
{
return false; // The input didn't match.
}
逻辑相当直白:匹配边界,尽可能消耗单词字符,再匹配边界。但若回顾IsBoundary的定义,会发现它执行了两次检查——一次针对前一个字符,一次针对后一个字符。
internal static bool IsBoundary(ReadOnlySpan<char> inputSpan, int index)
{
int indexM1 = index - 1;
return ((uint)indexM1 < (uint)inputSpan.Length && RegexCharClass.IsBoundaryWordChar(inputSpan[indexM1])) !=
((uint)index < (uint)inputSpan.Length && RegexCharClass.IsBoundaryWordChar(inputSpan[index]));
}
现在,看看这个,再回头看看生成的代码,再看看这个,再回头看看生成的源代码。看到什么多余的东西了吗?当我们执行第一次边界比较时,我们认真地检查了前一个字符,这是必要的,但随后我们又检查了当前字符,而这个字符即将被后续的\w+循环与\w进行匹配。第二个边界检查同样如此:我们刚完成\w+匹配,该匹配仅在存在至少一个单词字符时才成功。虽然仍需验证后续字符是否为边界字符(存在两种既非单词字符又属于边界字符的情况),但无需重复验证前一个字符。因此,dotnet/runtime#118105 对编译器和源代码生成器的边界处理进行了全面改造,使其能基于上下文知识生成定制化的边界检查。若能证明后续构造将验证字符属于单词字符,则只需验证前一个字符不是边界字符;反之,若能证明前一个构造已验证字符属于单词字符,则只需验证下一个字符不属于。这使得在 .NET 10 上生成的源代码调整为:
// Match if at a word boundary.
if (!Utilities.IsPreWordCharBoundary(inputSpan, pos))
{
return false; // The input didn't match.
}
// Match a word character atomically at least once.
{
int iteration = 0;
while ((uint)iteration < (uint)slice.Length && Utilities.IsWordChar(slice[iteration]))
{
iteration++;
}
if (iteration == 0)
{
return false; // The input didn't match.
}
slice = slice.Slice(iteration);
pos += iteration;
}
// Match if at a word boundary.
if (!Utilities.IsPostWordCharBoundary(inputSpan, pos))
{
return false; // The input didn't match.
}
这些IsPreWordCharBoundary和IsPostWordCharBoundary辅助函数仅承担主边界辅助函数一半的检查工作。当边界测试频繁执行时,减少的检查次数累积起来效果显著。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private static readonly string s_input = new HttpClient().GetStringAsync(@"https://www.gutenberg.org/cache/epub/3200/pg3200.txt").Result;
private static readonly Regex s_regex = new Regex(@"\ba\b", RegexOptions.Compiled | RegexOptions.IgnoreCase);
[Benchmark]
public int CountStandaloneAs() => s_regex.Count(s_input);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| CountStandaloneAs | .NET 9.0 | 20.58 ms | 1.00 |
| CountStandaloneAs | .NET 10.0 | 19.25 ms | 0.94 |
Regex 优化器本质上是模式识别器:它识别序列和形状,并对这些模式进行转换以形成更高效的处理形式。典型示例是可合并分支的交替表达式。假设存在交替表达式 a|e|i|o|u。虽然可直接处理为交替形式,但将其转换为等效集合 [aeiou] 能显著提升表示与处理效率。现有优化机制在处理交替表达式时会执行此类转换,但截至 .NET 9 版本,该机制仅支持单字符和集合,不支持否定集合。例如,它会将a|e|i|o|u转换为[aeiou],将[aei]|[ou]转换为[aeiou],但不会合并[^\n]这类否定集合(即.,除非处于RegexOptions.Singleline模式)。当开发者需要表示所有字符的集合时,会采用多种惯用法,例如[\s\S]表示“包含所有空白字符和非空白字符的集合”,即代表所有字符。另一常见模式是\n|.,等同于\n|[^\n],表示“匹配换行符或除换行符外的任意字符”,同样涵盖所有字符。遗憾的是,尽管[\d\D]等示例已得到良好处理,.|\n却因交替优化缺口而未能完善。dotnet/runtime#118109 对此进行了改进,使这类“否定”场景可纳入现有优化机制。该方案将相对耗时的交替匹配转换为超快速集合检测。虽然集合包含性检查通常效率很高,但这种检查效率堪称极致——因为它永远成立。我们可以通过匹配C风格注释块的模式来观察这种情况。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private const string Input = """
/* This is a comment. */
/* Another comment */
/* Multi-line
comment */
""";
private static readonly Regex s_regex = new Regex(@"/\*(?:.|\n)*?\*/", RegexOptions.Compiled);
[Benchmark]
public int Count() => s_regex.Count(Input);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Count | .NET 9.0 | 344.80 ns | 1.00 |
| Count | .NET 10.0 | 93.59 ns | 0.27 |
请注意,这里还有一项有助于.NET 10的变更dotnet/runtime#118373,不过我犹豫是否将其称为性能改进,因为它本质上更像是修复了一个错误。撰写本文时,这些基准测试数据出现了异常现象(通常需对基准测试结果保持怀疑态度,并深入调查任何不符合逻辑和预期的情况)。经排查发现,仅需修改一个单词就能显著提升测试速度,尤其在使用RegexOptions.Compiled时效果明显(该错误在源代码生成器中并不存在)。在处理懒惰循环时,存在一种特殊情况:当懒惰循环包裹着匹配任意字符的集合时(得益于之前的PR,(?:.|\n)现在支持这种模式)。该特殊情况识别到:若懒惰循环匹配到任何内容,可通过搜索循环后接的字符(例如本测试中循环后接字面量“ */”)高效定位循环终点。遗憾的是,生成IndexOf调用的辅助函数接收到了模式中错误的节点:它获取的是表示(?:.|\n)任意集合的对象,而非“ */”字面量,导致其生成的调用等效于IndexOfAnyInRange((char)0, ‘\uFFFF’)而非IndexOf(“ */”)。哎呀。虽然功能上仍正确——IndexOfAnyInRange调用能成功匹配首个字符,循环也会从该位置重新评估——但这意味着我们未能高效跳过不可能匹配的SIMD位置,反而对每个位置都进行了非 trivial 的计算。
dotnet/runtime#118087 展示了另一种与交替模式相关的有趣转换。空分支交替模式非常常见,可能是开发者直接编写的,但更常见的是其他转换操作的结果。例如,对于模式 \r\n|\r(试图匹配以 \r 开头的行尾),存在一种优化机制:提取所有分支的公共前缀,生成等效模式 \r(?:\n|),即 \r 后跟换行符或空字符。这种交替形式完全符合该概念,但存在更自然的表达方式:?。行为上,该模式与\r\n?完全等价,而由于后者更常见且更符合规范,正则引擎针对这种循环形式拥有更多优化机制,例如与其他循环合并或自动原子化。因此,此PR将所有X|形式的交替表达式转换为X?,同时将所有|X形式的交替表达式转换为X??。X| 与 |X 的区别在于先尝试 X 还是先尝试空表达式;同理,贪婪的 X? 循环与懒惰的 X?? 循环的区别也在于先尝试 X 还是先尝试空表达式。这种差异在先前示例生成的代码中可见一斑。以下是.NET 9平台上\r\n|\r匹配例程核心的源代码生成结果:
// Match '\r'.
if (slice.IsEmpty || slice[0] != '\r')
{
return false; // The input didn't match.
}
// Match with 2 alternative expressions, atomically.
{
int alternation_starting_pos = pos;
// Branch 0
{
// Match '\n'.
if ((uint)slice.Length < 2 || slice[1] != '\n')
{
goto AlternationBranch;
}
pos += 2;
slice = inputSpan.Slice(pos);
goto AlternationMatch;
AlternationBranch:
pos = alternation_starting_pos;
slice = inputSpan.Slice(pos);
}
// Branch 1
{
pos++;
slice = inputSpan.Slice(pos);
}
AlternationMatch:;
}
而.NET 10平台生成的代码如下:
// Match '\r'.
if (slice.IsEmpty || slice[0] != '\r')
{
return false; // The input didn't match.
}
// Match '\n' atomically, optionally.
if ((uint)slice.Length > (uint)1 && slice[1] == '\n')
{
slice = slice.Slice(1);
pos++;
}
优化器识别出\r\n|\r等同于\r(?:\n|),这又等同于\r\n?,进而等同于\r(?>\n?)。由于无需回溯,优化器能为此生成大幅简化的代码。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private static readonly string s_input = new HttpClient().GetStringAsync(@"https://www.gutenberg.org/cache/epub/3200/pg3200.txt").Result;
private static readonly Regex s_regex = new Regex(@"ab|a", RegexOptions.Compiled);
[Benchmark]
public int Count() => s_regex.Count(s_input);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Count | .NET 9.0 | 23.35 ms | 1.00 |
| Count | .NET 10.0 | 18.73 ms | 0.80 |
.NET 10 对 Regex 的改进不仅限于消除此类冗余工作。Regex 的匹配流程被逻辑化地拆分为两部分:尽可能快速定位下一个潜在匹配点(TryFindNextPossibleStartingPosition),随后在该位置执行完整匹配流程(TryMatchAtCurrentPosition)。理想情况下,TryFindNextPossibleStartingPosition 既要尽可能快速完成工作,又要大幅限制需要执行完整匹配的位置数量。例如,该方法若始终判定输入中的下一个索引点需要测试,虽然能实现高速运行,但会导致在输入的每个索引点都执行完整匹配逻辑,这显然不利于性能优化。相反,优化器会分析模式特征以快速定位可行起始位置,例如模式中已知偏移量的固定字符串或集合。锚点是优化器能发现的最有价值特征之一,因为它们能显著限制匹配有效的可能位置。理想模式应以起始锚点(^)开头,这意味着匹配仅可能在索引0处成功。
我们之前讨论过环视匹配,但事实证明,在.NET 10之前,环视匹配并未被纳入TryFindNextPossibleStartingPosition的搜索范围。dotnet/runtime#112107对此进行了改进。该更新指导优化器在何时如何探索模式开头的正向查找,以更高效地定位起始位置。例如在.NET 9中,对于模式(?=^)hello,源代码生成器为TryFindNextPossibleStartingPosition生成的代码如下:
private bool TryFindNextPossibleStartingPosition(ReadOnlySpan<char> inputSpan)
{
int pos = base.runtextpos;
// Any possible match is at least 5 characters.
if (pos <= inputSpan.Length - 5)
{
// The pattern has the literal "hello" at the beginning of the pattern. Find the next occurrence.
// If it can't be found, there's no match.
int i = inputSpan.Slice(pos).IndexOfAny(Utilities.s_indexOfString_hello_Ordinal);
if (i >= 0)
{
base.runtextpos = pos + i;
return true;
}
}
// No match found.
base.runtextpos = inputSpan.Length;
return false;
}
优化器在模式中发现了“hello”字符串,因此将其作为寻找下一个完整匹配可能位置的搜索目标。这本是理想状态,但正向查找同时要求匹配必须发生在输入开头。而在.NET 10中,我们得到如下结果:
private bool TryFindNextPossibleStartingPosition(ReadOnlySpan<char> inputSpan)
{
int pos = base.runtextpos;
// Any possible match is at least 5 characters.
if (pos <= inputSpan.Length - 5)
{
// The pattern leads with a beginning (\A) anchor.
if (pos == 0)
{
return true;
}
}
// No match found.
base.runtextpos = inputSpan.Length;
return false;
}
该pos == 0检查至关重要,因为它意味着我们仅会在单一位置尝试完整匹配,从而避免了即使找不到合适匹配位置仍需进行的搜索。再次强调,每次消除此类冗余操作时,你都能构建出极具吸引力的微基准测试…
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private static readonly string s_input = new HttpClient().GetStringAsync(@"https://www.gutenberg.org/cache/epub/3200/pg3200.txt").Result;
private static readonly Regex s_regex = new Regex(@"(?=^)hello", RegexOptions.Compiled);
[Benchmark]
public int Count() => s_regex.Count(s_input);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Count | .NET 9.0 | 2,383,784.95 ns | 1.000 |
| Count | .NET 10.0 | 17.43 ns | 0.000 |
该PR还改进了交替表达式的优化。当前优化机制已能分析交替分支,寻找可提取的公共前缀。例如,对于模式 abc|abd,优化器会识别每个分支开头的共享前缀 “ab” 并将其提取为 ab(?:c|d),随后发现剩余交替分支均为单个字符,可转换为集合 ab[cd]。但若分支以锚点开头,则无法应用这些优化。以模式 ^abc|^abd 为例,代码生成器将直接输出原始表达式,形成包含两个分支的交替模式:首分支先检查开头条件再匹配 “abc”,次分支同样检查开头条件后匹配 “abd”。在 .NET 10 中,锚点可被提取,使得 ^abc|^abd 最终重写为 ^ab[cd]。
作为一项细微改进,dotnet/runtime#112065 还通过更高效的搜索例程优化了重复符的源代码生成。以模式 [0-9a-f]{32} 为例: 该模式用于匹配32位小写十六进制数字序列。在.NET 9中,其实现代码如下:
// Match a character in the set [0-9a-f] exactly 32 times.
{
if ((uint)slice.Length < 32)
{
return false; // The input didn't match.
}
if (slice.Slice(0, 32).IndexOfAnyExcept(Utilities.s_asciiHexDigitsLower) >= 0)
{
return false; // The input didn't match.
}
}
代码简洁明了,采用向量化的IndexOfAnyExcept方法高效验证了32位字符序列均为小写十六进制。但我们还能进一步优化。IndexOfAnyExcept方法不仅需要检测范围是否包含指定值之外的内容,还需返回该值的具体索引位置。虽然仅涉及少量指令,但这些指令实属冗余——因为在此场景下精确索引并无用武之地,实现仅需判断索引是否>= 0(即是否找到匹配项)。因此,我们可以改用该方法的Contains变体,它无需额外耗费周期来确定精确索引。在.NET 10中,生成的代码如下:
// Match a character in the set [0-9a-f] exactly 32 times.
if ((uint)slice.Length < 32 || slice.Slice(0, 32).ContainsAnyExcept(Utilities.s_asciiHexDigitsLower))
{
return false; // The input didn't match.
}
最后,.NET 10 SDK 引入了一个与 Regex 相关的全新分析器。我们经常会看到这样的代码:通过 Regex.Match(...).Success 来判断输入是否匹配正则表达式。虽然功能上正确,但这种方式比 Regex.IsMatch(...) 的开销要大得多。对于所有引擎而言,Regex.Match(...) 需要分配新的 Match 对象及支持数据结构(除非未找到匹配项,此时可使用空的单例); 而IsMatch无需分配实例,因为它无需返回实例(实现细节上仍可能使用Match对象,但可复用现有对象而非每次新建)。它还能规避其他低效操作:RegexOptions.NonBacktracking需按需收集信息,属于“按需付费”机制。仅判断是否存在匹配比确定匹配的起止位置更经济,而后者又比确定构成匹配的所有捕获项更经济。因此IsMatch成本最低,只需确认匹配存在即可,无需精确定位或获取捕获项;而Match则需确定所有这些信息。Regex.Matches(...).Count 的工作原理类似:它需要收集所有相关细节并分配大量对象,而 Regex.Count(...) 则能以更高效的方式完成此操作。dotnet/roslyn-analyzers#7547 添加了 CA1874 和 CA1875 代码分析规则,分别标记此类情况并建议使用 IsMatch 和 Count。


// dotnet run -c Release -f net10.0 --filter **
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.RegularExpressions;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private static readonly string s_input = new HttpClient().GetStringAsync(@"https://www.gutenberg.org/cache/epub/3200/pg3200.txt").Result;
private static readonly Regex s_regex = new Regex(@"\b\w+\b", RegexOptions.NonBacktracking);
[Benchmark(Baseline = true)]
public int MatchesCount() => s_regex.Matches(s_input).Count;
[Benchmark]
public int Count() => s_regex.Count(s_input);
}
| Method | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|
| MatchesCount | 680.4 ms | 1.00 | 665530176 B | 1.00 |
| Count | 219.0 ms | 0.32 | – | 0.00 |
Regex 虽是搜索的一种形式,但 .NET 中还存在其他用于各类搜索的原始类型和辅助工具,这些在 .NET 10 中也获得了显著改进。
SearchValues§
在探讨.NET 8性能优化时,我特别强调了两个最喜爱的改进:其一是动态PGO(程序生成优化),其二是SearchValues。
SearchValues 提供了一种预先计算最佳搜索策略的机制。.NET 8 引入了 SearchValues.Create 的重载版本,可生成 SearchValues<byte> 和 SearchValues<char> 类型,并相应地提供了接受此类实例的 IndexOfAny 等方法重载。若存在需反复搜索的固定值集,可一次性创建此类实例并缓存,后续搜索时直接复用,例如:
private static readonly SearchValues<char> s_validBase64Chars = SearchValues.Create("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/");
internal static bool IsValidBase64(ReadOnlySpan<char> input) =>
input.ContainsAnyExcept(s_validBase64Chars);
SearchValues<T> 背后存在多种不同的实现方案,每种方案都会根据 T 的类型以及目标值的具体性质进行选择和配置。dotnet/runtime#106900新增的实现方案,既能在核心向量化搜索循环中精简多条指令,也凸显了不同算法间的微妙差异。此前,当提供四个非连续的字节目标值时,SearchValues.Create会选择使用四个向量(每个目标字节对应一个向量)的实现方案,并对每个待测输入向量执行四次比较(分别与每个目标向量比对)。然而当所有目标字节均为ASCII码时,已有专用于处理五字节以上场景的优化方案。本次PR允许该方案同时适用于目标字节数为四或五的情况——只要每个目标字节的低半字节(末四位)互不相同。此举可大幅节省指令开销:无需执行四次比较,仅需一次洗牌操作加等值检测即可完成。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private static readonly byte[] s_haystack = new HttpClient().GetByteArrayAsync(@"https://www.gutenberg.org/cache/epub/3200/pg3200.txt").Result;
private static readonly SearchValues<byte> s_needle = SearchValues.Create("\0\r&<"u8);
[Benchmark]
public int Count()
{
int count = 0;
ReadOnlySpan<byte> haystack = s_haystack.AsSpan();
int pos;
while ((pos = haystack.IndexOfAny(s_needle)) >= 0)
{
count++;
haystack = haystack.Slice(pos + 1);
}
return count;
}
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Count | .NET 9.0 | 3.704 ms | 1.00 |
| Count | .NET 10.0 | 2.668 ms | 0.72 |
dotnet/runtime#107798 在支持 AVX512 时优化了另一种类似算法。SearchValues.Create<char> 采用的备用策略之一是向量化的“概率映射”,本质上是布隆过滤器。该算法通过位图存储字符每个字节的对应位;当检测字符是否属于目标集时,会检查该字符所有字节位的设置状态。若至少有一个位未设置,则该字符肯定不在目标集合中。若两个位均设置,则需进行更多验证以确定该值是否实际包含在集合中。这种机制能高效排除大量肯定不在集合中的输入,仅对可能存在的输入投入更多验证资源。该实现涉及多种洗牌、移位和置换操作,本次改动采用了更优的指令集,有效减少了所需操作次数。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private static readonly SearchValues<char> s_searchValues = SearchValues.Create("ßäöüÄÖÜ");
private string _input = new string('\n', 10_000);
[Benchmark]
public int IndexOfAny() => _input.AsSpan().IndexOfAny(s_searchValues);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| IndexOfAny | .NET 9.0 | 437.7 ns | 1.00 |
| IndexOfAny | .NET 10.0 | 404.7 ns | 0.92 |
虽然 .NET 8 引入了对 SearchValues<byte> 和 SearchValues<char> 的支持,但 .NET 9 引入了对 SearchValues<string> 的支持。SearchValues<string> 的用法与 SearchValues<byte> 和 SearchValues<char> 略有不同:前者用于在字节集合中搜索目标字节,后者用于在字符集合中搜索目标字符, 而SearchValues<string>用于在单个字符串(或字符区间)内搜索目标字符串。换言之,这是多子字符串搜索。假设你拥有正则表达式 (?i)hello|world,它要求以不区分大小写的方式搜索“hello”或“world”。对应的 SearchValues 实现是 SearchValues.Create([“hello”, “world”], StringComparison. OrdinalIgnoreCase)(实际上,若指定此模式,Regex编译器和源代码生成器会在底层调用此SearchValues.Create方法以优化搜索)。
SearchValues<string> 在 .NET 10 中也得到增强。该类在适用场景下会优先采用名为“Teddy”的核心算法,支持对多个子字符串进行向量化搜索。其核心处理循环中,当使用 AVX512 指令集时会调用两个指令:PermuteVar8x64x2 和 AlignRight; dotnet/runtime#107819 指出这两条指令可被单条 PermuteVar64x8x2 指令替代。同样地,在 Arm64 架构上,dotnet/runtime#118110 通过指令优化策略,将 ExtractNarrowingSaturateUpper 的使用替换为成本略低的 UnzipEven。
SearchValues<string> 还能优化单字符串搜索,其耗费更多时间生成最优搜索参数,而非直接调用简单的 IndexOf(string, StringComparison)。与先前采用的概率映射方法类似,向量化搜索可能产生误报,需要后续筛除。但在某些构造场景下,我们确知不可能出现误报;dotnet/runtime#108368 扩展现有优化方案,使其在部分不区分大小写的场景中同样适用(原方案仅支持区分大小写),从而在更多情况下省去额外的验证步骤。对于剩余的候选项验证,dotnet/runtime#108365 同样显著降低了多种场景的开销,包括为长度达16字符的针(被搜索项)添加专项处理(此前仅支持8字符),并预先计算更多信息以加速验证过程。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers;
using System.Text.RegularExpressions;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private static readonly string s_haystack = new HttpClient().GetStringAsync(@"https://www.gutenberg.org/cache/epub/3200/pg3200.txt").Result;
private static readonly Regex s_the = new("the", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex s_something = new("something", RegexOptions.IgnoreCase | RegexOptions.Compiled);
[Benchmark]
public int CountThe() => s_the.Count(s_haystack);
[Benchmark]
public int CountSomething() => s_something.Count(s_haystack);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| CountThe | .NET 9.0 | 9.881 ms | 1.00 |
| CountThe | .NET 10.0 | 7.799 ms | 0.79 |
| CountSomething | .NET 9.0 | 2.466 ms | 1.00 |
| CountSomething | .NET 10.0 | 2.027 ms | 0.82 |
dotnet/runtime#118108 还为单字符串实现添加了“紧凑”变体,通过忽略字符的上零字节来提升ASCII等常见场景的处理效率,使向量容量翻倍。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers;
using System.Text.RegularExpressions;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private static readonly string s_haystack = string.Concat(Enumerable.Repeat("Sherlock Holm_s", 8_000));
private static readonly SearchValues<string> s_needles = SearchValues.Create(["Sherlock Holmes"], StringComparison.OrdinalIgnoreCase);
[Benchmark]
public bool ContainsAny() => s_haystack.AsSpan().ContainsAny(s_needles);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| ContainsAny | .NET 9.0 | 58.41 us | 1.00 |
| ContainsAny | .NET 10.0 | 16.32 us | 0.28 |
MemoryExtensions§
搜索功能的改进自然不止于SearchValues。在 .NET 10 之前,MemoryExtensions 类已通过 IndexOf、IndexOfAnyExceptInRange、ContainsAny、Count、Replace、SequenceCompare 等扩展方法提供了丰富的跨度搜索与操作支持(该集合还通过 dotnet/runtime#112951 进一步扩展 ,新增了CountAny和ReplaceAny)。但其中绝大多数方法仅限于处理受IEquatable<T>约束的T类型。实际应用中,多数需要搜索的类型确实实现了IEquatable<T>接口。然而,当处于泛型上下文中且T不受约束时,即使用于实例化泛型类型或方法的T可比较,类型系统也无法识别此特性,导致MemoryExtensions方法无法使用。当然也存在需要自定义比较逻辑的场景。这两种情况在 LINQ 的 Enumerable.Contains 实现中均有体现:若源 IEnumerable<TSource> 实际可视为跨度类型(如 TSource[] 或 List<TSource>),理想情况下应能直接委托给优化的 MemoryExtensions.Contains<T>。但存在两个限制:a) Enumerable. Contains 并未对其 TSource : IEquatable<TSource> 进行约束,且 b) Enumerable.Contains 接受可选的比较器。
为解决此问题,dotnet/runtime#110197 为 MemoryExtensions 类新增了约 30 个重载方法。这些重载方法均与现有方法并行,但移除了泛型方法参数对IEquatable<T>(或IComparable<T>)的约束,并接受可选的IEqualityComparer<T>?(或IComparer<T>)。当未提供比较器或使用默认比较器时,它们可回退至为相关类型使用的相同向量化逻辑;否则,它们会根据T的特性和提供的比较器,尽可能提供最优实现。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private IEnumerable<int> _data = Enumerable.Range(0, 1_000_000).ToArray();
[Benchmark]
public bool Contains() => _data.Contains(int.MaxValue, EqualityComparer<int>.Default);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Contains | .NET 9.0 | 213.94 us | 1.00 |
| Contains | .NET 10.0 | 67.86 us | 0.32 |
(值得强调的是,借助 C# 14 的“第一类”span支持,MemoryExtensions中的许多扩展方法如今能自然地直接应用于string等类型。)
此类搜索常作为其他 API 的组成部分出现。例如编码 API 通常需先定位待编码对象,而采用高效实现的搜索 API 可显著加速该过程。核心库中已有数十个此类用例,其中多数场景使用了 SearchValues 或各类 MemoryExtensions 方法。dotnet/runtime#110574 添加了另一项优化,加速了 string.Normalize 的参数验证。当前实现逐字符遍历查找首个代理字符,新实现通过使用 IndexOfAnyInRange 提供了跳转起点。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private string _input = "This is a test. This is only a test. Nothing to see here. \u263A\uFE0F";
[Benchmark]
public string Normalize() => _input.Normalize();
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Normalize | .NET 9.0 | 104.93 ns | 1.00 |
| Normalize | .NET 10.0 | 88.94 ns | 0.85 |
dotnet/runtime#110478 同样更新了 HttpUtility.UrlDecode,使其采用向量化的 IndexOfAnyInRange。同时,若无需解码任何内容,该实现将避免分配结果字符串。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Web;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
[Benchmark]
public string UrlDecode() => HttpUtility.UrlDecode("aaaaabbbbb%e2%98%ba%ef%b8%8f");
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| UrlDecode | .NET 9.0 | 59.42 ns | 1.00 |
| UrlDecode | .NET 10.0 | 54.26 ns | 0.91 |
同样地,dotnet/runtime#114494 在 OptimizedInboxTextEncoder 中运用了 SearchValues,该实现是 System.Text.Encodings.Web 库中 JavaScriptEncoder 和 HtmlEncoder 等各类编码器的核心支持。Encodings.Web库中支持JavaScriptEncoder和HtmlEncoder`等多种编码器的核心实现。
JSON§
JSON作为数据交换的通用语言,已成为众多领域的核心技术。作为 .NET 中处理 JSON 的推荐库,System.Text.Json 持续演进以满足日益增长的性能需求。在 .NET 10 中,该库不仅优化了现有方法的性能,还新增了专门提升性能的方法。
JsonSerializer 类型基于底层的 Utf8JsonReader 和 Utf8JsonWriter 类型构建。序列化时,JsonSerializer需要Utf8JsonWriter实例(该类型为class)及其关联对象(如IBufferWriter实例)。对于所需的临时缓冲区,它会从ArrayPool<byte>租用缓冲区,但针对这些辅助对象,它会维护独立缓存以避免高频重建。该缓存原本用于所有异步流式序列化操作,但实际发现同步流式序列化操作并未使用该缓存。dotnet/runtime#112745 修复了此问题,使缓存使用一致,避免了这些中间分配。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.Json;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private Data _data = new();
private MemoryStream _stream = new();
[Benchmark]
public void Serialize()
{
_stream.Position = 0;
JsonSerializer.Serialize(_stream, _data);
}
public class Data
{
public int Value1 { get; set; }
}
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| Serialize | .NET 9.0 | 115.36 ns | 1.00 | 176 B | 1.00 |
| Serialize | .NET 10.0 | 77.73 ns | 0.67 | – | 0.00 |
先前讨论集合时提到,OrderedDictionary<TKey, TValue> 现已提供 TryAdd 等方法的重载版本,这些方法会返回相关项的索引,从而使后续访问无需执行成本更高的基于键的查找。事实证明,JsonObject 的索引器需要执行此操作:先通过键索引字典进行检查,再进行二次索引。现已更新为使用这些新重载方法。由于此类查找通常占用设置器的大部分成本,此优化可使 JsonObject 索引器的吞吐量提升一倍以上:
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.Json.Nodes;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private JsonObject _obj = new();
[Benchmark]
public void Set() => _obj["key"] = "value";
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Set | .NET 9.0 | 40.56 ns | 1.00 |
| Set | .NET 10.0 | 16.96 ns | 0.42 |
不过System.Text.Json的大部分改进其实是通过新API实现的。同样的“避免双重查找”问题也出现在其他场景,例如需要向JsonObject添加属性时,仅当该属性尚未存在才执行操作。通过Flu提交的dotnet/runtime#111229, 通过新增TryAdd方法(同时提供TryAdd重载及现有TryGetPropertyValue的重载——类似OrderedDictionary<>,该重载返回属性的索引)解决了此问题。
// dotnet run -c Release -f net10.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.Json.Nodes;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private JsonObject _obj = new();
private JsonNode _value = JsonValue.Create("value");
[Benchmark(Baseline = true)]
public void NonOverwritingSet_Manual()
{
_obj.Remove("key");
if (!_obj.ContainsKey("key"))
{
_obj.Add("key", _value);
}
}
[Benchmark]
public void NonOverwritingSet_TryAdd()
{
_obj.Remove("key");
_obj.TryAdd("key", _value);
}
}
| Method | Mean | Ratio |
|---|---|---|
| NonOverwritingSet_Manual | 16.59 ns | 1.00 |
| NonOverwritingSet_TryAdd | 14.31 ns | 0.86 |
dotnet/runtime#109472 来自 @karakasa 的提案,同样为 JsonArray 注入了新的 RemoveAll 和 RemoveRange 方法。除了提升易用性外,这些方法还继承了在List<T>上的性能优势(这并非巧合,因为JsonArray在实现细节上本质上是List<JsonNode?>的封装)。从List<T>中“错误”删除元素可能演变为O(N^2)操作,例如运行以下代码时:
// dotnet run -c Release -f net10.0
using System.Diagnostics;
for (int i = 100_000; i < 700_000; i += 100_000)
{
List<int> items = Enumerable.Range(0, i).ToList();
Stopwatch sw = Stopwatch.StartNew();
while (items.Count > 0)
{
items.RemoveAt(0); // uh oh
}
Console.WriteLine($"{i} => {sw.Elapsed}");
}
输出结果如下:
100000 => 00:00:00.2271798
200000 => 00:00:00.8328727
300000 => 00:00:01.9820088
400000 => 00:00:03.9242008
500000 => 00:00:06.9549009
600000 => 00:00:11.1104903
请注意:随着列表长度线性增长,耗时呈现非线性增长。这主要源于每次RemoveAt(0)操作都需要将剩余元素全部向后移动,该操作复杂度为O(N)(列表长度)。这意味着操作量为N + (N-1) + (N-2) + ... + 1,即N(N+1)/2,属于O(N^2)复杂度。而RemoveRange和RemoveAll通过仅对每个元素执行一次元素位移,成功规避了此类开销。当然,即使没有这些方法,我也可以通过反复移除末尾元素而非首元素的方式,将之前的移除循环保持线性时间复杂度(当然,如果我 真的 打算移除所有元素,直接使用Clear即可)。但实际使用中通常只需移除少量元素,此时能够委托操作而不必担心意外产生非线性开销就非常实用。
// dotnet run -c Release -f net10.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.Json.Nodes;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private JsonArray _arr;
[IterationSetup]
public void Setup() =>
_arr = new JsonArray(Enumerable.Range(0, 100_000).Select(i => (JsonNode)i).ToArray());
[Benchmark]
public void Manual()
{
int i = 0;
while (i < _arr.Count)
{
if (_arr[i]!.GetValue<int>() % 2 == 0)
{
_arr.RemoveAt(i);
}
else
{
i++;
}
}
}
[Benchmark]
public void RemoveAll() => _arr.RemoveAll(static n => n!.GetValue<int>() % 2 == 0);
}
| Method | Mean | Allocated |
|---|---|---|
| Manual | 355.230 ms | – |
| RemoveAll | 2.022 ms | 24 B |
(需注意:尽管本微基准测试中RemoveAll的速度快了150倍以上,但它确实存在手动实现所没有的小额内存分配。这是由于实现中调用List<T>.RemoveAll时涉及闭包所致。未来如有必要可避免此问题。)
另一项高频需求的新方法源自dotnet/runtime#116363,该提案为JsonElement新增Parse方法。若开发者仅需临时获取JsonElement,当前最高效的机制仍是最佳方案:解析JsonDocument,使用其RootElement,并在完成JsonElement操作后 仅此时 释放JsonDocument,例如:
using (JsonDocument doc = JsonDocument.Parse(json))
{
DoSomething(doc.RootElement);
}
但这种方案仅适用于JsonElement在作用域内使用的场景。若开发者需要分发JsonElement,则有三种选择:
Parse解析为JsonDocument,克隆其RootElement,销毁JsonDocument,再分发克隆对象。虽然JsonDocument适用于临时场景,但此类克隆操作会带来相当大的开销:
JsonElement clone;
using (JsonDocument doc = JsonDocument.Parse(json))
{
clone = doc.RootElement.Clone();
}
return clone;
- 通过
Parse转换为JsonDocument后直接分发其RootElement。请 切勿采用此方案 !JsonDocument.Parse生成的对象由ArrayPool<>中的数组支撑。若未对JsonDocument执行Dispose,该数组将被占用且永不归还池中。这并非世界末日;当其他请求者向池索取数组时,若池中无缓存可用,系统会动态生成新数组,最终数组仍会被补充。但池中的数组通常比其他数组“更珍贵”——它们存在时间更长,更可能属于更高代的垃圾回收层级。对于存活周期较短的JsonDocument,若使用ArrayPool数组而非新建数组,你更有可能丢弃对整体系统影响更大的数组。这种影响在微基准测试中不易察觉。
return JsonDocument.Parse(json).RootElement; // please don't do this
- 使用
JsonSerializer反序列化JsonElement。这虽是简洁合理的一行代码,但会调用JsonSerializer机制,引入更多开销。
return JsonSerializer.Deserialize<JsonElement>(json);
在 .NET 10 中新增第四种方案:
- 使用
JsonElement.Parse。此为正确方案,应替代 (1)、(2) 或 (3) 使用。
// dotnet run -c Release -f net10.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.Json;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private const string JsonString = """{ "name": "John", "age": 30, "city": "New York" }""";
[Benchmark]
public JsonElement WithClone()
{
using JsonDocument d = JsonDocument.Parse(JsonString);
return d.RootElement.Clone();
}
[Benchmark]
public JsonElement WithoutClone() =>
JsonDocument.Parse(JsonString).RootElement; // please don't do this in production code
[Benchmark]
public JsonElement WithDeserialize() =>
JsonSerializer.Deserialize<JsonElement>(JsonString);
[Benchmark]
public JsonElement WithParse() =>
JsonElement.Parse(JsonString);
}
| Method | Mean | Allocated |
|---|---|---|
| WithClone | 303.7 ns | 344 B |
| WithoutClone | 249.6 ns | 312 B |
| WithDeserialize | 397.3 ns | 272 B |
| WithParse | 261.9 ns | 272 B |
由于JSON已成为众多现代协议的编码格式,流式传输大型JSON有效负载已变得极为普遍。对于大多数用例而言,System.Text.Json已能很好地实现JSON流式传输。但在早期版本中,尚无理想方案处理部分字符串属性的流式传输——字符串属性必须通过单次操作写入其值。若字符串体积较小,这尚可接受。但若遇到极其庞大的字符串,且这些字符串是按需分块生成的,理想方案应是随获取随写入属性分块,而非强制缓冲完整值。dotnet/runtime#101356 为 Utf8JsonWriter 增补了 WriteStringValueSegment 方法,实现了这种分段写入功能。这解决了大多数情况,但还有一种非常常见的场景需要对值进行额外编码,而自动处理编码的 API 既高效又便捷。现代协议常在 JSON 有效负载中传输大量二进制数据块,这些数据块通常最终会作为 Base64 字符串出现在某个 JSON 对象的属性中。当前输出此类数据块需要对整个输入进行Base64编码,再将生成的byte或char完整写入Utf8JsonWriter。为解决此问题,dotnet/runtime#111041 在 Utf8JsonWriter 中新增了 WriteBase64StringSegment 方法。对于有充分动机降低内存开销并支持此类有效负载流式传输的场景,WriteBase64StringSegment允许传入字节范围,实现将对该范围进行Base64编码并写入JSON属性; 该方法可多次调用(每次设置isFinalSegment=false),写入器将持续将生成的Base64数据追加至属性,直至接收到终止属性的最终分段。(Utf8JsonWriter长期存在WriteBase64String方法,新方法仅实现了分段写入功能。) 此方法的核心优势在于降低延迟和工作集,因为无需在写入前缓冲全部数据负载,但我们仍可通过吞吐量基准测试验证其效益:
// dotnet run -c Release -f net10.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers;
using System.Text.Json;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private Utf8JsonWriter _writer = new(Stream.Null);
private Stream _source = new MemoryStream(Enumerable.Range(0, 10_000_000).Select(i => (byte)i).ToArray());
[Benchmark]
public async Task Buffered()
{
_source.Position = 0;
_writer.Reset();
byte[] buffer = ArrayPool<byte>.Shared.Rent(0x1000);
int totalBytes = 0;
int read;
while ((read = await _source.ReadAsync(buffer.AsMemory(totalBytes))) > 0)
{
totalBytes += read;
if (totalBytes == buffer.Length)
{
byte[] newBuffer = ArrayPool<byte>.Shared.Rent(buffer.Length * 2);
Array.Copy(buffer, newBuffer, totalBytes);
ArrayPool<byte>.Shared.Return(buffer);
buffer = newBuffer;
}
}
_writer.WriteStartObject();
_writer.WriteBase64String("data", buffer.AsSpan(0, totalBytes));
_writer.WriteEndObject();
await _writer.FlushAsync();
ArrayPool<byte>.Shared.Return(buffer);
}
[Benchmark]
public async Task Streaming()
{
_source.Position = 0;
_writer.Reset();
byte[] buffer = ArrayPool<byte>.Shared.Rent(0x1000);
_writer.WriteStartObject();
_writer.WritePropertyName("data");
int read;
while ((read = await _source.ReadAsync(buffer)) > 0)
{
_writer.WriteBase64StringSegment(buffer.AsSpan(0, read), isFinalSegment: false);
}
_writer.WriteBase64StringSegment(default, isFinalSegment: true);
_writer.WriteEndObject();
await _writer.FlushAsync();
ArrayPool<byte>.Shared.Return(buffer);
}
}
| Method | Mean |
|---|---|
| Buffered | 3.925 ms |
| Streaming | 1.555 ms |
.NET 9 引入了 JsonMarshal 类及其 GetRawUtf8Value 方法,该方法可直接访问由 JsonElement 封装的属性值底层字节数据。若需同时获取属性名称,@mwadams 在 dotnet/runtime#107784 中提供了对应的 JsonMarshal.GetRawUtf8PropertyName 方法。
诊断工具§
多年来,我见过不少代码库引入基于struct的ValueStopwatch实现;甚至在Microsoft.Extensions库中仍能发现若干此类实现。其设计初衷在于:System.Diagnostics.Stopwatch虽为类,但本质上只是包装了long类型(时间戳)。因此相较于编写如下分配内存的代码:
Stopwatch sw = Stopwatch.StartNew();
... // something being measured
sw.Stop();
TimeSpan elapsed = sw.Elapsed;
可改写为:
ValueStopwatch sw = ValueStopwatch.StartNew();
... // something being measured
sw.Stop();
TimeSpan elapsed = sw.Elapsed;
从而避免内存分配。后来Stopwatch新增了辅助方法,使得ValueStopwatch的吸引力减弱——从.NET 7起,我可改写为:
long start = Stopwatch.GetTimestamp();
... // something being measured
long end = Stopwatch.GetTimestamp();
TimeSpan elapsed = Stopwatch.GetElapsedTime(start, end);
但这不如原始示例自然,后者仅使用Stopwatch。若能编写原始示例代码,却能像后者那样执行,岂不美妙?得益于.NET 9和.NET 10在逃逸分析与栈分配方面的技术积累,如今这已成为可能。dotnet/runtime#111834通过优化Stopwatch实现,使StartNew、Elapsed和Stop方法完全支持内联。此时JIT编译器可识别分配的Stopwatch实例从未逃逸出当前帧,从而允许其采用栈分配。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Diagnostics;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
[Benchmark]
public TimeSpan WithGetTimestamp()
{
long start = Stopwatch.GetTimestamp();
Nop();
long end = Stopwatch.GetTimestamp();
return Stopwatch.GetElapsedTime(start, end);
}
[Benchmark]
public TimeSpan WithStartNew()
{
Stopwatch sw = Stopwatch.StartNew();
Nop();
sw.Stop();
return sw.Elapsed;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Nop() { }
}
| Method | Runtime | Mean | Ratio | Code Size | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|
| WithGetTimestamp | .NET 9.0 | 28.95 ns | 1.00 | 148 B | – | NA |
| WithGetTimestamp | .NET 10.0 | 28.32 ns | 0.98 | 130 B | – | NA |
| WithStartNew | .NET 9.0 | 38.62 ns | 1.00 | 341 B | 40 B | 1.00 |
| WithStartNew | .NET 10.0 | 28.21 ns | 0.73 | 130 B | – | 0.00 |
dotnet/runtime#117031 是一项不错的改进,有助于减少使用 EventSource 且事件 ID 特别大的用户的运行时工作集。出于效率考虑,EventSource 采用数组将事件ID映射至对应数据;由于每次事件写入时都需要查找该事件的元数据,因此查找操作必须极其快速。在多数EventSource场景中,开发者编写的事件ID通常集中在较小的连续区间,导致数组密度极高。但若开发者编写了ID极大的事件(我们在多个实际项目中观察到这种情况:由于将事件拆分到不同项目共享的多个部分类定义中,且为每个文件选择ID时难以避免冲突), 系统仍会创建能容纳该巨型ID的长度数组,导致事件源生命周期内存在巨大内存分配,其中大量空间最终沦为浪费。值得庆幸的是,自EventSource实现多年以来,Dictionary<TKey, TValue>的性能已显著提升,现已能高效处理查找操作,无需依赖事件ID的密集存储。需注意:每个EventSource派生类型实例应仅存在一个实例,推荐模式是将其存储为静态只读字段并统一使用。因此此处的开销主要源于单次大容量分配对进程运行期间工作集的影响。为便于演示,我特意采用了绝不应在实际环境中使用的做法——为每个事件创建新实例。请勿在生产环境中尝试此操作。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Diagnostics;
using System.Diagnostics.Tracing;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private MyListener _listener = new();
[Benchmark]
public void Oops()
{
using OopsEventSource oops = new();
oops.Oops();
}
[EventSource(Name = "MyTestEventSource")]
public sealed class OopsEventSource : EventSource
{
[Event(12_345_678, Level = EventLevel.Error)]
public void Oops() => WriteEvent(12_345_678);
}
private sealed class MyListener : EventListener
{
protected override void OnEventSourceCreated(EventSource eventSource) =>
EnableEvents(eventSource, EventLevel.Error);
}
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| Oops | .NET 9.0 | 1,876.21 us | 1.00 | 1157428.01 KB | 1.000 |
| Oops | .NET 10.0 | 22.06 us | 0.01 | 19.21 KB | 0.000 |
dotnet/runtime#107333 由 @AlgorithmsAreCool 提出,该方案减少了启动和停止 Activity 时涉及的线程竞争。ActivitySource 维护着一个线程安全的监听器列表,该列表仅在监听器注册或注销的罕见情况下发生变化。每次创建或销毁 Activity 时(可能以极高频率发生),都需要遍历监听器列表来通知每个监听器。先前代码使用锁保护监听器列表,为避免锁定期间通知监听器,实现方式是:获取锁→确定下一个监听器→释放锁→通知监听器→循环重复直至通知所有监听器。当多个线程同时启动和停止Activity时,这种方式可能导致严重竞争。本次PR将列表转换为不可变数组。每次列表变更时,系统都会创建包含更新后监听器的全新数组。这使得修改监听器列表的操作成本大幅增加,但如前所述,此类操作通常较为罕见。作为交换,通知监听器的成本则显著降低。
dotnet/runtime#117334(由@petrroll提出)通过在LoggerFactory. 而[dotnet/runtime#117342](https://github.com/dotnet/runtime/pull/117342)通过封装NullLogger类型,使针对NullLogger的类型检查(如if (logger is NullLogger)`)能被JIT编译器更高效地处理。而@mpidash提出的dotnet/roslyn-analyzers#将帮助开发者意识到日志操作的开销可能超出预期。请看以下代码:
[LoggerMessage(Level = LogLevel.Information, Message = "This happened: {Value}")]
private static partial void Oops(ILogger logger, string value);
public static void UnexpectedlyExpensive()
{
Oops(NullLogger.Instance, $"{Guid.NewGuid()} {DateTimeOffset.UtcNow}");
}
该代码使用日志源生成器,该生成器会为该日志方法生成专属实现,包含日志级别检查机制——除非启用对应级别,否则不会消耗大部分日志相关的开销:
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Logging.Generators", "6.0.5.2210")]
private static partial void Oops(global::Microsoft.Extensions.Logging.ILogger logger, global::System.String value)
{
if (logger.IsEnabled(global::Microsoft.Extensions.Logging.LogLevel.Information))
{
__OopsCallback(logger, value, null);
}
}
然而调用点正在执行非平凡操作:创建新Guid、获取当前时间、通过字符串插值分配字符串——即便LogLevel.Information不可用时这些操作可能徒劳无功。CA1873分析器会标记此问题:

加密§
.NET 10 在密码学领域投入了大量精力,几乎全部集中在后量子密码学(PQC)上。PQC指的是一类旨在抵御量子计算机攻击的密码算法。这类机器未来可能通过高效解决整数分解和离散对数等问题,使经典密码算法(如Rivest-Shamir-Adleman算法(RSA)或椭圆曲线密码学(ECC))失去安全性。面对“先收集后解密”攻击的迫近威胁(资金雄厚的攻击者可闲置捕获加密网络流量,静待未来解密读取)以及关键基础设施迁移所需的多年周期,向量子安全的加密标准转型已成为当务之急。基于此背景,.NET 10新增对以下算法的支持:ML-DSA(美国国家标准与技术研究院PQC数字签名算法)、复合ML-DSA(互联网工程任务组草案规范,用于创建结合ML-DSA与RSA等经典加密算法的签名)、SLH-DSA(另一种NIST PQC签名算法)以及ML-KEM (NIST PQC密钥封装算法)。这是迈向量子抗性安全的重要一步,使开发者能够开始探索并规划后量子时代的身份验证与数据真实性场景。尽管本次PQC工作并非以性能为导向,但其设计理念高度契合现代技术对性能的追求。早期类型(如基于AsymmetricAlgorithm派生类型)采用数组设计并后期添加span支持,而新型类型则以span为核心进行设计,数组API仅作为便利性补充存在。
不过.NET 10中仍有部分加密相关变更直接聚焦于性能优化。其一是提升OpenSSL“摘要”性能。由于.NET加密堆栈基于底层平台原生加密库构建,在Linux环境中这意味着使用OpenSSL,使其成为哈希、签名和TLS等常用操作的热点路径。“摘要算法”是一类加密哈希函数(如SHA-256、SHA-512、SHA-3),能将任意输入转换为固定长度的指纹;其应用场景遍布软件包验证、TLS握手及内容去重等领域。虽然.NET可使用操作系统提供的OpenSSL 1.x版本,但自.NET 6起,其开发重点日益转向优化并启用OpenSSL 3(前文提及的PQC支持需OpenSSL 3.5及以上版本)。在 OpenSSL 1.x 中,该库暴露了类似 EVP_sha256() 的获取函数,这些低开销函数直接返回指向相关哈希实现的 EVP_MD 指针。而 OpenSSL 3.x 引入了提供者模型,通过获取函数(EVP_MD_fetch)来检索由提供者支持的实现。为保持源代码兼容性,1.x时代的获取函数被改为返回兼容性适配层指针:当将此类旧版EVP_MD指针传递给EVP_DigestInit_ex等操作时,OpenSSL会在后台执行“隐式获取”以解析实际实现。这种隐式获取路径每次使用都会增加额外开销。OpenSSL建议用户改为显式获取并缓存结果以供复用,这正是dotnet/runtime#118613所实现的方案。该方案使基于OpenSSL的平台上执行更精简、更快速的加密哈希操作。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Security.Cryptography;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private byte[] _src = new byte[1024];
private byte[] _dst = new byte[SHA256.HashSizeInBytes];
[GlobalSetup]
public void Setup() => new Random(42).NextBytes(_src);
[Benchmark]
public void Hash() => SHA256.HashData(_src, _dst);
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Hash | .NET 9.0 | 1,206.8 ns | 1.00 |
| Hash | .NET 10.0 | 960.6 ns | 0.80 |
其他若干性能优化也已纳入其中:
AsnWriter.Encode。通过dotnet/runtime#106728和dotnet/runtime#112638 在加密堆栈中为AsnWriter添加并使用基于回调的机制,实现编码时无需强制分配临时编码状态。SafeHandle单例模式。dotnet/runtime#109391 在X509Certificate的更多场景中采用单例SafeHandle,避免临时句柄分配。- 基于 Span 的
ProtectedData。dotnet/runtime#109529 由 @ChadNedzlek 提出,为ProtectedData类添加基于Span<byte>的重载,使数据保护无需依赖源或目标数据位于已分配数组中。 PemEncodingUTF-8。dotnet/runtime#109438 为PemEncoding添加了 UTF-8 支持。PemEncoding作为解析和格式化 PEM(增强隐私邮件)编码数据(如证书和密钥中使用的数据)的实用类,此前仅支持char类型。如同先前在dotnet/runtime#109564中的处理方式,此变更使直接解析UTF-8数据成为可能,无需事先转码为UTF-16。FindByThumbprint。dotnet/runtime#109130 新增了X509Certification2Collection.FindByThumbprint方法。该实现采用基于栈的缓冲区存储候选证书的指纹值,避免了简单手动实现中需要创建的数组。dotnet/runtime#113606 随后在SslStream中应用了此功能。SetKeydotnet/runtime#113146 添加基于范围的SymmetricAlgorithm.SetKey方法,可避免创建冗余数组。
花生酱§
如同每次.NET版本发布,本次也有大量PR以不同方式提升性能。这些优化被采纳得越多,应用程序和服务的整体开销就越低。以下是本次发布的部分亮点:
- GC.DATAS(动态适应应用程序规模)在.NET 8引入,并在.NET 9默认启用。在.NET 10中,dotnet/runtime#105545对DATAS进行了调优:减少冗余工作、平滑暂停(尤其在高分配率场景下)、修正可能导致额外短暂回收(gen1)的碎片化计数问题,并实施了其他优化。最终效果是减少不必要的垃圾回收次数,提升吞吐量稳定性,并使高内存分配工作负载的延迟更可预测。dotnet/runtime#118762还新增了多个配置选项来调整DATAS行为,特别是用于精细控制Gen0分配区增长的设置。
- GCHandle。GC支持多种类型的“句柄”,用于显式管理与GC操作相关的资源。例如可创建“固定句柄”,确保GC不会移动相关对象。历史上,这些句柄是通过
GCHandle类型提供给开发者的,但该类型存在诸多问题,包括因缺乏强类型支持而极易被误用。为解决此问题,dotnet/runtime#111307 引入了若干强类型句柄新变体:GCHandle<T>、PinnedGCHandle<T>和WeakGCHandle<T>。这些不仅能解决部分可用性问题,还能削减旧设计带来的部分开销。
// dotnet run -c Release -f net10.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.InteropServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private byte[] _array = new byte[16];
[Benchmark(Baseline = true)]
public void Old() => GCHandle.Alloc(_array, GCHandleType.Pinned).Free();
[Benchmark]
public void New() => new PinnedGCHandle<byte[]>(_array).Dispose();
}
| Method | Mean | Ratio |
|---|---|---|
| Old | 27.80 ns | 1.00 |
| New | 22.73 ns | 0.82 |
- Mono解释器。Mono解释器针对多条操作码实现了优化支持,包括开关指令(dotnet/runtime#107423)、新数组(dotnet/runtime#107430) 以及内存屏障 (dotnet/runtime#107325)。但更具影响力的当属一系列十余个PR,它们使解释器能够对WebAssembly(Wasm)进行更多操作的向量化处理。其中包括dotnet/runtime#114669——该贡献实现了移位运算的向量化,以及dotnet/runtime#113743 则实现了
Abs、Divide和Truncate等大量运算的向量化。其他PR则在更多场景中应用Wasm专属内置API,以加速已在其他架构上通过架构专属内置函数实现加速的Wasm例程,例如 dotnet/runtime#115062 在Convert的十六进制转换例程核心方法(如Convert.FromBase64String)中引入了PackedSimd。 - FCALL调用。在
System.Private.CoreLib底层的许多位置,托管代码需要调用运行时中的本机代码。历史上,这种从托管到本机的转换主要通过两种方式实现: 一种是通过称为“QCALL”的方式,本质上就是调用运行时暴露的本机函数的DllImport(P/Invoke)。另一种历史上的主流机制是“FCALL”,这是一种更复杂且专用的路径,允许本机代码直接访问托管对象。FCALL曾是行业标准,但随着时间推移,多数实例已被转换为QCALL。这种转变提升了可靠性(因FCALL实现正确性向来困难重重),同时还能提高性能——FCALL需要辅助方法帧,而QCALL通常可避免此需求。.NET 10 版本中大量 PR 致力于移除 FCALL,例如:dotnet/runtime#107218 移除了Exception、GC和Thread中的辅助方法帧,dotnet/runtime#106497 则针对object中的辅助方法帧进行了优化。dotnet/runtime#107152 处理连接分析器时使用的帧, dotnet/runtime#108415 和 dotnet/runtime#108535 涉及反射中的调用,另有十余处。最终,所有涉及托管内存操作或抛出异常的FCALL均被移除。 - 十六进制转换。近期 .NET 版本在
Convert中新增了FromHexString和TryToHexStringLower等方法,但这些方法均采用 UTF16 编码。dotnet/runtime#117965 为其添加了支持 UTF8 字节的重载版本。 - 格式化处理。字符串插值由“插值字符串处理器”支持。当使用字符串目标类型进行插值时,默认调用来自
System.Runtime.CompilerServices的DefaultInterpolatedStringHandler。该实现能利用栈分配内存和ArrayPool<>缓冲格式化文本,从而降低分配开销。尽管技术复杂,其他代码(包括其他插值字符串处理器)可将DefaultInterpolatedStringHandler作为实现细节使用。但此时该代码仅能访问最终输出的string类型,底层缓冲区不会暴露。dotnet/runtime#112171 为DefaultInterpolatedStringHandler添加了Text属性,供需要访问已格式化文本的代码通过ReadOnlySpan<char>获取内容。 - 枚举相关的内存分配。dotnet/runtime#118288 移除了若干枚举相关的分配操作,例如在
EnumConverter中移除string.Split调用,并用无需分配string[]或单独string实例的MemoryExtensions. Split调用,该调用无需分配string[]数组或单独的string实例。 - NRBF解码。dotnet/runtime#107797 由 @teo-tsirpanis 提交,移除了
decimal构造函数调用中使用的数组分配,改用指向跨度(span)的集合表达式替代,从而实现栈分配状态。 - 类型转换器分配。dotnet/runtime#111349(提交者:@AlexRadch)通过采用更现代的 API 和构造(如基于 span 的
Split方法和字符串插值),减少了Size、SizeF、Point和Rectangle的解析开销,采用更现代的API和构造方式,例如基于span的Split方法和字符串插值。 - 通用数学转换。多数使用原始类型实现的通用数学接口的
TryConvertXx方法已被标记为MethodImplOptions. AggressiveInlining,以提示JIT始终进行内联优化,但仍有少量残留未处理。dotnet/runtime#112061由@hez2010提交的修复已解决此问题。 - ThrowIfNull。C# 14 现支持编写扩展静态方法。这对需要支持向下兼容的库而言是重大利好,因为这意味着静态方法可像实例方法一样通过多态填充实现。.NET 中存在大量不仅针对最新运行时,还兼容 .NET Standard 2.0 和 .NET Framework 的库,这些库此前无法使用
ArgumentNullException.ThrowIfNull之类的辅助静态方法。该功能不仅能简化调用场景、提升方法内联效率,更能优化代码结构。随着dotnet/runtime仓库转用C# 14编译器构建,dotnet/runtime#114644通过ThrowIfNull多填充方案替换了此类库中约2500处调用点。 - 文件提供者变更令牌。通过在
PollingWildCardChangeToken中采用免分配机制计算哈希值,dotnet/runtime#116175 减少了内存分配;而dotnet/runtime#115684 来自 @rameel,通过避免为空的NullChangeToken占用空间,减少了CompositeFileProvider的分配。 - 字符串插值。dotnet/runtime#114497 在处理可空输入时移除了多种空值检查,削减了插值操作的部分开销。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private string _value = " ";
[Benchmark]
public string Interpolate() => $"{_value} {_value} {_value} {_value}";
}
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Interpolate | .NET 9.0 | 34.21 ns | 1.00 |
| Interpolate | .NET 10.0 | 29.47 ns | 0.86 |
AssemblyQualifiedName。此前每次访问时都会重新计算Type.AssemblyQualifiedName的结果。根据 dotnet/runtime#118389,该值现已实现缓存。
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
[Benchmark]
public string AQN() => typeof(Dictionary<int, string>).AssemblyQualifiedName!;
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| AQN | .NET 9.0 | 132.345 ns | 1.007 | 712 B | 1.00 |
| AQN | .NET 10.0 | 1.218 ns | 0.009 | – | 0.00 |
后续展望§
呼!经过以上介绍,希望您和我一样对 .NET 10 乃至 .NET 的未来充满期待。
正如本次巡礼(及以往版本巡礼)所展示的,.NET性能进化的故事始终贯穿着持续迭代、系统性思维以及众多针对性改进的累积效应。虽然我通过微基准测试展示了具体提升,但真正的意义不在于这些基准数据……而在于让实际应用更具响应性、更可扩展、更可持续、更经济高效,最终使开发和使用过程更令人愉悦。无论您在交付高吞吐量服务、交互式桌面应用,还是资源受限的移动体验,.NET 10 都将为您和用户带来切实的性能提升。
体验这些改进的最佳方式是亲自尝试 .NET 10 RC1。下载它,运行您的工作负载,衡量其影响,并分享您的体验。发现显著提升?遇到需要修复的回归问题?发现进一步改进的机会?请大声说出来,提交问题报告,甚至发送 PR。每条反馈都将助力 .NET 变得更好,我们期待与您共同前行。
编程愉快!

预计阅读时间:366分钟9秒。含73230字
:v
下周的阅读计划定好了。😎
读了15分钟才发现滚动条几乎没动。这些帖子年年越写越长,再过十年干脆直接出版成百科全书算了。
这是在用超大规模语言模型写吗?
不是。内容并非充斥着无意义的废话或杜撰内容。
大量基准测试和代码示例,深入解析技术难题。似乎是某位工程师在详尽阐述团队过去12个月的所有工作。
或许可以输入完整代码段,至少能生成可操作的框架。
若代码本身附有文档说明效果更佳
年度浏览器压力测试启动!
这根本不是博客文章,简直是篇长篇小说。
而且还是单页的。
本该做成列表式文章,每更新一页就写一篇。
列表式文章听起来像是在招惹人踢。
哦,也没那么长嘛。
好吧,确实不算太长。
呃……
第一次来?
那这周剩下的内容就到此为止吧。
字符串插值提速14%的新闻,感觉比被塞进末尾脚注的规范更值得关注。
这类性能提升通常来之不易,考虑到日志记录和序列化操作对所有内容的影响,其意义往往不容小觑。
想想看,在.NET 9中,
return someCondition ? true : false居然比return someCondition更快。这太疯狂了。没错,但别写这种代码,直接用正确写法返回
someCondition。别让JIT的当前局限性决定正确代码的写法,否则既会错失未来JIT的改进,还会留下难以阅读的代码。更别提你在PR里还要为此争论不休。而且审核者会讨厌你——毕竟你的代码既荒谬又正确🤷
这似乎搞错了。我艾莎博客里怎么突然冒出一堆编程乱七八糟的东西?
你连都铎时代都没研究过?
C# 如今简直是我的理想语言。它性能相当出色(优于 Python 和 JS,但逊于 Rust 和 C++),完全满足我的需求。更重要的是其设计极具优雅气质
可惜这门本应全面的语言至今仍缺乏官方开源工具链。难以置信2025年的微软竟仍将调试器这类核心工具锁在专有许可之下。
其他主流语言都不这么做。
调试器是有的,只是你想要的是那个超棒的调试器 🙂
是哪款调试器?我所知的开源调试器都是第三方开发的。
我记得是Mono继承的那款(可能记错了),但第三方调试器又如何?开源生态本就不该要求所有工具都来自单一供应商。
对于非商业用途,社区版并不构成实际问题。
你指的是哪个社区版?
Visual Studio Community版。虽非开源但可免费使用。对非竞争者而言,非开源属性本不该成为顾虑。
当然我更希望调试器开源,但非开源状态并不困扰我;某种意义上,我将其视为使用.NET的“代价”。
啊,没错——毕竟若有人要在技术栈上构建项目或业务,微软可是我们最值得信赖的企业。
短期内他们确实会犯错(如Hot Reload功能),但长期来看我绝对信任他们。
还有其他调试器可选(Rider的,或三星的开源版本)。更不用说.NET运行时和SDK中几乎所有其他组件都是开源的。
热重载有什么问题?我可能跟不上进度了
参见https://github.com/dotnet/sdk/issues/22247。他们在.NET 6开发周期的最后一刻将其从开源的
dotnet watch命令中移除,意图仅通过Visual Studio提供该功能。在社区强烈反对后,他们撤销了移除决定。它在各个方面都糟糕透顶。
由于非开源,他人无法在此基础上开发,社区因此蒙受损失。你无法使用Windsurf、Cursor等工具…
Windsurf和Cursor并非“社区”组成部分,它们是与微软产品竞争的商业软件。
但我是社区成员,希望使用这些工具——若微软能开源这些组件,我本可以做到。我相信不止我一人如此。为何商业产品和公司不能属于社区?标准究竟是什么?
你可以使用三星的开源调试器、dnSpy的开源调试器,或自行编写调试器。微软调试器采用专有模式,并不妨碍他人开发替代方案。
按你的逻辑,那为何要开源任何东西?为何要支持Linux及其他平台?甚至Mac?Linux作为竞争对手远比Cursor和Windsurf强大,但微软仍选择了开源路线。
似乎调试器本身是开源的,但封装器采用与VS相同的许可证,属于非开源组件。
微软对Python也玩过类似手段。VSCode的Python插件套件中有大量组件被专门锁定在VSCode本体上,无法在分支版本中使用。
我们又被“拥抱/扩展/消灭”策略耍得团团转。
完全同意,但最根本的问题(空值类型)恐怕永远无法真正解决。
呃,我认为空值问题被严重夸大了,尤其在实时编译(NRT)时代。老实说我记不清上次看到
NullReferenceException是什么时候了,反正是很久以前的事。而且我从不用Option这类东西——实在不喜欢它们。确实被夸大了。向来如此。过去二十年里我遇到的每个NRE(空指针异常)都只是其他缺陷的表象——即使禁止空值或消除空值,这些问题也 *不会凭空消失 *,它们依然存在,只是会以其他异常形式显现,甚至更糟。好吧,或许不是二十年里每个NRE都如此,但至少99.9%是这样。
我认为关键在于“空值是价值百万美元的错误”这类说法主要源自数据库空值(其实那也夸大了)。程序员看到这种说法后,联想到自己遭遇空指针异常时的恼怒,便误以为两者本质相同,却未意识到二者截然不同——那些异常的出现,根本是因为自身或他人代码存在缺陷,异常本身具有合理性。
NRE 指向的是其他问题。人们总把空值当作根源,但真正的症结在于程序错误。事实上如今整型比引用类型危险得多——其默认值0会导致数据损坏,而非抛出合理异常。
[删除内容]
Option<T>与Nullable<T>并非完全相同(尽管概念极为相似,若非“完全相同”)。Nullable<T>用于使值类型可空,而引用类型本就可空。Option<T>适用于任意类型,其目的并非赋予可空性,而是实现“可选性”——即允许返回该类型或不返回。两者的主要差异体现在模式匹配机制上:使用
Optional<T>时完全无需考虑null值。此外,从概念定义上讲,可选类型可以包含其他可选类型,而
Nullable<T>不能。虽然你可能不会刻意显式使用Optional<Optional<T>>这类类型,但泛型方法等场景中可能隐式出现此类类型组合。[已删除]
没错,这种切换语句本质并无差异,无非是在空值分支中改为检查类型是否为
None。但考虑到.NET/C#实现Optional的思路,关键区别在于Optional检查无需默认分支(尽管类型检查仍可能需要)。他们曾指出,当联合类型每个成员都有对应分支时,模式切换将具有穷尽性。因此Optional应遵循相同机制——只要对泛型类型和None进行检查,就能实现穷尽性。但这可能也不是一个恰当的例子,因为它本质上只是在处理“常规”的多态性,并未涉及任何
Optional值或概念上类似的机制。虽然不清楚具体实现细节,但我认为该node参数不应允许为空——尤其当你拥有多种节点类型并通过模式匹配进行判别时,完全可以引入NullNode或EmptyNode等类型来表示节点缺失(类似Optional实现中的None类型)。是的,但这正是关键所在——你必须检查。在你的代码中,直接解引用
node就会引发空指针异常。编译器可能会发出警告,但你可以忽略警告;若出现错误,则需借助!操作符规避(或使用Nullable<T>时直接调用.Value)。而使用
Optional时就无法这样操作。其设计理念在于:获取值前必须先检查其存在性,因此模式匹配应运而生——它能通过单次操作/语句同时完成这两项检查。编译器中存在专门检测空值相关代码中潜在空指针的特性/分析器。而
Optional(至少设计良好的实现)则无需此类工具(并非说完全不可能出现检测机制),因为其语法/API本身禁止对错误值执行错误操作。例如当IDocumentNode拥有Children属性时,你永远无法对None对象调用.Children。[已删除]
没错,它们不必如此,但1)其他语言通常就是这样实现的,2)在GitHub关于为C#添加联合型的讨论中,这正是联合型能派上用场的示例之一(顺带提一句,如果你觉得这样更好,我当时指出
Nullable<T>已经实现了大部分功能)。我认为其中一个好处是:当前
is运算符专为Nullable<T>设计,用于模式匹配T类型,而Optional<T>也需要实现相同功能。但既然他们必须让
is兼容联合类型,那么若Optional<T>直接用联合类型实现,他们就能“免费”获得Optional<T>(甚至可能将Nullable<T>转换为相同机制,而非特殊处理?)明白了。这种情况下或许不必使用
Optional。我也不确定是否需要。感觉这会诱导人们将所有类型都改写成Optional<T>。理想情况下,我认为应该更接近你所指出的方向——让
T?成为Optional<T>的语法糖,或许还能实现Nothing与null之间的隐式转换,但现在提出这个可能为时已晚。或许可以引入T??表示Optional<T>?或者T!?不过不确定这是否会与现有可空类型中!的用法冲突。虽然它确实表示“非空”。[已删除]
这种说法绝对是夸大其词。所谓“空值是价值百万美元的错误”之类的论调简直荒谬,尤其考虑到该论断主要源自数据库中的空值概念——那里空值异常根本不是问题,而编程中人们更青睐的可选类型,恰恰会引发与数据库空值完全相同的问题。
但类与结构体的空值处理机制截然不同。这会引发诸如:https://github.com/dotnet/csharplang/discussions/7902
我认为“?”运算符本就不该用于
Nullable<T>。结构体天生不该支持空值——这毫无意义,开发者本应将此作为铁律你对“正确”的定义是什么?C#的空值支持还缺什么?
引用类型默认不可空,声明空值类型需显式使用“?”,新类型也支持
Optional<T>机制。具体在哪个语言实现?
例如Kotlin。
https://kotlinlang.org/docs/null-safety.html#nullable-types-and-non-nullable-types
呃,我觉得在C#里把警告设为错误,再配合可空引用类型,已经足够接近了。
不过我还是更倾向于将可空性作为API的一部分。
之前看到消息说要引入总和类型。可能还没在这个版本实现。这个功能我很期待。
https://blog.ndepend.com/csharp-unions/
早该有了。其他语言早就有的基础特性,他们却在语言里添了这么多乱七八糟的东西。
[已删除]
我也是。
其实可以直接用可空类型并追踪赋值操作。
顺便分享我的实现方案:
https://github.com/forgotten-aquilon/qon/blob/master/src/Optional.cs
我可能漏了什么,但这不就是普通的可空类型吗?只不过没有静态分析警告。把自定义异常替换为NullReferenceException,把HasValue替换为引用类型的‘… is not null’,行为就完全一致了。
澄清一下,您是在问我为何要使用自定义的
Nullable<T>?您对通用行为的理解没错,但细节决定成败。明确语义上,我更倾向于显式使用空值而非null;无法意外赋值null;即使空值也能调用Equals、GetHashCode和ToString方法,这意味着它可作为字典键使用。
我认为
Nullable<T>本身已具备你描述的行为。当HasValue为false时,Equals无需解引用即可工作,GetHashCode返回零,ToString返回空字符串。当然,可空引用类型不具备此特性。但在此情况下,我认为maybe?.GetHashCode ?? 0和maybe?.ToString() ?? string.Empty能更清晰地提示可能存在值缺失的情况。不过这属于个人偏好问题。我之前未考虑字典键的情况。
Nullable<T>的键可以为空,但引用类型键不能。这点确实有道理。这其实不算真正的
Optional…真正的Optional不会抛出异常,还会提供隐式转换等更多特性。我并非否定你的方案可行,但这种设计容易造成误解。
咦?我认为Optional本质上就是一种类型,用于表示特定类型值的存在或缺失。其余都是语法糖。
好的,我并非争论,只是想提个建议:你的类型其实并不真正表示特定类型的值。它更像一个容器,可能包含该类型的值,也可能不包含。看看
Nullable<T>的源代码,它甚至支持隐式转换,实际上可以像表示该类型值那样使用(当然,这正如你所说属于语法糖)。真正的可选类型应该采用联合类型(至少概念上如此),而非单纯能存值或不存值的容器。
例如,你的类型至少无法实现以下操作:
Optional<bool> o = true;但若在实现中添加:
public static implicit operator Optional<T>(T value) => new(value);则可实现该功能。自从引入NRTs以来,我只见过1次NRE(在启用NRTs的项目中;而在未启用或直接忽略警告的项目里,我见过不少)
还在等我的联合类型😭。真希望它能更接近TS,这样我就不用依赖JS生态了。
需要添加错误检查功能。
性能方面还得看Java的表现。
太棒了!那些LINQ基准测试数据简直惊艳。
奇怪的是我的手机能流畅加载这个页面。
我认为这在Java中被称为“跳板函数”。
工作环境卡在.net 4.7.2版本。现在升级的话,性能提升幅度简直不敢想象
一年半前我们从.NET 4.5升级到.NET 6或7(记不清具体版本了)。升级后内存占用骤降至原先的1/8,仅剩12.5%。简直不可思议!
数组协变性是Java的错误设计,而.NET却沿袭了这一缺陷。
从某种角度看这有其合理性,因为.NET最初旨在通过J#语言运行Java代码。但J#从未获得发展机会——它基于过时的Java版本,在.NET发布前几乎已被所有人弃用。
此时J++登场。当Sun因微软改进Java使其兼容COM(特别是添加属性与事件)而起诉微软时,和解协议要求J++冻结在Java 1.1版本。这成为实际困境,因为Java 1.2引入的诸多增强功能已被业界公认为必要。
回到J#的话题,我不确定它是否直接基于J++开发,抑或仅受其影响。但无论如何,它同样受限于Java 1.1的功能范围。这意味着它注定失败,因此数组协变性其实并非必需。
.NET平台是否还有需要性能优化的环节?感觉现在所有操作都快如闪电
Linux系统中许多底层操作效率依然极差。SqlClient持续存在十余年的性能问题和漏洞。
本文详述的改进多属微基准测试层面的优化,实际应用中你可能难以察觉性能提升。
所以没错,还有大量改进空间哈哈。你总不会以为不会有“.NET 11性能优化”的专题文章吧?
这看法有点悲观了吧?多数优化都相当基础,应该能对现有应用产生积极影响。那些在某些情况下消除GC需求的优化方案在我看来很有前景,现实中存在大量短寿命对象引发内存压力的案例。
我还注意到他们做了些Unix特有的改进,虽然没什么惊艳之处。不过我个人确实没发现什么明显缺陷——毕竟我只在Unix上搞过Web服务相关的工作,这大概就是原因。
不,这并非主观臆断。对绝大多数应用而言,这些原始数据本身意义有限。
其价值在于 聚合效应 或大规模应用场景。微软自身比大型企业客户更能从这些改进中获益。
我敢断言,若这些数字对“你们”(团队或公司)具有实质意义,你们早该放弃.NET(或任何类似技术栈)了。
请注意,我并非否定这些改进的必要性或价值(我们始终应追求各层级的代码更高效运行)。
相比.NET 4.6性能提升显著吗?我在工作中被迫使用它时,感觉运行极其缓慢(相较于Go或Rust)。后来尝试.NET Core后稍有好转。
这是个严肃的问题 🙂
编辑:感谢各位解答,未来或许会再尝试 🙂
是的,如今.NET的性能表现令人惊叹。
我期待看到基准测试数据,对比其他框架每年投入的资金规模。
我管理的系统承载着与StackOverflow全盛时期(AI时代前)相当的流量。
当我们从完整框架(v4.8)迁移至新框架(v6)时,计算资源分配直接减半。除迁移所需操作外未作其他重大变更。
不仅如此,响应时间和内存负载也同步下降——虽非50%的惊人幅度,但仍达显著改善(10%+)。
若能接受垃圾回收机制,.NET的性能表现已堪称顶尖。微软还推出了大量工具,让开发者更轻松地利用技术栈并最大限度规避垃圾回收。
虽然内存控制精度不及Rust/C++,但相较于旧版框架已实现飞跃性提升。
当然。虽然你可能看不到像Go或Rust那样稳定流畅的性能表现,但.NET(核心版)确实是个相当可靠的全能选择。
根据具体工作类型,我根本不会犹豫选择它。
完全同意。
Go和Rust的定位与.NET框架时代的定位截然不同,所以…这倒也说得通。
没错。
难道你不讨厌SQL+异步+VarChar Max组合把数据库调用拖到死吗?
当然,但如果每次更新都能略微改进一点,长期积累下来效果依然显著。
其实我更想表达的是对它的赞美
Stephen每年至少写一篇这类文章,等着看下篇吧 🙂
我个人非常渴望能显式释放内存的选项,包括使用区域分配器执行操作后立即低成本清理所有占用内存。C#在游戏开发场景中不够理想的核心问题在于:所有堆分配的内存都必须由垃圾回收器管理,这会导致不可预测的性能骤降风险。
这在不安全代码中早已实现,因此我猜阻碍其进入标准代码的并非技术难题,而是实际考量——它突破了垃圾回收的边界,所以被隔离在不安全代码块中,仅供参考。
我认为最佳解决方案是建立堆内存分配池,并设计可复用的实例。这样可在加载界面等场景通过移除实例并强制垃圾回收来缩减内存占用。
但这种方案未必适用于所有场景。
如果能找到或抽空实现支持对象池作为分配器的集合类型,应该会非常实用。不过话说回来,如果语言和标准库类型能直接支持传递实际分配器(哪怕只是单个大型异构内存缓冲区),那确实会更理想。
[已删除]
怎么操作?我是不是漏了什么?
《永远的第一次》比《随它去》好听多了,我为此死磕到底。
赞同
这正是“过早优化是万恶之源”的典型案例。该谚语的作者并非否定所有优化,而是特别针对手动将乘法转换为加法这类微小优化提出警示。
通俗而言,编写能清晰体现开发者意图的代码更为明智,优化技巧应交由编译器处理。编译器不仅能更可靠地完成优化,若发现更优方案还能无缝切换,且无需开发者额外付出。
但重大优化(例如用单次数据库调用替代五次调用)仍应由程序员负责。
我认为开发者在初始构建阶段对性能的责任应更进一步。
无论如何,每当提及这段话时,我常引用以下内容:
https://ubiquity.acm.org/article.cfm?id=1513451
“过早优化是万恶之源”——这句话长期以来被软件工程师奉为圭臬,用来规避在软件开发周期结束前考虑应用性能的问题(而此时出于经济或上市时间的考量,优化阶段通常会被忽略)。然而, 霍尔的本意并非“在应用开发早期关注性能表现是错误的”。 他特指的是“过早优化”;且在他提出该观点的年代,“优化”的含义与今日大相径庭。当时的“优化”常涉及汇编语言代码中计数周期和指令等操作——这绝非程序初始设计阶段应进行的工作,毕竟此时代码库尚处于动态演变期。
感谢您花时间整理这段内容。我厌恶人们过度引用“过早优化”名言却曲解其本意,以此为借口在设计阶段偷懒。
我认同此观点,但需补充:开发者常被迫采用不恰当的系统架构——这些架构多因营销炒作而非实际需求而被选中。
目前我正在抵制在基础CRUD应用中使用Azure事件服务。我敢说,他们迟早会拆分功能模块,只为强行引入消息队列。
在微优化(如选择更优指令集)与数据库调用之间存在诸多变量。“意图”这个概念若脱离上下文,其模糊性丝毫不亚于“过早优化”的警句。使用默认分配方法创建新对象是否传达了你的意图?某种程度上是,但上下文信息大多缺失。因此编译器无法真正解决问题并选择最佳分配方法。你得到的只是基于启发式算法的优化,这些优化在大多数程序中平均性能似乎略有提升。
有时确实能传达。例如考虑这行代码:
语义上,这先创建一个默认值的
RecordType,再创建其副本并覆盖两个值。此时编译器能推断出仅需副本,无需实际创建中间对象。
话虽如此,我承认意图有时确实模糊。正因如此,我更青睐能最小化冗余代码、实现业务逻辑与仪式化操作高比例的语言。
// 注:我实际并不使用C#记录类型,也不清楚编译器/JIT的具体行为。此处仅为理论示例,说明少量上下文即可揭示意图。
我不要性能优化,我要的是.NET GitHub公开问题的修复。那个失效的运行时标志、wasm导出功能、globaljson语法等等
但修复漏洞太无聊,搞些嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡嗡�
他们深入探讨细节固然可贵——但若能提供改进幅度的概览就更好了。
是提升5%?还是10%?
[已删除]
取决于你的升级起点。.NET 8(准确说是.NET Core及以上版本)?非常简单。.NET Framework 3.5?相当复杂
[已移除]
老实说,这简直是打开了潘多拉魔盒。官方指南在此:https://learn.microsoft.com/en-us/aspnet/core/migration/fx-to-core/?view=aspnetcore-9.0
我倾向于采用混合方案:先进行就地增量迁移,将代码库拆分成若干模块逐个迁移至.Net Standard;待核心组件全部迁移完成后,再整体迁移基础设施层,可一次性完成或通过反向代理实现。
[removed]
务必从部门开始着手。具体取决于应用类型和架构,但在经典洋葱结构中,应从核心模块切入。按顺序迁移数据持久层、领域层、业务逻辑层及服务层,待仅剩顶层仍使用.Net Framework时即可启动整体迁移。迁移过程中98%的时间将耗费在.NET Framework上,这很正常。关键是要保持规模精简且功能正常,否则很快就会失控。
有人该把.NET 10的性能优化应用到这篇博文上了。
接下来四周的必读内容 xD
Java早已具备此功能。尽管这不会改变我的工作方式,但看到.NET开始在该领域迎头赶上,我仍感到由衷欣喜。
Java并非通过逃逸分析实现栈分配,而是采用标量替换机制:它将对象展开并将其数据存入寄存器。
https://shipilev.net/jvm/anatomy-quarks/18-scalar-replacement/
.NET同样采用此机制,但作为独立阶段处理:对象可先栈分配,随后(按我们的术语)可能进行字段提升并保存在寄存器中。
这样我们仍能为小型数组等对象保留栈分配优势——当代码无法明确判断对象哪个部分会被访问时,提升操作往往不可行。
当然有!我只是在说明Java的实现方式。我在这方面也不是专家。
他们甚至在提及.NET或编程之前,就写了整篇导论文章
自从6.0就没碰过.NET Core…现在居然到10了?天啊。
每年都会配合Windows重大版本发布新版本
说实话这纯属巧合。
我最近不碰Windows了,但有点困惑。你说的重大版本应该是Win10或Win11的重大更新吧?记得微软说过“不会推出重大新版本”,所以现在说的应该是重大“服务包”更新吧。
我指的是Windows 24H2、25H2等版本
这是AI优化版本吗?还是他们停止向Copilot提供运行时支持了?
懒得读这些,直接让AI帮我总结。