.NET 11 性能全解读:从 JIT 到基础库,这一版到底快了多少
原文:Performance Improvements in .NET 11 | 作者:Stephen Toub(Microsoft 杰出工程师) | 发布:2026 年 9 月 15 日
说明:本文为上述官方长文的中文整理编译版。文中每一个 PR 编号、每一位贡献者 GitHub ID、每一组 BenchmarkDotNet 数据都来自原文,未做增删或演绎。为了方便中文阅读,案例解释与技术背景做了本地化改写。
如果你只用一句话总结 .NET 11:你什么都不用改,升级就变快。
没有新的 API 要学,没有配置要打开,也没有「高性能模式」要手动切换。这一次微软把力气花在了最不性感的地方——少一次边界检查、少一次装箱、少一把锁、把两条指令合成一条、把一段循环交给 SIMD、把一个 O(N²) 悄悄改成 O(N)。每一处单独看都微不足道,几百处叠起来,就是你 P99 延迟曲线上肉眼可见的那一段下沉。
官方原文足足有五万多词。这篇中文版把它完整过了一遍:18 个章节、136 组对照数据、263 个 PR、29 位外部贡献者。如果你没时间通读,先看下面这张清单;如果有时间,从「JIT 编译器」那部分开始慢慢读——那才是真正藏着最多魔法的地方。
怎么读这篇文章
全文的数据几乎都来自 BenchmarkDotNet,写法统一:同一份代码,分别在 .NET 10 和 .NET 11 上跑,然后并排对比。
| 列名 | 含义 |
|---|---|
| 方法 | 被测量的那个方法(有时会带参数,如 Size=4) |
| 运行时 | .NET 10.0 / .NET 11.0 |
| 平均值 | 单次操作的平均耗时(ns / μs / ms) |
| 比值 | .NET 11 ÷ .NET 10,小于 1 表示新版本更快,0.5 就是快一倍 |
| 分配 | 单次操作在托管堆上分配的内存,– 表示零分配 |
| 代码大小 | JIT 为该方法的机器码字节数(部分表格才有) |
请特别注意「分配」这一列。 很多微基准里耗时只降了 20%,但分配从 72 B 变成 –(零分配)——后者对 GC 压力、对 P99 抖动的影响,往往比那 20% 的墙钟时间重要得多。
另外,微基准放大的是「单次操作的成本」。真实应用里你这个 API 每天只调几次,那 3 ns 的差距毫无意义;但如果你在热路径上每秒调它一百万次,同一处改动就是实打实的几个百分点 CPU。不要对着表格做优化,对着你自己的火焰图做优化。
速读:这一版最夸张的 37 处提速
下面这些是从全文 136 组对照数据里挑出来的、提速 ≥ 1.6 倍的部分(格式:旧 → 新,提速倍数,优化点):
JIT·去抽象化
ReadOnlyInstance13.874 ns → 2.674 ns,5.3×,分配 32 B → – 去抽象化(Deabstraction)FormatNullableInt9.583 ns → 1.987 ns,4.8×,分配 24 B → – 去抽象化(Deabstraction)Shared7.166 ns → 1.764 ns,4.0×,分配 24 B → – 去抽象化(Deabstraction)
JIT·简化
SingleToUInt644.810 ns → 1.366 ns,3.6× float/double 转 long/ulongDoubleToUInt644.663 ns → 1.363 ns,3.4× float/double 转 long/ulongSingleToInt645.103 ns → 2.093 ns,2.4× float/double 转 long/ulong
JIT·内在函数与 GC
CollectGen010.737 ms → 310.7 μs,34.5× 写屏障与 GCCountOrdinal_Generic59.24 ns → 10.01 ns,5.9×,分配 288 B → – 内在函数(Intrinsics)QuaternionDot2.640 ns → 1.326 ns,2.0× 内在函数(Intrinsics)
数值
ToStringLargeDecimal135,894,135.4 ns → 7,868,359.3 ns,17.2× limb 从 32 位拓宽到 64 位BitIncrementHalf3.918 μs → 573.6 ns,6.7× 小心 legitimacy 的向量化FormatSubnormal6.884 μs → 1.237 μs,5.6× Number.BigInteger 与浮点解析/格式化
全球化
LongAscii248.38 ns → 39.22 ns,6.2× 先走托管的 ASCII 路径ConvertTimeToUtc51.97 ns → 20.23 ns,2.6× 把年度转换数据缓存起来ConvertTimeFromUtc45.13 ns → 19.44 ns,2.3× 把年度转换数据缓存起来
字符串与 Span
Decode10.70 μs → 2.256 μs,4.8× DecodeFromUtf8InPlace 也向量化了ValidWithSurrogatePairs2.994 μs → 856.8 ns,3.4× 先把 Arm64 上的「逐元素回退」干掉ToBase64String_InsertLineBreaks560.66 ns → 194.99 ns,2.9× 带换行的 Convert.ToBase64String 终于吃到向量化
查找与比较
Match861.9 μs → 9.251 μs,90.9× 改用 SearchValues整串搜索 Miss861.4 μs → 9.647 μs,90.9× 改用 SearchValues整串搜索 IgnoreCaseAlternation415.0 μs → 7.012 μs,58.8× 前缀从 "htt" 变成 "http"
集合与 LINQ
AppendChain8.253 ms → 28.49 μs,289.9×,分配 187.57 KB → 136.77 KB 修掉那个 O(N²)FreshDestinationUnionWith46.207 μs → 2.433 μs,20.0×,分配 252.27 KB → 76.07 KB 布局兼容时,直接克隆SequenceEqual925.8 ns → 122.2 ns,7.7× 把活交给运行时
I/O
SetSameValue8.281 ns → 2.168 ns,3.8×,分配 32 B → – TextWriter、TarWriter、ZipArchive、FileInfoReadCentralDirectory553.5 ns → 258.1 ns,2.1×,分配 5.05 KB → 1.13 KB TextWriter、TarWriter、ZipArchive、FileInfoRead4K4.564 μs → 2.747 μs,1.7×,分配 176 B → – 一个比特位省掉整条回调
网络
EscapedAscii442.6 ns → 208.1 ns,2.1×,分配 448 B → 368 B 向量化扫描、一次建串LongHost218.1 ns → 112.9 ns,1.9× 向量化扫描、一次建串Serialize78.49 ns → 41.90 ns,1.9×,分配 296 B → 64 B 请求头、分隔符、trailers
JSON
Write30.83 μs → 7.875 μs,3.8× 用 SearchValues 预计算转义集合
Diagnostics
GetProcessName332.13 μs → 11.90 μs,25.0× 只查名字,就别把整个对象都填上ExtractTraceParent45.41 ns → 9.137 ns,5.0× trace-id 校验与 baggage 编码Record17.16 ns → 4.511 ns,3.8×,分配 72 B → – 可观测仪器不再硬凑一个数组
Cryptography
EncryptKeyWrapPadded2.497 ms → 111.3 μs,22.2×,分配 264 KB → 88 B 一次创建 cipher,整轮复用DecryptKeyWrapPadded2.473 ms → 123.5 μs,20.0×,分配 264 KB → 88 B 一次创建 cipher,整轮复用Read(VisibleString)1.243 μs → 112.7 ns,11.0× 把校验和转码向量化
以上只是「提速超过 1.6 倍」的部分。全文共含 136 组 .NET 10 / .NET 11 对照数据,另有大量没有 benchmark 但同样吃掉的真实开销。
目录
- 基准测试环境:怎么看懂后面那些数字
- JIT 编译器(一):去抽象化、运行时异步、边界检查与断言传播
- 去抽象化(Deabstraction)
- 运行时异步(Runtime Async)
- 边界检查(Bounds Checks)
- 断言传播(Assertion Propagation)
- JIT 编译器(二):简化(Simplification)
- 一、常量折叠:编译期能算完的,绝不拖到运行时
- 二、分支简化:把不可预测的判断变成纯粹的算术
- 三、If-conversion:一句话能解决的,别写 if/else
- 四、窥孔优化:认出一个短模式,就换掉它
- 五、真实的性能数字:float/double 转 long/ulong
- JIT 编译器(三):向量化(Vectorization)
- 嵌入式广播:一条常量只需要存 4 字节
- 嵌入式掩码:把 blend 折进同一条指令
- Blend 的化简:全 1 掩码其实只是一个 mov
- 小结
- JIT 编译器(四):内在函数、寄存器分配、写屏障与 GC、冻结数据、JIT 吞吐
- 内在函数(Intrinsics)
- 寄存器分配
- 写屏障与 GC
- 运行时知识与冻结数据
- JIT 吞吐与清理
- 启动与部署
- 让 TPA 列表的构建少干点活
- ReadyToRun 里的
Comparer<T>.Default/EqualityComparer<T>.Default - EventSource:把元数据发现搬到构建时
- NativeAOT:小块内存不再预占 64 KB 虚存
- 顺带一提:R2R 的活不只为桌面和服务器
- 线程
Monitor.Wait/Pulse:把条件变量贴到锁上- 把
volatile的「花生酱」刮掉 - 运行时哈希表:更窄的屏障、epoch 回收、xxHash
- 线程池:小任务的调度开销
- CA2027:那个让大规模服务出问题的
Task.Delay
- 数值
- BigInteger:limb 从 32 位拓宽到 64 位
- 免转码:BigInteger 与 Complex 的 UTF-8 解析/格式化
- BigInteger → double / float 的小值快路径
- Number.BigInteger 与浮点解析/格式化
- Matrix4x4.GetDeterminant 的 SIMD 化
- TensorPrimitives.Asin:一次算一整组
- BitIncrement / BitDecrement:小心 legitimacy 的向量化
- TensorPrimitives.Round:删掉一次多余的全量遍历
- Half.CompareTo:把 NaN / 有符号零的特殊处理只做一次
- Math.BigMul:直接映射到 x64 的 mulx / imul
- Guid / Decimal / IPAddress:「长度既定」技巧
- Guid.NewGuid 在 Linux 上也快起来了
- Random.Shuffle:少拷一次长度、少判一次自转
- Random.Next:一个刻意为之的 NoInlining
- 全球化
- TimeZoneInfo:把年度转换数据缓存起来
- Invariant casing:先走托管的 ASCII 路径
- 更小的几处:按需分配、换掉装箱哈希表
- 往返 "O" 格式与 DateTime.ToString("G")
- 字符串与 Span:到处都在跑的那点代码
- UTF-8:先把 Arm64 上的「逐元素回退」干掉
- UTF-8 解码:只在「真的有非 ASCII」时才去算位置
- Base64 编码:带换行的
Convert.ToBase64String终于吃到向量化 - Base64 解码:
DecodeFromUtf8InPlace也向量化了 CommonPrefixLength:让短的在前面也别吃亏Encoding.GetEncoding:把读写锁换成ConcurrentDictionarystring.Concat:认出数组和 List,直连 span 实现- 最后一颗小糖:
span[start..]的 IL 从 21 字节缩到 9 字节
- 查找与比较
- 先说正则:为什么它值得单独讲一大段
- 收尾清理 pass:把公共前缀提出来
- 忽略大小写的 alternation:前缀从
"htt"变成"http" - 捕获组挡住了前缀识别:让
\b(in)\b也能搜"in" - 多字面量前缀:改用
SearchValues<string>整串搜索 - 找到位置之后:证明「不用回溯」,直接测最终位置
- 锚定模式:连一个字符都不用看就能判死
RegexOptions.Compiled:补上源生成早就有的两招Ascii.Equals:长度 8 到 15 也能向量化SequenceEqual认识Guid和Int128了string.Split:一次打包比较,跳过两倍输入- Arm64:用
shrn一步把掩码压成标量 - 新增:一整套「查空白」的 span API
Span<T>.Sort:终于不再装箱你的结构体比较器- 小结:这一章的主题其实是「别做多余的功」
- 集合与 LINQ
- ImmutableArray:把活交给运行时
- Array.FindAll:结果很小时,别倒贴
- Dictionary 与 HashSet:抠掉虚调用和边界检查
- UnionWith:布局兼容时,直接克隆
- FrozenDictionary:用现成的 Count 定容量
- SetEquals:能线性扫就别重建
- SortedSet:视图的 Clear 少折腾
- LINQ:让迭代器把知道的说出来
- FullJoin:终于把五种连接补齐
- AsyncEnumerable:修掉那个 O(N²)
- I/O
- 重定向进程输出:别再占用线程池线程
- RandomAccess.Read:一个比特位省掉整条回调
- 高层的小分配:TextWriter、TarWriter、ZipArchive、FileInfo
- 把压缩器暴露出来:绕过适配流的缓冲
- 网络
- Happy Eyeballs:别把延迟串成一串
- Socket.Blocking:连接建立后翻回阻塞模式
- Winsock 扩展函数指针缓存:从加锁链表到写时复制数组
- SslStream:中间数组和 BIO 拷贝
- Linux 上的 SslStream:自定义 BIO,把拷贝彻底消掉
- HttpClient:请求头、分隔符、trailers
- Uri:向量化扫描、一次建串
- 小结
- JSON
- Utf8JsonWriter:用 SearchValues 预计算转义集合
- Utf8JsonReader:一次跳过一整段空白
- Diagnostics
- W3C Trace Context:trace-id 校验与 baggage 编码
- Process 启动:环境变量封送与 posix_spawn
- ProcessName:只查名字,就别把整个对象都填上
- Native AOT:只为本地进程 API 付钱
- Metrics:可观测仪器不再硬凑一个数组
- MemoryCache:干掉性能计数器里的 false sharing
- Logging:JSON 日志复用线程级缓冲区
- Cryptography
- ValueAsnReader:让 ASN.1 解析不再层层包对象
- ASN.1 字符串:把校验和转码向量化
- SHA-1 one-shot:AssemblyName.GetPublicKeyToken
- AES 密钥封装:一次创建 cipher,整轮复用
- 证书吊销检查:CRL 缓存与 AIA 下载去重
- 写在最后
下面是正文。倒好你手边那杯热饮,我们把音量拧上去。
在《The Office》和《Parks and Recreation》把「伪纪录片」这个概念刻进几亿人脑子之前,还有 Christopher Guest。他不算发明了这个类型,但公认是最有影响力的实践者之一——我个人认为,没人比他更强。《Waiting for Guffman》和《Best in Show》我看了数不清多少遍。但印象最深、动不动就想引用一句的,还是《This Is Spinal Tap》(《摇滚万岁》)。
看过的人已经知道我要说什么了(没看过的,周末安排上了)。这是一部虚构纪录片,讲一支上了年纪的英国摇滚乐队 Spinal Tap,乐队成员完全就是我们脑子里「过气摇滚明星」该有的样子。其中有一幕非常经典:吉他手 Nigel 带导演 Marty 参观他最宝贝的装备,特别展示了一台与众不同的音箱——它的旋钮刻度,到 10 并不停。于是有了整部电影被引用最多的一段对白:
Nigel:「你看,大多数人弹琴,都是开到 10。你这儿是 10,一路拧上去、拧上去、拧上去,你的吉他开到 10。到那儿你还怎么往上走?往哪儿走?」
Marty:「我不知道。」
Nigel:「哪儿也去不了。正是。那我们要是需要再推一把、冲下悬崖,你知道我们怎么办吗?」
Marty:「把它开到 11?」
Nigel:「11。正是。再响一格。(One louder.)」
这就是 .NET 11。它「再响一格」——又一年的性能工作,把运行时和基础库整体推快了一截。
当然,Nigel 那台音箱的前提是荒谬的,紧接着几句对白就把这点说透了:
Marty:「你为什么不干脆把 10 做响一点,让 10 就是最大,把 10 那一档调响些?」
Nigel:(停顿)「……这些能到 11。」
而 .NET 11 不一样,它是真的高了一格、真的更响。下面各节里全是实打实的改进:去掉了一次边界检查、省掉了一次不再发生的堆分配、少拿了一把锁、一个循环比一年前少跑几个周期、某个比较被折叠成了常量、某个冗余检查被提到了循环外、两条指令被融合成一条、一次系统调用被绕开、一次数组拷贝交给了 SIMD……诸如此类。真正的性能工作就是这样:一次一点收益,一点一点复利累积,直到整体可测量、可证明地「更响」了。
所以在这篇文章里,我会像往年写 .NET 10、.NET 9、.NET 8、.NET 7、.NET 6、.NET 5、.NET Core 3.0、.NET Core 2.1、.NET Core 2.0 那样,不紧不慢地把其中几百处逛一遍。
这篇很长,它就是应该长。倒好你手边那杯热饮,坐下来,我们把音量拧上去。
基准测试环境:怎么看懂后面那些数字
和往年一样,文章里塞满了用于说明单个改进的微基准测试(micro-benchmark)。几乎全部使用 BenchmarkDotNet,而且每个例子都写成自包含的,你可以直接照着跑一遍。
首先确保机器上同时装了 .NET 10 和 .NET 11(绝大多数基准是在两个版本上跑同一份代码做对比),然后在一个全新的 benchmarks 目录里新建控制台项目:
dotnet new console -o benchmarks
cd benchmarks
把生成的 benchmarks.csproj 内容替换成下面这份,它同时面向两个 TFM,这样 BenchmarkDotNet 就能分别为每个版本构建:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net11.0;net10.0</TargetFrameworks>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ServerGarbageCollection>true</ServerGarbageCollection>
<SystemPackageVersion Condition="'$(TargetFramework)' == 'net10.0'">10.0.12</SystemPackageVersion>
<SystemPackageVersion Condition="'$(TargetFramework)' == 'net11.0'">11.0.0-rc.1.26425.128</SystemPackageVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.16.0-preview.1" />
<PackageReference Include="System.IO.Hashing" Version="$(SystemPackageVersion)" />
<PackageReference Include="System.Runtime.Caching" Version="$(SystemPackageVersion)" />
<PackageReference Include="System.Numerics.Tensors" Version="$(SystemPackageVersion)" />
</ItemGroup>
</Project>
要跑某个基准,就把它的完整内容覆盖到 Program.cs 里再执行。每个基准顶部注释里都写好了确切的命令,大多数情况下是:
dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
这条命令以 Release 构建,并让基准分别在 .NET 10 和 .NET 11 上运行,输出并排对比。另一种常见形式用于「在同一个运行时上比较两种写法」的场合:
dotnet run -c Release -f net11.0 --filter "*"
老规矩的免责声明:这些都是微基准,很多测的是短到眨个眼就错过的操作。你的结果会随硬件、操作系统、运行时配置、你机器当时还在干点别的什么、以及水星是否逆行而变化。
本文的数据口径:所有表格中的「比值」(Ratio)以 .NET 10 为基准 1.00,比值小于 1 就代表 .NET 11 更快;「分配」列里的 – 表示零分配;时间单位 ns 是纳秒、μs 是微秒。
每一行托管代码最终都要落到 JIT 编译器手里,那我们就从那儿开始。
JIT 编译器(一):去抽象化、运行时异步、边界检查与断言传播
在 .NET 里,能带来性能提升的地方很多,但很少有哪个像即时编译器(JIT)影响面这么广。C#、F#、Visual Basic 通常先被编译成中间语言(IL),再由 JIT 把 IL 变成 CPU 真正执行的本机指令。因此,一处 JIT 改进能惠及任何出现该模式的应用与库代码,而且往往不需要改动源码、甚至不需要重新编译应用。哪怕只是少一条指令、或者证明某次检查没必要,只要那段代码在极热的路径上,攒起来就很可观。
去抽象化(Deabstraction)
我们开发者爱抽象。抽象让我们写出干净、可复用、面向对象的代码,但我们不希望在运行期为每一层抽象都付钱。当运行时能证明抽象的副作用「不可观测」时,它常常可以把抽象拆掉:看到一个虚调用就推断出它实际会调哪个具体方法;看到一次堆分配就认出这个对象根本不会离开当前栈帧;看到一次接口转换就复用前面已经确立的类型事实。这个过程就叫「去抽象化」(deabstraction)。.NET 多年来在这个方向上稳步进步,.NET 11 继续。
接口调用有多贵
每次在 C# 里写 interface,你都在创建一份契约:任何实现该接口的类型都可以被替换进来。这种灵活性极有价值,正因如此我们才能写 IEnumerable<T>,然后让它同样好地跑在数组、List、其他集合、LINQ、自定义迭代器上。但 CPU 不懂契约,它只会执行指令。要把「调用这个接口引用指向的随便哪个方法」变成真正的机器指令,需要特殊机制。看这个例子:
private Animal _animal = Environment.TickCount >= 0 ? new Dog() : new Cat();
[Benchmark]
public int Speak() => _animal.Speak();
编译时,其它条件相同,JIT 并不知道 _animal 是 Dog 还是 Cat。它生成的代码是:加载实例的「方法表指针」(即对象类型句柄,有时也叫 vtable 指针,它存放在每个 .NET 对象的开头),在方法表中索引到 Speak 的已知槽位,再调用在那里找到的函数指针:
; x64
mov rcx, [rcx+8] ; load _animal
mov rax, [rcx] ; load method table
mov rax, [rax+40] ; load vtable chunk
call qword ptr [rax+20]
就为了这一次 Speak 调用,我们付了三次相互依赖的内存解引用加一次间接调用,因为处理器事先并不确定调用目标(它可以猜,也就是「投机执行」,但必须准备好猜错的情况);而且因为调用目标是间接的,JIT 无法内联被调方。无论 Speak 干什么,它的代码都没法折叠进调用方。
这是个性能问题。这些间接跳转本身有开销,但更大的代价是失去了内联的机会。内联不仅省下函数调用开销,更重要的是它把被调方的代码开放给作用于调用方的那一整套优化——常量传播、死代码消除、边界检查消除、进一步的去虚拟化等等。这意味着一串看起来人畜无害的小虚调用,一旦被去虚拟化并内联,就能塌缩成寥寥几条指令,跟原始源码比起来又便宜又认不出来。没有内联,每个被调方都是个不透明的黑盒;有了内联,JIT 就能看穿层层包装。
GDV:猜一下,然后验证
对于静态就能确定类型的情况,JIT 可以直接去虚拟化。比如它能证明 animal 一定是 Dog——因为对象刚被 new Dog() 创建出来、因为变量类型是 sealed 类、或者(在 NativeAOT 全程序编译下)因为 Animal 是 abstract 且整个应用里唯一派生类型是 Dog。这些情况下它可以直呼 Dog.Speak(),然后让内联器试试看。
但静态分析证明不了的情况,JIT 就转向基于profile的优化(PGO)。PGO 听着玄乎,概念其实很简单:配合「分层编译」,方法首次被调用时以几乎无优化的方式编译(Tier 0),JIT 可以在这份编译里插入探针(想象成 printf 调试),记录下运行时的真实情况——哪些分支被走过、虚调用点或类型转换处实际出现的具体类型是什么。如果一个方法被调用得够多或循环得够多,运行时会让 JIT 生成一个优化版(Tier 1),这次编译就能把profiling学到的东西全用上。
JIT 当然还得生成永远正确的代码。就算profile说 animal 100% 是 Dog,也不保证它以后永远是 Dog——可能前 1000 次传进来的是 Dog,第 1001 次传进来的是 Dolphin。那 JIT 怎么把这个学到的东西用起来?答案是插入一个运行期检查:
// JIT 大致生成的代码
if (animal?.GetType() == typeof(Dog))
{
((Dog)animal).Speak(); // 去虚拟化,可内联
}
else
{
animal.Speak(); // 原始虚调用,希望很少走
}
这个「猜一下再验证」的模式叫守卫式去虚拟化(guarded devirtualization, GDV),它贡献了真实工作负载中不少最大的吞吐提升。它不仅适用于虚分派,也适用于接口分派——而接口分派其实比虚分派还贵一点,因为一个类型可以实现任意多个接口,接口槽位没法简单映射到固定的 vtable 位置。
逃逸分析:把对象从堆上搬到栈上
去抽象化还能让对象创建更高效:一旦它看清了对象的类型。一般来说 .NET 的对象分配在 GC 堆上,被 GC 跟踪、在不可达时回收。堆分配通常很快,往往就是挪一下指针;但当空间不够挪指针时就贵多了,甚至可能触发一次 GC。而且每个已分配对象实际上都要分担所有 GC 的摊销成本,因为它最终都得被清理。
逃逸分析(escape analysis)就是用来回答「这个对象会不会逃出当前方法」的编译技术。如果能证明一个新分配对象的引用不会逃逸,JIT 就能更高效地分配它:没必要放进 GC 堆,因为不可能还有谁需要再引用它,于是可以分配在栈上——分配和清理几乎免费。栈分配甚至比堆的指针碰撞还快,它就是把栈指针减一下,而栈指针通常本来就躺在寄存器里。更重要的是:对 GC 零影响,因为函数返回时栈帧被原子地释放。
.NET 9 和 .NET 10 已经在栈分配委托与闭包、Nullable<T> 临时对象、小型辅助对象上做了大量投入。核心主题是:每一次「假阳性逃逸」——JIT 错误地断定一个对象可能逃逸而实际没有——都代表一次本可以避免的堆分配,我们要把这份假阳性清单一点点削掉。.NET 11 从好几个方向削减了它。
可空装箱:dotnet/runtime#122167
先看这个基准:
private int? _nullableNull;
private int? _nullableValue = 42;
[Benchmark] public object? BoxNullableNull() => (object?)_nullableNull;
[Benchmark] public object? BoxNullableValue() => (object?)_nullableValue;
[Benchmark] public string? FormatNullableInt() => Format(_nullableValue);
private static string? Format<T>(T value)
{
if (value is IFormattable formattable) return formattable.ToString(null, null);
return null;
}
| 方法 | 运行时 | 平均值 | 比值 | 分配 | 分配比值 |
|---|---|---|---|---|---|
| BoxNullableNull | .NET 10.0 | 2.095 ns | 1.00 | – | – |
| BoxNullableNull | .NET 11.0 | 1.764 ns | 0.84 | – | – |
| BoxNullableValue | .NET 10.0 | 9.213 ns | 1.00 | 24 B | 1.00 |
| BoxNullableValue | .NET 11.0 | 4.126 ns | 0.45 | 24 B | 1.00 |
| FormatNullableInt | .NET 10.0 | 9.583 ns | 1.00 | 24 B | 1.00 |
| FormatNullableInt | .NET 11.0 | 1.987 ns | 0.21 | – | 0 |
dotnet/runtime#122167 在 JIT 内部展开了可空装箱,把这个临时装箱对象暴露给逃逸分析;此前它被一个运行时辅助函数藏起来了。输入为 null 时两个版本都没有分配,因为压根没装箱;BoxNullableValue 两个版本都返回了装箱对象,意味着对象逃逸,所以 24 字节分配还在。但 FormatNullableInt 不同:.NET 11 的 JIT 现在能看出这个临时的 24 字节装箱不会逃逸,干脆把堆分配整个消掉了——耗时降到 0.21,分配归零。
枚举器的条件逃逸分析:dotnet/runtime#122946
枚举器上的逃逸分析通过一种叫条件逃逸分析(Conditional Escape Analysis, CEA)的机制进一步改进了。CEA 在 .NET 10 引入,.NET 11 扩展了它能安全识别的模式集合。
问题出在 GDV 优化 foreach (var x in someIEnumerable) 时产生的结构上:GDV 会把接口调用变成「类型检查 + 两个分支」,快分支针对最可能出现的集合类型,回退分支保留原来的接口调用。沿快分支去虚拟化 + 内联后,往往会暴露出该集合类型的枚举器分配;而后面保留的枚举器守卫里还留着 IEnumerator<T>.MoveNext 之类的回退调用。原来的逃逸分析看到这些调用,就断定「本地分配的枚举器可能逃逸」。
CEA 的做法是记录快路径分配与后面守卫所检查的枚举器局部变量之间的关系:如果每一次表面上的逃逸都只发生在一次失败的类型检查之后,JIT 就可以把这段代码区域克隆出一个「热版本」,在里面那些检查已知成立。在这个克隆里,对象到不了回退调用那儿,于是可以栈分配,甚至常常被提升成若干个独立的标量局部变量。原来的区域则保留为通用慢路径。
.NET 10 漏掉的一种情况是:GetEnumerator() 的实现返回了另一个 GetEnumerator() 调用的结果。比如集合表达式转成 IEnumerable<int> 时,会用到一个编译器生成的只读数组包装器,它的 GetEnumerator() 正是委托给底层数组的 GetEnumerator。有了 dotnet/runtime#122946,.NET 11 的 JIT 能处理这种「链式」结构:
private static readonly IEnumerable<int> s_readOnlyStatic = [1, 2, 3, 4, 5];
private readonly IEnumerable<int> _readOnlyInstance = [1, 2, 3, 4, 5];
[Benchmark] public int ReadOnlyStatic() { int sum = 0; foreach (int i in s_readOnlyStatic) sum += i; return sum; }
[Benchmark] public int ReadOnlyInstance() { int sum = 0; foreach (int i in _readOnlyInstance) sum += i; return sum; }
| 方法 | 运行时 | 平均值 | 比值 | 分配 | 分配比值 |
|---|---|---|---|---|---|
| ReadOnlyStatic | .NET 10.0 | 2.665 ns | 1.00 | – | – |
| ReadOnlyStatic | .NET 11.0 | 2.666 ns | 1.00 | – | – |
| ReadOnlyInstance | .NET 10.0 | 13.874 ns | 1.00 | 32 B | 1.00 |
| ReadOnlyInstance | .NET 11.0 | 2.674 ns | 0.19 | – | 0 |
静态只读字段那一路 .NET 10 就已经优化好了(JIT 基本能把它当常量);.NET 11 里实例字段这一路也丢掉了 32 字节的枚举器分配,吞吐直接收敛到与前者同一水平——快了 5 倍多。
别让地址暴露了对象:dotnet/runtime#121918
dotnet/runtime#121918(作者 @MichalPetryka)修掉了另一种「取地址导致对象看起来逃逸」的情形。IL 的 constrained. 前缀让同一条 callvirt 序列既能服务值类型也能服务引用类型:对值类型避免装箱,对引用类型则解引用接收者并走正常虚分派。ObjectEqualityComparer<T>.Equals(下面这个基准里 EqualityComparer<T>.Default 会用到它)里就有这样一个 value.Equals(other) 调用。接收者原本被表示成「通过一个局部变量的地址做间接读取」,而仅仅取这个地址就把局部变量标记成了「已暴露」,导致新分配的 Value 无法被考虑栈分配。现在接收者改成直接的值加载,那 24 字节的堆分配就消失了。
private static readonly Value s_other = new(42);
[Benchmark]
public bool Equals() => EqualityComparer<Value>.Default.Equals(new Value(42), s_other);
| 方法 | 运行时 | 平均值 | 比值 | 分配 | 分配比值 |
|---|---|---|---|---|---|
| Equals | .NET 10.0 | 3.874 ns | 1.00 | 24 B | 1.00 |
| Equals | .NET 11.0 | 1.786 ns | 0.46 | – | 0 |
时间砍掉一半以上,24 字节分配归零。
Tier 0 也不再为 ThrowIfNull 装箱:dotnet/runtime#129392
CEA 能把不逃逸的对象搬离 GC 堆,而有时 JIT 还能更进一步:证明这次分配根本不需要存在。泛型代码里的装箱就是常见的机会来源。比如 ArgumentNullException.ThrowIfNull 接受 object 参数:
static void Test<T>(T value)
{
ArgumentNullException.ThrowIfNull(value);
...
}
当 T 被约束为非空 struct 时,为了把 value 当 object 传进去,就要装箱。ThrowIfNull 在非空时其实是个 nop(if (value is null) Throw();),此前优化后的代码已经能消掉这次装箱,但 Tier 0 里没应用这个优化,ThrowIfNull 最终还是会分配。这不影响稳态吞吐,却会给 profiling 带来恼人的噪声,也会在启动阶段增加额外开销(那时这些代码还没被提升出 Tier 0)。.NET 11 里,dotnet/runtime#129392 把这个支持也加到了 Tier 0。
泛型虚方法(GVM)的三连击
在虚分派这一侧,多个 PR 一起改进了泛型虚方法(generic virtual methods, GVM):
- dotnet/runtime#120866(@hez2010):不再把
ldvirtftn的调用目标急切地溢出到临时变量,并在合法时让泛型虚目标解析前移到参数准备之前。 - dotnet/runtime#122023(@hez2010):让 JIT 能去虚拟化非共享 GVM,带上所需的泛型上下文,把间接分派变成直接、且可能被内联的调用。
- dotnet/runtime#128702(@hez2010):把该支持扩展到共享 GVM 以及需要实例化存根(instantiating stub)的默认接口实现。
这些优化在直接调用被内联时会增加总代码量,但这通常是我们想要的交易:更多真实工作对优化器可见了。
[Benchmark] public int NonShared() => ((IProcessor)new Processor()).SizeOf(42);
[Benchmark] public int Shared() => ((IProcessor)new Processor()).SizeOf("hello");
private interface IProcessor { int SizeOf<T>(T value); }
private sealed class Processor : IProcessor { public int SizeOf<T>(T value) => Unsafe.SizeOf<T>(); }
| 方法 | 运行时 | 平均值 | 比值 | 分配 | 分配比值 |
|---|---|---|---|---|---|
| NonShared | .NET 10.0 | 6.678 ns | 1.00 | 24 B | 1.00 |
| NonShared | .NET 11.0 | 1.764 ns | 0.26 | – | 0 |
| Shared | .NET 10.0 | 7.166 ns | 1.00 | 24 B | 1.00 |
| Shared | .NET 11.0 | 1.764 ns | 0.25 | – | 0 |
把一个刚分配的 Processor 转成 IProcessor,在 IL 里是一次接口泛型虚调用;现在 JIT 能看清接收者的确切类型(连共享泛型的 string 情况也能),于是 .NET 11 去虚拟化并内联了这两次调用。这又反过来把 Unsafe.SizeOf<T>() 暴露成常量,并证明了那个短命的 Processor 压根不需要分配——两种情况的分配都归零,耗时降到约四分之一。
在此基础上,dotnet/runtime#123183(@hez2010)让 ReadyToRun 编译能够解析并去虚拟化更多原本会保持间接的非共享泛型虚调用;dotnet/runtime#130202(@hez2010)把该支持扩展到 NativeAOT。NativeAOT 会把某些泛型虚目标表示成「胖指针」(不只是一个地址,而是地址 + 关联元数据,这里同时携带代码地址和泛型上下文);通过把这个变换推迟到「确切类型去虚拟化」有机会跑完之后,JIT 就能把一个只有单一已知目标的接口调用点(目标是非共享 GVM)变成一次直接调用,然后可能被内联。
类型信息还得在 JIT 内部变换中存活下来:如果 JIT 在重构表达式树时把一个引用表达式溢出到临时变量,丢了它的确切 class 信息,一次本来可以去虚拟化的调用就会退回不透明的虚调用。dotnet/runtime#128485(@hez2010)在临时变量上保留了 class 句柄及其「确切性」。信息还在,.NET 11 就去虚拟化并内联了这次调用,消掉了装箱及其 24 字节分配。
给 [Intrinsic] 类型的包装方法多一点内联预算:dotnet/runtime#127433
dotnet/runtime#127433 放宽了内联器对 [Intrinsic] 类型(如 Span、Vector)上被调方的预算启发式。这类类型刻意暴露大量小巧、可组合的方法,充当通往 JIT 内建操作的入口。如果包装方法保留成一次调用,调用方就要付调用开销,而且它周围的优化看到的是一道不透明的边界;如果内联了,导入器就能把方法体替换成内建节点,并把它与周围的索引、边界检查、向量运算一起优化。给这类包装方法更宽松的预算,就能让更多包装保持可内联,把更多真实操作暴露给优化器的其余部分。
委托:省下一个指针,把字段排得更顺
委托是 .NET 里核心的抽象机制之一:它让我们传递代表函数的对象,并带上所需的状态。去抽象化能在某些情况下消除委托的开销,其余时候我们仍希望委托尽可能便宜。
- dotnet/runtime#99200(@MichalPetryka):简化 CoreCLR 的委托表示,从每个委托对象里去掉一个指针大小的字段。在 64 位 CoreCLR 进程中,每个委托省 8 字节。
- dotnet/runtime#129304(@MichalPetryka):单独改进 NativeAOT 的委托布局,把已有的四个字段重新排序,让相关的值彼此相邻。新布局也让相等性与哈希码操作能更直接地拿到所需的方法标识。
- dotnet/runtime#129410(@MichalPetryka):跟进 CoreCLR 的布局,把目标对象与方法指针放在一起——调用时这两个值常常被一起消费,相邻后在 Arm64 这类架构上可以做成对加载(paired loads)。
private static readonly Target s_target = new();
private static readonly Func<int> s_first = s_target.GetValue;
private static readonly Func<int> s_second = s_target.GetValue;
[Benchmark] public Func<int> ClosedInstance() => s_target.GetValue;
[Benchmark] public bool DelegateEquals() => s_first.Equals(s_second);
[Benchmark] public int DelegateGetHashCode() => s_first.GetHashCode();
| 方法 | 运行时 | 平均值 | 比值 | 分配 | 分配比值 |
|---|---|---|---|---|---|
| ClosedInstance | .NET 10.0 | 7.395 ns | 1.00 | 64 B | 1.00 |
| ClosedInstance | .NET 11.0 | 6.844 ns | 0.93 | 56 B | 0.88 |
| DelegateEquals | .NET 10.0 | 3.254 ns | 1.00 | – | – |
| DelegateEquals | .NET 11.0 | 2.215 ns | 0.68 | – | – |
| DelegateGetHashCode | .NET 10.0 | 5.623 ns | 1.00 | – | – |
| DelegateGetHashCode | .NET 11.0 | 3.741 ns | 0.67 | – | – |
每个委托从 64 B 缩到 56 B(这正是省掉的那个指针),相等性和 GetHashCode 各快了约三分之一。
运行时异步(Runtime Async)
十多年来,async/await 让我们能用近乎同步的写法写异步代码:await 外面能套 try/catch,它两侧都能用局部变量,能返回值,基本能按源码顺序来推理。但当执行碰到一个尚未完成的 await 时,方法不能简单地留在当前栈帧里等着——线程得腾出来干别的活,而 await 之后的工作、包括它以后需要的局部状态,必须存活在别的地方。在 C# 里,这个「把方法改造成可恢复表示」的工作,传统上由编译器负责。
我在《How async/await really works》里详细讲过这段历史和机制。极简版是:编译器传统上把一个 async 方法替换成一个小的入口方法 + 一个生成的状态机,状态机的 MoveNext 里放着变换后的用户代码。参数、需要跨越未完成 await 存活的局部变量、溢出的表达式值、awaiter、当前状态编号、方法构建器(builder)全都变成堆分配对象上的字段。生成的 MoveNext 跑用户代码,直到某个 awaiter 报告「还没完成」,就存下足够的信息(在哪儿、用什么值恢复),把 MoveNext 注册为续延(continuation)并返回。操作完成后 MoveNext 再次被调用,依据保存的状态编号跳到正确位置(想象 goto 和一个 label),从产生值的 awaiter 里取回结果,继续执行。如果所有 awaiter 都已完成,MoveNext 可以一路同步跑完。方法完成或抛异常时,构建器通过返回的 Task/Task<T>/ValueTask/ValueTask<T> 发布结果、取消或异常。
比如下面这个三行的 C# 方法,生成出来的代码量相当可观:入口方法、<ReadLengthAsync>d__0 状态机类型、state/builder/stream/cancellationToken/awaiter 字段,以及一个带 try/catch 的 MoveNext。
问题在于:编译器必须在程序运行前就对状态机布局、哪些值需要存活、需要几个 awaiter 字段、所有挂起点怎么塞进一个 MoveNext 分派里做出决定。运行时和 JIT 多年来重度优化了这个模式(包括把 task、状态机、续延和 ExecutionContext 合并成一次分配),但等到 JIT 看到 IL 时,变换已经发生了,留给它的只是一个非常复杂的系统要去优化。
.NET 11 的新分工:把变换交给运行时
.NET 11 引入了一种新的职责划分方式,即对 async/await 基础设施的重新实现,称为「runtime async」(运行时异步)。做变换的不再是 C# 编译器,而是 JIT。C# 编译器为每个符合条件的 async 方法只发出一小段「可挂起」的 IL 契约,并在元数据里把方法标记为 async。然后由运行时和 JIT 去做那些依赖运行时知识的工作:创建外部可见的 Task 或 ValueTask、识别直接的 async 调用、决定每个挂起点上哪些值是真正存活的、布局续延对象、生成挂起与恢复的控制流。本质上,变换从 C# 侧搬到了运行时侧,而那儿有更多信息可供优化。
编程模型没变。这仍然是 C# 的 async/await:await 依然遵循 awaiter 模式,异常与取消依然通过返回的 task-like 对象暴露,ConfigureAwait 依然是老含义,同步完成依然是同步完成……这个功能有一个明确目标:100% 行为兼容。一个 async 方法是由语言编译器降级还是由运行时降级,属于实现细节,任何可观测的语义差异都是 bug。
在 .NET 11 里,应用代码通过一个编译器特性开关来启用:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<Features>$(Features);runtime-async=on</Features>
</PropertyGroup>
</Project>
注意这不涉及任何新的 C# 语法,所以既不需要 LangVersion=preview,也不需要 EnablePreviewFeatures。虽然它在应用层是可选的,但.NET 11 的大部分内置共享框架已经是用这种方式构建的了。.NET 11 在 async/await 上的性能目标是与 .NET 10 持平,而总体上 runtime async 在许多重要路径上已经不逊于、甚至优于旧实现。不过它还没被完全优化,也存在已知会产生较低效代码的场景。作者的建议是:在 .NET 11 里拿你的应用和服务做实验并开启它,但务必测量。他希望它能从 .NET 12 开始默认开启。
顺带的好处:二进制体积直接减半
把变换从 C# 编译器搬到运行时,还带来一个附加收益:减小二进制体积。传统降级为每一个 async 方法都发出入口方法 + 生成的状态机类型 + 捕获状态的字段 + MoveNext 主体;runtime async 只留下一个小得多的方法体给运行时去变换。下面这个小应用包含十个返回 Task<int> 的 async 方法,每个 await 下一个,用两种降级方式各编译一次:
| 降级方式 | SizeProbe.dll | 比值 |
|---|---|---|
| 编译器(Compiler) | 10,752 bytes | 1.00 |
| 运行时异步(Runtime async) | 5,632 bytes | 0.52 |
对于这样一个方法:
static async Task<int> CallerAsync() => await CalleeAsync();
开启 runtime async 后,C# 编译器生成的 IL 大致是这样:
; MSIL
.method private hidebysig static
class System.Threading.Tasks.Task`1<int32> CallerAsync() cil managed async
{
call class System.Threading.Tasks.Task`1<int32> CalleeAsync()
call int32 System.Runtime.CompilerServices.AsyncHelpers::Await<int32>(
class System.Threading.Tasks.Task`1<int32>)
ret
}
没有生成的 <CallerAsync>d__0 类型,没有 IAsyncStateMachine,没有 MoveNext,没有 AsyncTaskMethodBuilder<int>,也没有 AsyncStateMachineAttribute。过去,async 这个 C# 关键字在编译时就蒸发了;现在方法多了一个新的 MethodImpl async 位(在 IL 汇编语法里就是那个 async 修饰符),方法体则调用 System.Runtime.CompilerServices.AsyncHelpers 里的辅助函数。
两个 MethodDesc:公开的 Task 与内部的 AsyncCall
乍看那个 ret 是不可能的:声明的签名返回 Task<int>,而 IL 求值栈上的是个 int。这显然不是普通的调用约定。虚拟机可以给一个返回 Task 的方法两个相关的身份(MethodDesc):一个是其余托管代码看到的正常签名 Task<int> CallerAsync();另一个是 AsyncCall 变体,它实际上返回 int,并带有一个用于续延的隐式通道。两者指向同一个逻辑方法和同一个元数据 token,但调用约定不同、职责不同。如果普通托管代码调用 CallerAsync,VM 生成的外层 thunk 会维持公开契约并返回 Task<int>;如果另一个 runtime async 方法直接 await 它,JIT 就可以改为调用 AsyncCall 变体,在同步完成时直接拿到结果,或在挂起时拿到一个续延——也就是说,它能直接交回那个 T,从而避免分配一个 Task<T>。
这个配对是双向的:对用 runtime async 编译的方法,AsyncCall 变体拥有生成的(新的、紧凑的)IL,而公开的返回 Task 的入口点是适配 thunk;对传统编译的方法,公开方法拥有它平常的 IL,而 VM 可以为它创建一个 AsyncCall 适配器。这意味着 runtime async 代码依然能够 await 现有的、由老编译器编译的库和代码,这对我们 100% 兼容的目标是项关键能力。自然,async 调用链中被 runtime async 编译的部分越多,收益越大。
融合调用链:只在真正需要 Task 的地方造 Task
这正是 JIT 获得了一个此前根本不存在的机会的地方——过去,每条边界都已经被表达成一个 task 加一个生成的状态机了。假设 A await B,B await C:
static async Task<int> A(bool yield) => await B(yield);
static async Task<int> B(bool yield) => await C(yield);
static async Task<int> C(bool yield)
{
if (yield) await Task.Yield();
return 42;
}
传统上,每个方法都有自己的编译器生成状态机和自己的 task-like 结果:C 挂起并最终完成它的 task,唤醒 B 的状态机;B 再完成它的 task,唤醒 A 的状态机;A 完成调用方观察到的根 task。多年来为降低这些对象和转换的成本已经做了海量工作。
有了 runtime async,导入器会识别「调用一个返回 Task 的方法,然后 await 那个 task」这种相邻模式。在简单情况下,它可以直接调用被调方的 AsyncCall 变体。当 yield 为 false、C 同步完成时,那个 int 就作为一个普通值一路流过 B 和 A,只有最外层边界需要把它变成承诺给原始调用方的 Task<int>;当 yield 为 true、C 挂起时,运行时为这条链链接续延状态并最终恢复它,而不要求每条被直接融合的边上都存在一个中间 Task<int>。Task 契约并没有消失,它只是挪到了真正需要 Task 的地方。
当然,runtime async 不会让每个异步操作都变成零分配。它做的是给 JIT 足够的信息,避免物化那些「仅仅为了把结果从一个 async 方法直接搬到下一个」而存在的 task 对象。如果消费者把 task 存进集合、手工挂续延、或以其它方式把 task 当对象观察,那这个对象仍然是必需的。这项优化关乎的是:别为那些不可观测的边界付钱。
两层就已经能看到效果了(以下为同一 .NET 11 运行时下、仅降级方式不同的对比):
| 方法 | 平均值 | 比值 | 分配 | 分配比值 |
|---|---|---|---|---|
| ClassicCompleted | 21.221 ns | 1.00 | 144 B | 1.00 |
| RuntimeCompleted | 6.151 ns | 0.29 | 0 B | 0.00 |
| ClassicYielding | 254.139 ns | 1.00 | 248 B | 1.00 |
| RuntimeYielding | 116.927 ns | 0.46 | 168 B | 0.68 |
同步完成的调用链快了 3 倍多,并且避开了两处中间 task 分配;即便经历了一次真实的挂起,同样两层的调用链耗时也不到一半,少分配了 80 字节。
异常处理:差距被进一步放大
异常会让差距放大。还是 A → B → C。C# 编译器为每个方法生成的变换,都会在 MoveNext 的整个方法体外面套一个 try/catch,以便把任何未处理异常存进返回的 Task。假设 C 里的代码抛了一个未处理异常:它被这个人工制造的 catch 捕获、存进返回给 B 的 Task;B 里的 awaiter 从该 Task 对象里取出异常并抛出;然后又被 B 生成的 catch 捕获、存进它自己的 Task……如此往复。一个异常穿过十个这样的 async 辅助方法,就可能被抛出、捕获、存储十次——尽管源码里没有一个方法写了显式的处理器。这极其昂贵。
而 runtime async 不需要重新进入一个没有处理器的透传帧:同步路径上,异常正常地穿过被融合的调用展开;真实挂起之后,一次分派循环的 catch 会掠过那些没有处理器的续延记录,只让可观测的根 task 失败一次。
下面的基准同时测量了「完全同步地抛」和「经历一次真实 Task.Yield 挂起后抛」。它用了一个编译器可识别的逐方法逃生阀(RuntimeAsyncMethodGeneration),好让 classic 与 runtime async 方法在同一个进程、同一个 .NET 11 运行时上运行,只在编译器的降级方式上不同。(注意该特性是实验性的,不是核心库暴露的公开 API;就像其它 C# 编译器已知的特性一样,编译器按名字和签名识别它。)
| 层数 | 是否挂起 | 方法 | 平均值 | 比值 | 分配 | 分配比值 |
|---|---|---|---|---|---|---|
| 1 | False | Classic | 4.727 μs | 1.00 | 1.6 KB | 1.00 |
| 1 | False | Runtime | 3.558 μs | 0.75 | 1.16 KB | 0.72 |
| 1 | True | Classic | 6.308 μs | 1.00 | 1.68 KB | 1.00 |
| 1 | True | Runtime | 8.211 μs | 1.30 | 1.42 KB | 0.85 |
| 10 | False | Classic | 19.469 μs | 1.00 | 15.13 KB | 1.00 |
| 10 | False | Runtime | 5.885 μs | 0.30 | 2.13 KB | 0.14 |
| 10 | True | Classic | 24.923 μs | 1.00 | 15.63 KB | 1.00 |
| 10 | True | Runtime | 6.122 μs | 0.25 | 2.88 KB | 0.18 |
| 30 | False | Classic | 51.302 μs | 1.00 | 84.2 KB | 1.00 |
| 30 | False | Runtime | 10.721 μs | 0.21 | 5.71 KB | 0.07 |
| 30 | True | Classic | 65.974 μs | 1.00 | 85.53 KB | 1.00 |
| 30 | True | Runtime | 11.254 μs | 0.17 | 7.72 KB | 0.09 |
层数越深,差距越夸张:30 层同步抛出时,runtime async 只用了 0.17 的时间和 7% 的分配。 唯一一个「变慢」的格子是 1 层 + 挂起(1.30),那是单层场景下的固定开销,一旦层数上去就完全被摊薄了。
现状、建议与诊断工具
runtime async 支持 Task、Task<T>、ValueTask、ValueTask<T> 作为方法返回类型,但目前还不支持 async void、async 迭代器、以及带自定义构建器的任意自定义 task-like 返回类型——这些仍然使用传统的编译器变换。
至于 ValueTask<T>:使用它的既有理由依然成立。一个 ValueTask<T> 可以直接携带结果、可以包装一个 Task<T>、也可以引用一个 IValueTaskSource<T>。这让它在「同步完成足够常见、省掉一次 Task 分配比更大的返回值和更严格的消费规则更划算」的 API 上很有用,或者异步完成的成本可以由一个可复用的后备对象摊销。runtime async 则解决了一些原本会促使开发者使用 ValueTask<T> 的场景。那是不是所有人都该弃用 ValueTask<T> 了?不是。 选 Task 还是 ValueTask 依然是基于完成模式、分配敏感度、调用频率以及消费者需要怎么使用结果的 API 设计决策。写对这个 API 有意义的返回类型,然后让编译器、VM 和 JIT 尽力去优化它。
拥有大量小型 async 方法层的工作负载受益最大,因为这些层恰好就是中间 task 和状态机堆积的地方。共享框架代码里满是这种模式:一个公开方法校验参数后 await 一个私有辅助方法,后者 await 一个传输辅助方法,再后者 await 一次操作系统调用。应用服务同样会组合认证、重试、日志、序列化和 I/O 辅助方法。runtime async 能让源码层面的拆分变便宜,而不必逼着开发者为了规避「实现细节成本」把代码摊平成一个巨方法。
达到今天这一步所需的工作量大得惊人:2026 年 9 月 14 日在 GitHub 上搜索 runtime async 的跟踪标签,返回了 235 个 pull request,多到没法逐个列举。这项工作也不只是直接的性能改进,还包括诊断与性能工具的改进,帮助你更好地使用自己代码里的 async。当一个 async 方法挂起时,它的物理线程栈就展开了;它的续延稍后可能跑在另一个线程上,那个线程的物理栈从线程池开始,通向原始 await 的那些方法早已不见踪影。采样型 CPU profiler 能看到处理器时间花在哪儿,但没有额外信息就无法可靠地把这些踪迹串回逻辑 async 调用链,于是很难回答「哪些 async 调用路径真正昂贵」。Visual Studio 的 async profiler 之类工具传统上是从 Task 基础设施发出的事件里重建这些链的,但 async 密集的应用会产生海量这类非常啰嗦的事件,由此产生的开销很容易扰动被测负载,在生产环境里几乎没法用。
dotnet/runtime#127238 为 .NET 11 和 runtime async 新增了一条轻量级 async-profiler 事件流:不再把每一个细小的转换都作为独立完整事件送进事件系统,运行时改为把紧凑的记录写进每线程缓冲区,对时间戳和指令指针做差分编码,并批量 flush。它还在调用续延时往物理栈里放一个小的、可识别的包装帧,profiler 可以把这个帧当锚点,把普通的 CPU 采样与事件流所表示的逻辑 async 调用栈连接起来。在某些测量中,这种新方式的开销不到 1%,并把追踪数据量缩小了一个数量级。dotnet/runtime#129043 及若干后续 PR 把同样的做法扩展到了现有 async 代码所使用的编译器生成状态机上。也就是说,这不只对选择启用 runtime async 的应用有用——工具在两种实现上都能得到一份一致的表示。
那么作为开发者,面对 runtime async 你该怎么做不同的事?基本什么都不用做。 按你想让它好读的方式继续写异步代码,把大操作拆成辅助方法(当这能让代码更清晰时)。默认用 Task,在 API 与使用权衡确实契合的地方选 ValueTask。也别因为「今天的实现可能会分配一个中间 Task」就把源码扭来扭去掉一个干净的 await。降级策略理应作为一个实现细节「自动生效」、保持行为不变,并让既有源码随着运行时进步而变好。
边界检查(Bounds Checks)
C# 是一门内存安全的语言。对数组、字符串和 span 的访问,运行时保证在界内;如果你用小于 0、或大于等于长度的索引去访问 someArray[i]、someString[i]、someSpan[i],你会得到异常,而不是静默的内存破坏或进程崩溃。运行时要保证所有被允许的访问都在界内,这意味着它必须能证明访问在界内。JIT 达成这点的主要手段是注入执行边界检查的代码,就像你写的:
int value = array[i];
实际被编译成:
if ((uint)i >= array.Length) throw new IndexOutOfRangeException();
int value = array[i];
在汇编层面,一次边界检查长这样:
; x64
cmp ecx, dword ptr [rax+8] ; compare index with array length
jae THROW ; unsigned index >= length
mov edx, dword ptr [rax+rcx*4+16] ; load the element
JIT 当然可以在每次访问上都注入这样的代码然后收工,但这些代码是有开销的,所以 JIT 会努力在能证明索引合法的地方把这些检查、以及它们的开销消掉。
最经典的例子就是遍历整个数组或 span 的 for 循环:JIT 从这个惯用法认出循环体内 i 一定在 [0, array.Length) 范围内,于是不为 array[i] 生成边界检查。这个特例 JIT 处理很久了。其它情况就不一定了。 边界检查消除几乎每个 .NET 版本都在进步:较近的版本加入了派生表达式的范围传播(.NET 7 和 .NET 8 在此有显著改进)、基于 SSA 的推理(.NET 9)、以及对 Span<T> 更好的处理(它的长度在字段里而不是对象头里,使跟踪复杂化)。每一年,参与 JIT 开发的贡献者都会发现一些此前被漏掉、但在现实世界确实出现、且可修的新模式。.NET 11 改进了其中若干。
让 != 常量也能收紧区间:dotnet/runtime#121273
JIT 里的范围分析为每个变量跟踪一个区间(上界和下界)。比如走 x < 5 的真分支,x 的上界就变成 4;走 x > 2 的真分支,下界就变成 3。那 x != 5 呢?在真边上我们知道 x 不是 5,如果当前 x 的区间是 [5, 10],那实际区间必然是 [6, 10]——下界可以收紧,因为下界那一端唯一的值被排除了。类似地,若区间是 [0, 5],x != 5 这个断言告诉我们实际区间是更窄的 [0, 4]。或者,至少你希望它会这么做。而 JIT 里躺着这么一句注释:
// We have a != assertion, but it doesn't tell us much about the interval. So just skip it.
continue;
在 .NET 11 里,dotnet/runtime#121273 把这段逻辑换成了有产出的推理:它检查被排除的常量是否恰好落在当前跟踪区间的某一端,如果是就把新洞察加进去。C# 11 引入的列表模式恰好生成这样的比较序列。例如 name is [] or [':'] or [':', not ':', ..] 会降级成类似这样:
if (name != null)
{
int num = name.Length;
if (num == 0) return true;
if (num == 1)
{
if (name[0] == ':') return true;
}
else if (name[0] == ':' && name[1] != ':')
{
return true;
}
return false;
}
范围分析随之推进:
- 我们知道
Array.Length永不为负,所以它的区间是[0, Array.MaxLength]。 - 在
num == 0的假边上,我们知道num != 0,区间收窄为[1, Array.MaxLength]。 - 同理,在
num == 1的假边上,num != 1,区间收窄为[2, Array.MaxLength]。 - 然后我们访问
name[0]和name[1],基于已确立的下界 2,两者都保证在界内。
没有这个 != 常量收紧,第 2、3 步的收窄不会发生,第 4 步的边界检查也就消不掉。谢天谢地,.NET 11 能消掉了:
private static int Classify(ReadOnlySpan<char> name) =>
name switch
{
[] => 0,
[':'] => 1,
[':', not ':', ..] => 10 + name[0] + name[1],
_ => 3
};
在 .NET 10 里,我们能在方法底部看到对 CORINFO_HELP_RNGCHKFAIL 的调用——这是「方法里至少还有一次边界检查」的铁证。在 .NET 11 里,这个痕迹消失了:
; Arm64
G_M000_IG05:
- cmp w1, #1
- bls G_M000_IG11
ldrh w0, [x0, #0x02]
cmp w0, #58
beq G_M000_IG06
- add w0, w2, w0
+ add w0, w1, w0
add w0, w0, #10
-G_M000_IG11:
- bl CORINFO_HELP_RNGCHKFAIL
- brk #0
-; Total bytes of code 112
+; Total bytes of code 96
同一基本块内的断言也要传播:dotnet/runtime#121527
JIT 里的「断言」机制在基本块之间传播已学到的事实(比如前面说的范围信息):如果 A「支配」B(到 B 的唯一路径要经过 A),那么 A 里确立的信息就流向 B。但同一块内更早确立的事实呢?这正是 dotnet/runtime#121527 补上的缺口:
private static void Test(int[] arr, int i)
{
arr[i] = 0; // 1: 确立 'i >= 0 && i < arr.Length'
i++; // 2: 同一个块
if (i < arr.Length) arr[i] = 0; // 3: 本可由 1 的断言证明安全
}
语句 1、2、3 在条件之前都处于同一个基本块:语句 1 执行后如果能到达语句 2,说明它的边界检查通过了,我们知道 i >= 0 && i < arr.Length;语句 2 之后 i 变成 i + 1;过了 i < arr.Length 这个守卫,我们知道递增后的 i 仍在界内。但当 .NET 10 JIT 的范围检查阶段去考察语句 3 的边界检查时,它只能看到从前驱块传播过来的断言;语句 1 的断言是在当前块内生成的,范围检查看不到它。这个 PR 把它改成:按执行顺序遍历当前块的表达式树,边走边累积断言。走到语句 3 的边界检查时,我们已经走过了语句 1 并拾起了它的断言。
; Arm64
G_M000_IG03:
- cmp w1, w2
- bhs G_M000_IG05
str wzr, [x0, w1, UXTW #2]
-; Total bytes of code 68
+; Total bytes of code 60
位运算拼出来的索引:dotnet/runtime#122263
JIT 能去找、去特判的东西几乎无穷无尽。但每一个特例都需要代码、维护和——最重要的——编译时间。即时编译器是在应用运行时跑的,所以 JIT 本身也必须被优化,把有限的预算只花在可能有回报的地方。这推动开发者去关注真实工作负载中出现的模式。其中一种常见于格式解码器这类库的模式是:用位运算从一个 byte 构造表索引,比如 ((b & 0x03) << 4) | ((b & 0xf0) >> 4)。每个被掩码的片段上界极小,所以这些片段的 OR 结果总在 [0..63] 内,对比如 Base64 字母表来说是安全范围。在 dotnet/runtime#122263 之前,JIT 常常无法证明这个合并后的上界,于是给索引留了一次边界检查。已有的范围检查代码理解按位 AND 和移位产生的上界,但不理解 OR;这个改动让 JIT 能够合并 OR 两个操作数的已知上界,从而去掉剩下的数组检查。
private static int Sum(ReadOnlySpan<byte> input)
{
int sum = 0;
foreach (byte b in input)
{
int index = ((b & 0x03) << 4) | ((b & 0xF0) >> 4);
sum += "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="u8[index];
}
return sum;
}
.NET 10 的汇编在每次迭代都把算出的索引与那张 65 字节查找表比较;.NET 11 里,范围分析证明索引至多为 63,于是比较和跳向范围检查失败辅助函数的分支一并消失:
; x64
M01_L00:
or r9d, r11d
- cmp r9d, 41
- jae short M01_L02
movzx r9d, byte ptr [r10+r9]
-M01_L02:
- call CORINFO_HELP_RNGCHKFAIL
-; Total bytes of code 95
+; Total bytes of code 79
无符号比较守卫的下界:dotnet/runtime#125056
另一个例子,dotnet/runtime#125056 改进了性能敏感代码里无处不在的 (uint)i < span.Length 这类守卫的处理:
private static int Test(Span<int> span, int i)
{
if ((uint)i < (uint)span.Length)
{
if (i != 0)
return span[i - 1] + span[i];
return span[i];
}
return 0;
}
因为是无符号比较,如果 i 是负数,(uint)i 会变成一个很大的正数,使 (uint)i < (uint)span.Length 不可能为真(span 长度非负,(uint)span.Length 至多为 int.MaxValue)。所以在真分支内,i 处于 [0, span.Length - 1]。此前 JIT 在处理 (uint)i < span.Length 断言时并不总是记录下界 i >= 0,这可能让像 i - 1 这样的表达式上的边界检查保留下来。这个修复在进入 (uint)i < span.Length 检查的真分支时,为索引变量补上 [0, int.MaxValue - 1] 的下界推导。结合已有的上界范围跟踪,JIT 就拿到了被守卫块内 i 范围的完整图景:
; Arm64
G_M000_IG03:
- sub w3, w2, #1
- cmp w3, w1
- bhs G_M000_IG09
- ldr w1, [x0, w3, UXTW #2]
+ sub w1, w2, #1
+ ldr w1, [x0, w1, UXTW #2]
ldr w0, [x0, w2, UXTW #2]
-; Total bytes of code 84
+; Total bytes of code 68
范围事实还能消掉溢出检查与窄化转换
边界检查消除一般基于各种形式的范围分析,JIT 需要证明给定索引一定落在数据结构的范围内。但同一套基于范围分析的事实,也能证明其它检查是不必要的:比如一旦 JIT 知道某个整数在 [0..100],它就能同时证明把它转成 byte 不会丢数据、把它乘以 10 也不会溢出。
dotnet/runtime#124147 让 JIT 能用这类事实去掉 checked 操作里不必要的分支。当范围分析证明操作数所在区间的结果不可能溢出、使 checked 变成一个 nop 时,后端现在可以发出普通的 add/multiply/subtract 指令,而不需要跳向失败处理:
private static int AddToLength(int[] array) => checked(array.Length + 10);
private static int Multiply(Span<int> span)
{
if (span.Length >= 100) return 0;
return checked(span.Length * 10);
}
; Arm64
G_M000_IG03:
mov w0, #10
- smull x0, w1, w0
- lsr x2, x0, #32
- cmp w2, w0, ASR #31
+ mul w0, w1, w0
- bne G_M000_IG07
-G_M000_IG07:
- bl CORINFO_HELP_OVERFLOW
-; Total bytes of code 64
+; Total bytes of code 44
这让该改动的适用范围非常广:任何时候你对天生有界的量(集合计数、长度、被先前比较约束过的索引)写 checked 算术,JIT 现在都有机会在编译期证明溢出不可能发生,从而完全消除运行期检查。在这个基准里我显式用了 checked,但更常见的形式是整个项目以 <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> 编译,使得这个 checked 变成隐式的。
在这个范围检查工作的基础上,dotnet/runtime#124184 教会 JIT 在同类守卫下消除「窄化转换(narrowing casts)」:
private static byte Narrow(uint value)
{
if (value > 100) return 0;
return checked((byte)value);
}
; Arm64
G_M000_IG02:
cmp w0, #100
- bhi G_M000_IG04
- cmp w0, #255
- bhi G_M000_IG06
+ csel w0, w0, wzr, ls
-G_M000_IG06:
- bl CORINFO_HELP_OVERFLOW
-; Total bytes of code 52
+; Total bytes of code 24
这种 checked 用法在序列化和协议代码里很常见——先校验值的范围,再截断它。改动之后,范围分析看到 value 落在 [0, 100],知道 byte 能装到 255,就把检查消掉了。代码大小从 52 字节降到 24 字节。
dotnet/runtime#128620 进一步教会范围分析前导零计数、尾随零计数和人口计数指令的可能结果范围。这些结果常用来索引小型查找表——知道它们的上界就能让 JIT 去掉边界检查:
sum += s_lookup[BitOperations.LeadingZeroCount(v)];
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| SumLookupByLeadingZeroCount | .NET 10.0 | 516.1 ns | 1.00 |
| SumLookupByLeadingZeroCount | .NET 11.0 | 438.5 ns | 0.85 |
因为 JIT 现在知道 LeadingZeroCount(uint) 的结果在 0 到 32 之间,边界检查被移除,快了 15%。
范围检查克隆:从基本块到 return 语句
JIT 还能通过「克隆(cloning)」有条件地应用基于范围检查的消除。克隆就是 JIT 把一段代码复制一份,一份保持原样,另一份特化。上面看着有点傻(拿一次隐式边界检查换一次显式的),但当 JIT 能用一次分支消掉多次边界检查时就不傻了:
int sum;
if (4 < array.Length)
{
ref int startRef = ref MemoryMarshal.GetArrayDataReference(array);
sum = startRef + Unsafe.Add(ref startRef, 1) + Unsafe.Add(ref startRef, 2) + Unsafe.Add(ref startRef, 3);
}
else
{
sum = array[0] + array[1] + array[2] + array[3]; // 可能四次边界检查
}
这类优化 JIT 早就通过 optRangeCheckCloning 阶段做了:它把一个基本块里的边界检查分组,为最大所需范围发出一个守卫,再把受影响的代码复制成「可去掉各个检查的快路径」和「保留检查的回退路径」。但范围检查克隆有个长期限制:它拒绝处理任何「终结块」(以跳转或 return 指令结尾的块)的最后一条语句。对于这样一个方法:
static int ArrayAccess(int[] abcd) => abcd[0] + abcd[1] + abcd[2] + abcd[3];
四次数组访问全在 return 语句里——也就是一个 return 块的最后一条语句——于是什么都没被克隆,热路径保留了四次独立的边界检查。在 .NET 11 里,dotnet/runtime#124705 移除了这个限制,使 return 语句也有资格参与范围检查克隆,从而可用一个快路径守卫覆盖全部四次访问。
但即便不做范围检查克隆,这种访问也没理由需要四次边界检查:JIT 本该能看出这个数组或 span 长度至少为 4,并用那一次检查守卫所有访问。如果存在有副作用的中间操作,JIT 需要维持操作顺序(至少要维持这些副作用的可观测行为),但这里不是这种情况。有了 .NET 11 里的 dotnet/runtime#127439,JIT 现在会在基本块内合并这些检查:把第一个检查强化到最大的常量索引,其余的删掉。
private readonly int[] _values = Enumerable.Range(0, 16).ToArray();
[Benchmark]
public int Sum16() =>
values[0] + values[1] + values[2] + values[3] +
values[4] + values[5] + values[6] + values[7] +
values[8] + values[9] + values[10] + values[11] +
values[12] + values[13] + values[14] + values[15];
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| Sum16 | .NET 10.0 | 2.958 ns | 1.00 |
| Sum16 | .NET 11.0 | 1.828 ns | 0.62 |
在以往的版本里,你有时会看到积极的开发者手工做类似的优化,比如把上面这种访问重排、把最大的那次读取放最前面。现在没必要了——快了 38%。
「读最后 N 个字节」:dotnet/runtime#127488
dotnet/runtime#127488 针对的是显式的(而非我们一直在讨论的隐式的)边界检查,目标是那种「在 span.Length >= 4 守卫下从 span 末尾读取一个固定大小的值」的代码,比如 BinaryPrimitives.ReadInt32BigEndian(span.Slice(span.Length - 4)):
if (span.Length >= 4)
{
... = ReadInt32BigEndian(span.Slice(span.Length - 4));
...
}
这里本不该再需要任何额外的边界检查。但 Span.Slice 开头是 if ((uint)start > (uint)_length) Throw...,ReadInt32BigEndian 开头是 if (sizeof(T) > source.Length) Throw...——所以尽管我们的 span.Length >= 4 检查本该足够了,我们最后还是多出两次检查。 要解决这个问题,JIT 需要两件事:
第一,它需要能识别 x - (x + a) 等同于 -a。没有这个恒等式,length - (length - 4) 只是两个表达式之间一次不透明的减法,看不出明显的常量结果;有了它,JIT 就能把内层表达式 (length - 4) 认作 length + (-4),套用 x == length、a == -4,得到 -(-4) == 4。于是 ReadInt32BigEndian 对 4 的检查变成 4 >= 4,JIT 一眼就能看出为真。
第二,Slice(start) 必须能确立 start 在 0 与 span 长度之间。当 start 是 length - 4 时,已有的 length >= 4 守卫证明了结果非负,而减去一个正常数意味着结果不会超过 length。改进后的范围分析把这个守卫和那次减法连起来,去掉了 Slice 的检查。
两个修复合在一起,上面这个例子现在消掉了两个额外的边界检查。这对解析器、网络协议实现和加密代码这类库尤其有用——它们在热路径上频繁做「读取缓冲区最后 N 个字节」这种事:
private static int ReadLastInt32(ReadOnlySpan<byte> span)
{
if (span.Length >= sizeof(int))
return BinaryPrimitives.ReadInt32BigEndian(span.Slice(span.Length - sizeof(int)));
return -1;
}
.NET 10 里这个辅助方法是 73 字节,包含两个额外检查及其抛出路径:
; x64 (.NET 10)
cmp ecx,4
jl RETURN_MINUS_ONE
lea edx,[rcx-4]
cmp edx,ecx
ja THROW_SLICE
mov r8d,edx
add rax,r8
sub ecx,edx
cmp ecx,4
jl THROW_READ
movbe eax,[rax]
.NET 11 里它是 28 字节,只剩原来的长度守卫:
; x64 (.NET 11)
cmp ecx,4
jl RETURN_MINUS_ONE
add ecx,-4
add rax,rcx
movbe eax,[rax]
向量化循环里的 span.Slice:dotnet/runtime#122040 与 dotnet/runtime#127117
dotnet/runtime#122040 和 dotnet/runtime#127117 类似地帮助移除涉及 span.Slice 的边界检查。向量化循环常常一次处理 span 的一个块,把已处理的部分切掉。JIT 并不总能跟踪这些越来越小的切片与原始 span 的关系,于是可能每轮迭代都重复检查同样的界限。这些改动改进了跟踪,使更多这类重复检查得以消除:
private static int SumSliced(ReadOnlySpan<int> data)
{
Vector128<int> sum = default;
while (data.Length >= Vector128<int>.Count)
{
sum += Vector128.Create(data);
data = data.Slice(Vector128<int>.Count);
}
int result = Vector128.Sum(sum);
foreach (int value in data) result += value;
return result;
}
在 .NET 10 里,循环条件已证明至少还剩一个向量,但从当前 span 构造向量时又把同样的检查做了一遍;.NET 11 保留了长度关系,于是循环体直接以向量加法开始:
; x64, vector loop
-cmp esi, 4
-jl THROW_ARGUMENT_OUT_OF_RANGE
-vpaddd xmm6, xmm6, [rbx]
-add rbx, 10
-add esi, 0FFFFFFFC
+vpaddd xmm0, xmm0, [rax]
+add rax, 10
+add ecx, 0FFFFFFFC
cmp ecx, 4
jge LOOP
循环克隆:< 之外的 !=
前面我们看到范围检查克隆如何通过复制一段指令来消除边界检查。「循环克隆」(loop cloning)把这个思路扩展到整个循环。考虑一个处理数组前 count 个元素的循环:
for (int i = 0; i < count; i++)
sum += values[i];
i < count 这个测试本身并不能证明 i < values.Length,所以默认编译下来循环体里需要边界检查,也就是每次 values[i] 访问都要一次。循环克隆给了 JIT 另一种选择:它生成一个「先判一次 (uint)count <= (uint)values.Length,成立则走无检查的克隆循环,否则走保留每次检查的原始循环」的结构。常见情况下,执行走的是没有逐次边界检查的克隆循环,而带检查的原始循环作为回退保留,为非法输入维持异常行为。正常路径只付一次守卫,省掉每轮一次的检查——代价是代码被复制了一份,所以 JIT 必须有选择地应用这个优化。
JIT 用循环克隆很久了,但有些看似适用的场合它就是不生效。前面的例子用的是迭代条件里的 <;而不知为何,有些开发者用的是 !=,循环克隆就不生效了(作者猜测他们用 != 是因为觉得更高效,结果反而去优化了)。感谢 dotnet/runtime#129268,.NET 11 里 != 现在也能处理了,只要满足特定条件,比如步长必须恰好是 1 或 -1(i++ 合格,i += 2 不合格)。dotnet/runtime#129303 也改进了以 i != bound 终止的循环,让 JIT 对 i 可能取的值有更紧的理解,即便无法克隆整个循环也能移除一些边界检查。
前瞻(lookahead):dotnet/runtime#124242 与 dotnet/runtime#125235
数组与 span 上的「前瞻」是另一种反复出现的模式,尤其在解析器里。dotnet/runtime#124242 和 dotnet/runtime#125235 识别 (uint)(i + 2) < (uint)span.Length 这样的条件,并用这个关系去掉 span[i + 1] 和 span[i + 2] 的后续检查:
for (int i = 0; i < span.Length; i++)
{
if (span[i] == '%' &&
(uint)(i + 2) < (uint)span.Length &&
span[i + 1] == 'F' &&
span[i + 2] == 'F')
{
return true;
}
}
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| ContainsPercentFF | .NET 10.0 | 232.1 ns | 1.00 |
| ContainsPercentFF | .NET 11.0 | 194.9 ns | 0.84 |
快了 16%。
一批更小的改动
若干较小的改动拓宽了 JIT 能从中移除边界检查的代码范围:
- dotnet/runtime#121640:一旦用某个选定索引做过一次访问并通过检查,之后对同一数组在同一索引上的访问就不需要再检查了。
- dotnet/runtime#121683:让 JIT 能把数组长度追溯到方法前面做过的计算中,暴露出更多冗余检查,包括一些涉及「从末尾索引」(index-from-end)表达式的。
- dotnet/runtime#124387 与 dotnet/runtime#130326:教会优化器信赖「span 的长度永远非负」。
- dotnet/runtime#124571:改进一系列 index-from-end 访问——一旦
arr[^4]这样的访问确立了数组至少有四个元素,JIT 就把这个信息复用于arr[^3]之类的邻近访问。 - dotnet/runtime#129101:改进 JIT 合并与传递算术表达式可能范围的方式,包括涉及按位 OR 和无符号除法的表达式。更紧的范围能表明更多值是非负的或在界内。
循环结构本身
消除边界检查只是「理解循环结构」的回报之一。JIT 会分析归纳变量(像循环计数器这种每轮可预测变化的值),并把循环整理成标准形式,好让后续优化能够对其推理。.NET 11 拓宽了这套机制适用的循环范围:
- dotnet/runtime#122184:识别另一种 32 位到 64 位零扩展的表示形式,让使用
data[(uint)i]这类表达式的指针循环能用指针递增替换掉重复的索引扩展与地址计算。 - dotnet/runtime#119537:在寻找归纳变量的初始化与零次行程测试时,跟随简单的控制流前驱;dotnet/runtime#128303 为有多个回边的循环提供单一的规范化 latch 块。
- dotnet/runtime#128532:让循环克隆能容忍更新与测试周围更多的语句。
- dotnet/runtime#129309:把克隆扩展到更多具有非单位步长和偏移界限的 span 循环。
- dotnet/runtime#129349:对数组循环中的大步长,用显式安全守卫处理,而不是直接拒绝。
- dotnet/runtime#129472:允许循环反转(loop inversion)把更多预算花在可能的克隆候选上。
- dotnet/runtime#130205:移除在已知归纳变量范围下冗余的比较。
- dotnet/runtime#131362:在反转改变了循环出口后,校正profile权重。
一个「你可以现在就去做」的行动项
这些工作大多不是被我在文章里爱用的那种「除了数组索引啥也没有」的人造基准驱动的。相反,许多改进源自一项正在进行的工作:审计整个 .NET 基础库中的 unsafe 代码,这是一项提升 .NET 内存安全性的更大努力的一部分。.NET 和 C# 是内存安全的,但和 Rust 这类其它内存安全语言一样,它也提供了逃生舱,允许你关掉编译器和运行时提供的护栏。这项努力的目标,是减少开发者感到不得不使用这些逃生舱的场合与时机,因为每一处都是风险增加的机会。
unsafe 代码往往是多年前为了手工规避边界检查而引入的,典型做法是用指针、byref 或 Unsafe.Add 走缓冲区。审计有时发现:这段 unsafe 代码已经不必要了,可以直接删掉;有时「安全」的重写(指不用 unsafe 及其朋友们)已经一样快、甚至更快;还有时,重写暴露出了 JIT 漏掉的一个优化——这种情况下答案是改进 JIT,然后把库代码重写成普通的、带边界检查的 C#。本节讨论的若干优化,正是这种反馈循环的结果。
- dotnet/runtime#127429 是个特别漂亮的例子:
Enumerable.Sum的向量化实现原本用MemoryMarshal.GetReference、Vector.LoadUnsafe和Unsafe.Add无检查地遍历输入。有了前面讲的 span 切片改进,它可以改用Vector.Create(span)、span.Slice(...)和一个尾部foreach——更好推理,去掉了未检查的索引,而且最终更快。 - dotnet/runtime#114757 类似地把一个基于 unsafe 指针的 header-name 访问器替换成了泛型
ReadOnlySpan<T>实现,性能没有损失。 - dotnet/runtime#121270 从
Uri解析中移除了更多 unsafe 代码,并确实可测量地提升了相关代码的性能。
对 dotnet/runtime 之外的库,这里也有一个有用的「行动项」:为绕开 JIT 而写的 unsafe 代码,是编写它时 JIT 能力的一张快照。 如果你手上有为了规避边界检查而手写的指针或 Unsafe 循环,值得在 .NET 11 上用安全、带边界检查的 C# 重写一遍并重新测量。很可能你会发现:此时的差距要么已经不存在,要么小到不值得为「自己管理安全」付出额外的维护成本与风险。而如果改写后的版本仍然更慢,那就是一个极好的机会——去 dotnet/runtime 仓库提一个 repro,说不定就成了 .NET 12 JIT 里最早落地的性能改进之一的灵感来源。 在 interop 这类场景下 unsafe 仍然必要,但性能本身不该成为永久放弃 .NET 那些宝贵护栏的理由。
C# 15 的内存安全预览正朝着同一个方向推进,并且是这项工作的一部分。历史上,C# 大体上把指针等同于 unsafe 代码:仅仅声明或操作一个指针通常就需要 unsafe 上下文,哪怕代码从未访问它所指向的内存。在这个预览里,指针相关的「管道」操作——声明指针、用 & 取地址、使用 fixed、把 stackalloc 转成指针、对非托管类型应用 sizeof——不再需要 unsafe 上下文;真正访问所指内存的操作,包括 *p、p->member 和 p[i],仍然需要。C# 15 还新增了 unsafe(expression) 形式,类似于 checked(expression),让 unsafe 上下文只覆盖一个精确的表达式,而不是更大的语句块。这些改动是一个更大的、跨多个版本的 unsafe 演进的第一片预览切片。最终目标是让 unsafe 区域更小、让它们的假设通过调用图可见、让review者和工具更容易找到它们。 再配上一个「让惯用的安全代码跑得快」的 JIT,历史上那股「先写 unsafe 再说」的压力就被卸掉了一大半。
断言传播(Assertion Propagation)
如前面所说,JIT 在编译一个方法的过程中会不断学习事实:某个值等于某个常量、某个引用非空、某个整数落在特定范围内,等等。「断言传播」(assertion propagation)把这些事实向前传递,以简化后面的代码。「值编号」(value numbering)则作为补充,让 JIT 能认出两个表达式算的是同一个值,即便它们出现在不同位置或使用了不同变量。这两种机制共同支撑了诸如移除冗余的 null 检查与边界检查、把条件折叠成常量、复用重复计算之类的优化。.NET 11 对断言传播的改进,主要是修掉那些「有用事实要么从未被记录、要么后来没被认出来」的地方。
已知非空,就别再隐式检查:dotnet/runtime#124291
比如,读取数组的长度通常带有一次隐式的 null 检查:如果数组引用是 null,这次读取必须抛异常。然而,一旦全局断言传播已经知道该引用非空,我们应该就能避开这次隐式 null 检查。在 .NET 11 里,dotnet/runtime#124291 为 Array.Length 处理了这一点:
private static void Test(int[]? values)
{
if (values is not null)
_ = values.Length;
}
.NET 10 的代码仍然测试引用并读取长度;.NET 11 里,守卫证明了这次读取不可能抛出,而它的结果又没被使用,于是整个访问消失了:
; Arm64
G_M000_IG02:
- cbz x0, G_M000_IG04
-G_M000_IG03:
- ldr wzr, [x0, #0x08]
-; Total bytes of code 24
+; Total bytes of code 16
范围的来源:值本身、控制流与已完成的操作
- dotnet/runtime#119474 改进了整数范围分析的起点:JIT 现在利用值本身固有的事实——一个常量只有一个确切的值,而一个被转换成
byte的值必然在 0 到 255 之间。这能在没有前置if显式确立范围的情况下,消除边界检查和条件。dotnet/runtime#124415 进一步细化了对转换的处理,把「已知源值」与「已知目标类型」结合起来,推导出最紧的有用范围。 - 范围也可以来自控制流:在
if ((uint)x < 10)之后,JIT 知道真路径上x在 0 到 9 之间,这也许就足以移除后面的某次比较或数组边界检查。dotnet/runtime#123624 从断言和转换中推导出更紧的范围,包括证明某些比较恒为真或恒为假;dotnet/runtime#129390 在控制流路径合并时更准确地保留范围信息。 - 另一些改动是更好地利用已知的范围:dotnet/runtime#129354 把值回溯到它们的定义处,以折叠更多与 span/切片相关的比较;dotnet/runtime#126917 用收窄后的范围移除更多关系分支。
dotnet/runtime#124711 教会 JIT 从已经成功完成的操作中学到隐含事实。例如:
- 创建一个数组,证明它请求的长度非负;
- 一次引用数组存储可能需要运行期协变检查(因为一个声明为
object[]的值实际上可能指向string[]),而执行该类型检查的辅助函数同时也校验了索引——所以如果它成功返回,索引就在范围内; - 整数除法或取模,证明除数不为零。
诸如此类。这些事实随后可以移除方法后面冗余的检查和条件:
private readonly object?[] _objArr = new object?[8];
private readonly object _value = new();
[Benchmark]
public object? CovariantArrayStore()
{
object?[] objArr = _objArr;
objArr[3] = _value;
return objArr[2];
}
一次对元素 3 的成功存储,证明这个数组至少有四个元素;而数组长度不可变,于是随后对元素 2 的读取不需要再检查一次边界。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| CovariantArrayStore | .NET 10.0 | 3.565 ns | 1.00 |
| CovariantArrayStore | .NET 11.0 | 2.985 ns | 0.84 |
dotnet/runtime#128522 简化了全局断言阶段识别值的方式,降低「漏掉先前学到的事实」的概率。它的一个实际影响是:更好地传播静态字符串的已知长度,从而把一次通用的字符串比较变成一次定长向量化比较。
??= 之后的冗余 null 检查:dotnet/runtime#127810
dotnet/runtime#127810 改进了控制流合并处的 null 检查消除。对于非常常见的 ??=(用于惰性初始化)运算符:无论结果来自已有字段还是新分配的对象,合并后的值都非空。JIT 现在会把两条路径上的事实合并,并认出随后的调用不需要再检查一次 null:
[Benchmark]
[Arguments(42)]
public int Invoke(int n) => (_inner ??= new()).Increment(n);
生成的代码因此丢掉了合并值上的 null 检查:
; x64
M00_L00:
mov edx, esi
- cmp [rcx], ecx
call qword ptr [...] ; Inner.Increment(Int32)
-; Total bytes of code 75
+; Total bytes of code 73
结构体拷贝里多余的写屏障探针:dotnet/runtime#128701
dotnet/runtime#128701 从含对象引用的结构体拷贝中移除了类似的冗余 null 检查。这类拷贝要用一个运行时辅助函数,好让 GC 正确得知这些引用写入;但降级时为源地址和目标地址都加了探针,而没有保留「这两个地址是否真的可能触发异常」的信息。现在它只发出确实需要的那些探针:
[Benchmark]
public void BulkStructCopy() => _dst = _src;
private struct FourRefs { public object? A; public object? B; public object? C; public object? D; }
_src 和 _dst 是同一对象的字段,所以在探测源地址已确立该对象非空之后,探测目标地址不可能提供任何额外信息。.NET 11 移除了第二次探测:
; Arm64
G_M000_IG02:
add x1, x0, #8
ldrsb wzr, [x1]
add x0, x0, #40
- ldrsb wzr, [x0]
blr x3 // CORINFO_HELP_BULK_WRITEBARRIER
-; Total bytes of code 56
+; Total bytes of code 52
此外,dotnet/runtime#125215 让 JIT 在更大的方法中保留并高效地查找更多断言,增加了同类简化的机会;dotnet/runtime#129312 在「同一个简单字段地址被多次使用」时移除不必要的临时变量,从而实现更高效的加载与存储。
至此,我们走完了 .NET 11 性能之旅的开篇与 JIT 章节的前半部分:从去抽象化(逃逸分析、泛型虚方法去虚拟化、委托瘦身),到运行时异步这一整套 async/await 基础设施的重建,再到边界检查与断言传播这两个「把冗余证明掉」的永续战场。没有哪个改动是银弹,但正如 Nigel 那台能拧到 11 的音箱——一点点累积起来,整体就真的更响了。
JIT 编译器(二):简化(Simplification)
上一篇我们聊了 JIT 怎么通过内联和逃逸分析「少做无用功」。这一章继续往下走,看 JIT 拿到足够的上下文之后,怎么把已经出现的代码改写成一个更便宜的样子——这就是「简化(Simplification)」;以及怎么把标量循环改写成一次处理多个数据的 SIMD 指令——这就是「向量化(Vectorization)」。
先说清楚一个总纲:JIT 知道的越多,生成的代码就越短、越快。所谓断言传播(assertion propagation),本质就是「证明」:一旦 JIT 能证明某个输入满足某种性质,原来的那次运算往往就不需要了。下面这些 PR,几乎都是这条主线的不同分支。
还有一点要先铺好:别小看「少几条指令」。这一节里的大部分改动,单看收益也就是几字节代码、一条加法或一次 mov。但它们是「结构性」的——同样的模式在任何一个数组循环、任何一次区间判断里都会反复出现。热路径上省掉的每一条指令,都会被乘以百万次的执行次数。
再统一一下阅读方式:下面出现的代码大小(Total bytes of code)都来自原文的 DisassemblyDiagnoser 输出,同一份 IL 分别用 .NET 10 和 .NET 11 编译后对比。它衡量的是「生成的机器码有多大」,而不是「跑得有多快」——但在对代码体积极敏感的场合(指令缓存、页内命中、循环体能否塞进解码窗口),它就等价于速度。
一、常量折叠:编译期能算完的,绝不拖到运行时
「常量折叠(constant folding)」说白了就一句话:编译器在编译的时候把活干一遍,运行时就不用重复干了。而且算出来的结果还能喂给别的表达式,引发连锁折叠。
C# 编译器只能折叠「语言层面的常量」,JIT 能折叠的是「内联之后才知道的值」——这才是它的主场。
结构体字段偏移:两个地址相减,其实是个常量
dotnet/runtime#122297 教 JIT 认出更多这种情况:两个地址指向同一个结构体,它们的差值就是这个字段的偏移量,编译期已知。
private struct MyStruct { public int A; public int Field; }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static nint OffsetOfFieldInline()
{
MyStruct dummy;
return (nint)((byte*)&dummy.Field - (byte*)&dummy); // 第二个 int 字段,偏移恒为 4
}
.NET 10 里这段代码被内联进循环后,每次迭代都在现场算一遍:先把 fp+0x1C 和 fp+24 两个栈地址分别算出来(两条 add),再做一次减法(sub),最后才加到累加器上。三个昂贵的地址运算,只为得到一个常数 4。
.NET 11 直接把它折叠掉:循环体只剩一条「累加 4」的地址无关加法。副作用也很明显——那个本来用来放 dummy 的栈空间也不需要了,栈帧从 0x20 字节缩到 0x10 字节,总代码大小从 60 字节降到 40 字节。
SequenceEqual:两个常量字符串的比较,还比什么
dotnet/runtime#121985( @hez2010)让 JIT 在两个输入都已知的情况下,直接在编译期把 SequenceEqual 求值掉。SequenceEqual 平时是逐元素走、遇到首个不同就停的逻辑,但如果内联展开后两边都是常量,运行时就没什么可干的了:JIT 编译时比完,直接换成常量 true 或 false。
这个 intrinsic 的地位很重,MemoryExtensions.SequenceEqual、ReadOnlySpan<T>.SequenceEqual、string.Equals 全都站在它上面。
private static string AlphaLower => "abcdefghijklmnopqrstuvwxyz";
private static string AlphaUpper => "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
[Benchmark] public bool CompareEqual() => AlphaLower.Equals(AlphaLower);
[Benchmark] public bool CompareDistinct() => AlphaLower.Equals(AlphaUpper);
注意这两个属性不是 const,所以 C# 编译器无能为力;但 JIT 在内联之后看得见字符串字面量。CompareDistinct 于是被折叠成常量 false。
- .NET 10 的做法:先把两个字符串对象的数据地址取出来,用四条
vmovups把两个串各自的前 32 字节和余下的 20 字节分别装进ymm寄存器;接着两条vpxor逐块异或找出差异,一条vpor把差异合并,再用vptest判定「是否全零」,最后sete/movzx把布尔结果捞出来。本质是一次实打实的内容比对。 - .NET 11 的做法:
xor eax, eax; ret——答案已经在编译期算完了。
代码大小从 65 字节降到 3 字节。这不是「快一点」,是「彻底不干活」。
折叠的结果可以继续化简,哪怕是向量
dotnet/runtime#127124 把断言传播的能力扩展到了 128 位整数向量常量。
Vector128<int> v = Compute();
if (v == Vector128<int>.Zero)
{
Vector128<int> masked = Vector128.AndNot(v, Vector128.Create(0x00FF00FF));
return masked[0];
}
return -1;
如果这个分支里已经确定 v 是全零,那么 AndNot(等价于 v & ~mask)的结果必然还是全零,取 lane 0 也必然是 0。于是构造掩码、做 AndNot、抽 lane 三步全部作废。
.NET 10 的 Arm64 代码要先用 movi 造出 0x00FF00FF 掩码,再 and 一次,再用 smov 把 lane 0 搬到通用寄存器;.NET 11 直接用一条带条件的自增指令 cinc 在 -1 和 0 之间选值。68 字节降到 56 字节。
目前该优化只覆盖整数向量且宽度不超过 128 位——浮点相等还要处理 NaN 和带符号零的语义,暂时不能这样推导。
两处后端小清理
- dotnet/runtime#124332( @jonathandavies-arm):Arm64 上「把取负后的值和 0 比较」时,那次取负是多余的,直接删掉。
- dotnet/runtime#124642( @yykkibbb):内联之后,同一个基本块里可能留下没人读的写入;这些「死存储」以前会挡住优化器的视线,导致短路布尔表达式折叠不成。现在可以穿过去。
二、分支简化:把不可预测的判断变成纯粹的算术
现代 CPU 是流水线的,遇到条件分支会先猜一条路继续投机执行。猜对了,分支几乎免费;猜错了,投机工作全部作废、取指重定向、流水线重新填充。所以一个分支的「可预测性」,有时候比两个分支里干多少活还重要。
JIT 的做法是:在小巧的热循环里,用条件移动指令或者一个更简单的合并条件,把分支整个消掉。当然这不总是赚的——分支版本在数据好预测时可以跳过整块计算,而无分支版本无论如何都要算。小、且数据依赖强的选择,最适合做淡化处理。
零基等值链:一串 || 其实是一个无符号范围判断
dotnet/runtime#124567 专门识别从 0 开始的等值链,value == 0 || value == 1 || value == 2 可以直接换成 (uint)value <= 2。关键是那个无符号转换:负数按无符号解读会变成一个巨大无比的正数,天然就被上限排除掉了,不需要额外判断正负。
private int _value = 2;
[Benchmark]
public bool IsLetterCategory() =>
_value == 0 || _value == 1 || _value == 2 || _value == 3 || _value == 4;
聪明的读者可能会问:负数怎么办?答案全在那个无符号转换上。-1 的 32 位补码是 0xFFFFFFFF,按无符号解读就是 4294967295,必然大于任何小上界,所以 (uint)value <= 4 会自动把一切负数排除在外,一次符号属性的额外判断都不要。这正是现代 CPU 上 cmp + setb(无符号小于)这条组合能「免费」附带正确性检查的原因。
.NET 10 已经会把前四次比较合并成一个 x <= 3 的范围判断,但 剩下那个 == 4 还得单独再来一次比较加一次分支。
.NET 11 认出了整条链:把值读出来,跟 5 比一次,用 setb(无符号小于)拿结果,完事。从一个存疑的分支 + 两次条件,退化成一次比较,x64 代码从 24 字节降到 13 字节。
dotnet/runtime#128524( @BoyBaykiller)接着把同样的优化推广到不从 0 开始的连续区间:x == 3 || x == 4 || x == 5 变成 (uint)(x - 3) <= 2。
类型转换不该掩盖一次简单的比较
dotnet/runtime#128091( @BoyBaykiller)把「转换 + 比较」的优化范围扩展到了相等与不等。看这段:
foreach (int value in _values)
if ((ulong)(uint)value == uint.MaxValue)
matches++;
把 uint 提升成 ulong 并没有带来任何「跟 uint.MaxValue 比较所需的新信息」,所以完全可以在 32 位宽度上比。
Arm64 上,旧的写法是先把常量 0xFFFFFFFF 装进一个 64 位寄存器,再做 64 位 cmp;新写法直接用一条 cmn w3, #1(把操作数加上 1 并设置标志位,等价于判断寄存器是否等于 -1 的 32 位形式)。80 字节降到 76 字节。
同类组合:多个比较、被吸收的比较、以及跨基本块的推理
- dotnet/runtime#127181:同一表达式里的多个比较可以合并,
(x >= c) && (x <= c)只有在x == c时才成立;对应的 OR 形式同理。 - dotnet/runtime#126587:后面的强判断会吸收前面的弱判断。
if (x > 0) if (x > 1)只需要保留x > 1——能走到里层的必然已经满足x > 0。 - dotnet/runtime#127950:把值之间的关系传播得更远,比如已知
a > 10且b > a,后续分支和边界检查就能继续化简。
跳转线程化(Jump Threading):顺着路径直接送到终点
有些情况不是「删掉一次测试」,而是「用前面分支的结果,决定后面分支的去向」:
int value = condition ? 1 : 2;
if (value == 1) { One(); } else { Two(); }
condition 为真那条路径可以直接跳到 One(),为假那条直接跳到 Two(),第二次测试被彻底消除——效果等价于把赋值内联进各自的分支。
围绕这条思路,.NET 11 有一串增量:dotnet/runtime#126812 让路径重汇(rejoin)更多的时候也能继续线程化;dotnet/runtime#127103 保证在这些重写之后值仍然正确;dotnet/runtime#128500 把类型信息也合并起来——如果每条路径来的对象都派生自被测的基类型,汇合后那个 is 判断就可以删除;dotnet/runtime#127434( @hez2010)则让冗余分支消除看穿空的跳转块(这种块自己不干活、只负责把控制流导向别处,却能把两个条件之间的关系从优化器眼前藏起来)。
三个 benchmark 一起看:
// 1. 传递性比较
private static bool TransitiveComparison(int x, int y)
{
if (x > 10 && x < 100 && y > x) return y > 0;
return false;
}
// 2. 合并类型检查
object shape = flag ? new Circle() : new Rectangle();
s_sink = shape;
return shape is Shape;
// 3. 嵌套阈值
if (count > 1) if (count > 2) if (count > 3) if (count > 4) return 1;
return 3;
| 场景 | 相关 PR | Arm64 代码大小(.NET 10 → .NET 11) | 发生了什么 |
|---|---|---|---|
TransitiveComparison |
dotnet/runtime#127950 | 40 B → 36 B | 已知 x > 10 且 y > x,y 必然是正数,末尾的 y > 0 直接消失 |
MergedTypeCheck |
dotnet/runtime#128500 | 112 B → 88 B | 两条路径产出不同具体类型,但都派生自 Shape,is 辅助调用被换成常量 true(分配和写入被保留) |
NestedThresholds |
dotnet/runtime#127434 | 44 B → 32 B | 走到底等价于 count > 4,看穿空跳转块后前三次比较全部删掉 |
这一组里最值得看的是 MergedTypeCheck:JIT 并没有把 new Circle() / new Rectangle() 以及 s_sink = shape 那次写入优化掉(它们让这个例子具备可观察的副作用),它只是把本来要走一次 CORINFO_HELP_ISINSTANCEOFCLASS 辅助调用的类型测试,换成了常量 true——副作用照旧,类型查询免费。
三层嵌套的 if,最后只剩一次比较加一条条件选择。
三、If-conversion:一句话能解决的,别写 if/else
能让冗余分支消失当然最好,但很多时候分支确实必要——两条路都可能走(至少没法证明不可能)。这时候 JIT 还有一招:用把「选择」烧进指令的方式消除跳转,这就是 if-conversion:当 if/else 的两个分支都很便宜时,用一条条件移动指令(Arm64 的 csel / cset、x64 的 cmov)取代整个分支。
三项改进都来自 @BoyBaykiller:
- dotnet/runtime#124738:认出「前置默认值」就是隐式的 else。
bool x = false; if (cond) x = true;从此和写了显式 else 的版本享受同等待遇。 - dotnet/runtime#127915:反向清理——当条件选择的两个结果是同一个常量时直接删掉,同时保留计算条件本身的副作用。
- dotnet/runtime#128533:归一化 2 的幂次位测试。2 的幂只有一个比特为 1,此时
(A & bit) == bit与(A & bit) != 0完全等价;统一成同一种规范形式后,就更容易和周围的条件合并。
static bool ImplicitElse(int left, int right)
{
bool leftIsSmaller = false;
if (left < right) leftIsSmaller = true;
return leftIsSmaller;
}
static bool IsDefaultValue(double value) => 0.0.Equals(value);
static bool HasEitherBit(int value) => ((value & 4) == 4) || ((value & 8) == 8);
逐个看 Arm64 上的结果:
ImplicitElse(36 B → 24 B):.NET 10 已经没有分支了,但它先把两个布尔值分别物化到寄存器里(0 和 1),再用 csel 二选一。.NET 11 认识到默认值就是隐式 else,于是 cmp 之后一条 cset 直接搞定,两次 mov 和一次 csel 全部省掉。
IsDefaultValue(40 B → 24 B):0.0.Equals(value) 本来要考虑 NaN 的情况,但因为左操作数是零,「两边都是 NaN」这个分支永远不可能成立;删掉对应的条件选择之后,只剩一次浮点比较加一条 cset,原本那段跳来跳去的基本块结构也没了。
HasEitherBit(40 B → 36 B):两次 2 的幂测试归一化后,JIT 就能把两者合并——原本的短路分支被换成两条按位与加一次或,最后统一判定「是否非零」。没有分支,只有位运算。
四、窥孔优化:认出一个短模式,就换掉它
有些简化压根不需要全局的控制流推理,只需要认出一个短小固定的模式,把它换成更便宜的等价形式——这就是「窥孔优化(peephole optimization)」。单个收益可能只有一条指令,或者只是把表达式变成另一种优化能识别的形状,但这类模式在热代码里出现得非常频繁。
dotnet/runtime#126529( @BoyBaykiller)注意到:对 byte 来说,255 - x 等价于 x ^ 255——两者都是把八个比特全翻转,但后者可以把「取负 + 加法」合成一条异或。类似地,-1 - x 也能变成等价的 ~x。
为什么这两个等价式成立?在补码世界里,对宽度为 8 的量有 -x == ~x + 1,于是 255 - x == -x - 1 == ~x;而按位取反和异或全 1 是同一件事,也就是 x ^ 255。对 JIT 来说,好处是语义从「减法」变成了「位运算」:少一条传送/取负指令,也更容易被后续的断言传播和相邻的位运算合并。
foreach (byte b in _data) sum += 255 - b;
Arm64 上,旧代码要先 neg 取负、再 add 累加、再额外 add 255;新代码一条 eor w4, w4, #255 就把取反搞定,然后照常累加。76 字节降到 72 字节,循环少了一条指令。
dotnet/runtime#129361 处理另一个小瑕疵:把 sbyte 跟一个能用 8 位表示的常量做比较时,可以直接用字节宽度比,不需要符号扩展。
private static bool IsLow(sbyte value) => value < -64; // -64 的字节形式是 0xC0
x64 上,原来要先用 movsx 把 8 位符号扩展成 64 位,再和 0xFFFFFFC0 比较;现在直接 cmp cl, 0xC0。14 字节降到 10 字节。(少数确实需要全宽符号位的比较形式仍会保留那条扩展指令。)
五、真实的性能数字:float/double 转 long/ulong
前面几节主要看代码大小。这里给一组带真实耗时的数据。dotnet/runtime#125180( @saucecontrol)改进了 x86 机器上(支持 AVX-512 或 AVX10.2 的)非溢出 float / double 到 long / ulong 的转换。
这些转换对 NaN 和越界值是有明确定义行为的,所以旧代码为了保住这些语义,会调用一个辅助函数。新的指令集允许 JIT 把「正常路径」完全内联、全部在寄存器里完成,从而绕开这次辅助调用;不支持这些指令的机器仍然走原来的回退路径。
[Benchmark] public long SingleToInt64() => (long)_single;
[Benchmark] public ulong SingleToUInt64() => (ulong)_single;
[Benchmark] public long DoubleToInt64() => (long)_double;
[Benchmark] public ulong DoubleToUInt64() => (ulong)_double;
在支持 AVX-512 / AVX10.2 的机器上用 32 位 x86 运行时测得:
| 方法 | 运行时 | 平均值 | 比值 | 代码大小 |
|---|---|---|---|---|
| SingleToInt64 | .NET 10.0 | 5.103 ns | 1.00 | 31 B |
| SingleToInt64 | .NET 11.0 | 2.093 ns | 0.41 | 54 B |
| SingleToUInt64 | .NET 10.0 | 4.810 ns | 1.00 | 31 B |
| SingleToUInt64 | .NET 11.0 | 1.366 ns | 0.28 | 34 B |
| DoubleToInt64 | .NET 10.0 | 4.834 ns | 1.00 | 31 B |
| DoubleToInt64 | .NET 11.0 | 2.101 ns | 0.43 | 54 B |
| DoubleToUInt64 | .NET 10.0 | 4.663 ns | 1.00 | 31 B |
| DoubleToUInt64 | .NET 11.0 | 1.363 ns | 0.29 | 34 B |
注意这里一个有意思的反直觉点:代码反而变大了(31 B 涨到 54 B),但耗时只剩 0.28~0.43 倍。用几十字节的指令去换掉一次带复杂语义检查的辅助函数调用,是最划算的一类交易——尤其两个转无符号类型的版本,直接快到了原来的 1/3.5。
简化的总账:这半章所有代码大小变化
把上面的数字摊开排一遍,可以更直观地看清这半章的「性价比」:
| 改进点 | 相关 PR | 平台 | 代码大小(.NET 10 → .NET 11) |
|---|---|---|---|
| 结构体字段偏移折叠 | dotnet/runtime#122297 | Arm64 | 60 B → 40 B |
SequenceEqual 编译期求值 |
dotnet/runtime#121985 | x64 | 65 B → 3 B |
| 128 位整数向量常量断言传播 | dotnet/runtime#127124 | Arm64 | 68 B → 56 B |
| 零基等值链 → 无符号范围检查 | dotnet/runtime#124567 | x64 | 24 B → 13 B |
ulong 转换比较 → 32 位比较 |
dotnet/runtime#128091 | Arm64 | 80 B → 76 B |
值关系传递,消去 y > 0 |
dotnet/runtime#127950 | Arm64 | 40 B → 36 B |
| 汇合路径上的类型测试 | dotnet/runtime#128500 | Arm64 | 112 B → 88 B |
| 嵌套阈值看穿空跳转块 | dotnet/runtime#127434 | Arm64 | 44 B → 32 B |
隐式 else 的 if-conversion |
dotnet/runtime#124738 | Arm64 | 36 B → 24 B |
0.0.Equals(value) 消去冗余选择 |
dotnet/runtime#127915 | Arm64 | 40 B → 24 B |
| 2 的幂位测试归一化 | dotnet/runtime#128533 | Arm64 | 40 B → 36 B |
255 - b → b ^ 255 |
dotnet/runtime#126529 | Arm64 | 76 B → 72 B |
sbyte 比较去掉符号扩展 |
dotnet/runtime#129361 | x64 | 14 B → 10 B |
每一项单独看都很细碎,加在一起就是方法体普遍瘦身的百分之几十。尤其是 SequenceEqual 那个 65 B → 3 B,属于典型的「不是变快了,而是不干活了」。
JIT 编译器(三):向量化(Vectorization)
SIMD(Single Instruction, Multiple Data,单指令多数据)的核心概念是:一条指令同时对多个值做同一个操作。标量加法一次处理一对 32 位整数,128 位 SIMD 加法一条指令就能处理四对,256 位和 512 位分别是八对和十六对。当循环的各次迭代彼此独立时,「向量化」就能用一次向量运算顶替多次标量迭代,吞吐量提升非常可观。
.NET 提供三层能力:
- 可变宽度的可移植类型
Vector<T>:同一份代码在任何机器上都能跑,T的个数取决于当前硬件的宽度; - 固定宽度类型
Vector64<T>到Vector512<T>:装的T的个数恒定; - 体系结构专用的 intrinsic:对这些向量类型做特定运算,由 JIT 映射到底层对应的硬件指令。
向量里的每个元素通常被称为一个「lane(车道)」。因为 JIT 是直接认得这些操作的(而不是把它们当普通方法调用),它可以在其之上做常量折叠、指令选择和路径裁剪——这也是为什么前面讲向量的那一节里,断言传播能一路把 AndNot 整体消掉。
这里有个容易被忽略的好处:Vector<T> 的可变宽度意味着同一份 IL 在不同机器上会得到不同宽度的向量代码。你的 Vector<int> 在一台支持 AVX-512 的机器上可能是 16 个 lane,在只有 128 位的机器上就是 4 个 lane——写一次,到处都为当下的硬件优化。这个「宽度选择与指令选择」的活(该用多宽、该选哪条指令、哪些硬件分支可以整块裁掉),全交给 JIT。
.NET 11 在 AVX-512 的广播(broadcast)和掩码(masking) 上有一批集中改进。
嵌入式广播:一条常量只需要存 4 字节
「嵌入式广播」让一条指令在装载一个标量的同时,把它复制到所有 lane,从而不必在内存里准备一个满宽的向量常量。对比下面两条 x64 指令:
; 需要只读数据段里的 64 字节
vpandd zmm0, zmm1, zmmword ptr [reloc @RWD00]
; 只需要 4 字节,{1to16} 表示「装载 1 个 dword,广播到 16 个 lane」
vpandd zmm0, zmm1, dword ptr [reloc @RWD00] {1to16}
因为广播是作为装载的一部分完成的,它并不增加额外的指令延迟;主要收益是数据体积更小、缓存占用更少。
dotnet/runtime#117700( @saucecontrol)改进了当一个 intrinsic 的自然元素宽度和它的托管向量类型宽度不一致时的广播选择。VNNI(用于小整数乘加运算的向量神经网络指令)和按位运算现在可以使用最小有效的重复常量,避免一次满向量装载。
看一个例子:
// 需要 AVX-VNNI 与 AVX-512F
AvxVnni.MultiplyWideningAndAdd(Vector128<int>.Zero, _bytes, Vector128<sbyte>.One);
| 对比项 | .NET 10 | .NET 11 |
|---|---|---|
| 指令 | vpdpbusd xmm6, xmm0, xmmword ptr [RWD00] |
vpdpbusd xmm6, xmm0, dword ptr [RWD00] {1to4} |
| 只读数据 | dq 0101010101010101h, 01010101...(两段 8 字节) |
dd 01010101h(4 字节) |
这条 VNNI 指令做的是「把两组字节做乘积累加并累加到 32 位整数 lane 上」,它真正需要的只是一个重复出现的字节模式,而不是一整块 16 字节数据。
这一组 benchmark(VnniBroadcast、MaskAnd、BlendMaskAllOnes、MultiInsert、MultiInsertZero)加起来,在 .NET 11 中只读数据段少了 12 字节,常量池缓存占用也少了 12 字节。
嵌入式掩码:把 blend 折进同一条指令
「嵌入式掩码」让一条指令只更新部分 lane。掩码是「每个 lane 一个比特」,该比特决定这次运算是否作用于对应 lane。没有嵌入式掩码时,代码往往得先把每个 lane 都算出来,再和旧值做一次混合(blend);把掩码折进指令本身,可以同时消掉这次独立 blend 和一次零向量初始化。
还是 dotnet/runtime#117700 带来的连锁效果:
Vector128.ConditionalSelect(
Vector128.GreaterThan(_u32, Vector128<uint>.Zero),
(_u64 & Vector128<uint>.One.AsUInt64()).AsUInt32(),
Vector128<uint>.Zero);
以前:这条 AND 用的是 qword 粒度广播({1to2}),元素宽度对不上,后面还得单独来一次 blend 把掩码后的结果搬出去——两条指令。
现在 Vector128<uint>.One 可以按 dword 粒度广播,掩码的元素宽度和 AND 的元素宽度就一致了,于是解锁了合并掩码形式:
; .NET 10:两条
vpandq xmm0, xmm0, qword ptr [reloc @RWD00] {1to2}
vpblendmd xmm0 {k1}{z}, xmm0, xmm0
; .NET 11:一条,{k1}{z} 表示「按掩码 k1 操作,未选中的 lane 清零」
vpandd xmm0 {k1}{z}, xmm0, dword ptr [reloc @RWD00] {1to4}
代码 45 字节 → 39 字节,数据 8 字节 → 4 字节。这个「按位操作 + 掩码选择」的模式在大量做位运算和哈希的向量化算法里反复出现,System.Numerics.Tensors、System.IO.Hashing、甚至 System.Private.CoreLib 里都有它的影子。
Blend 的化简:全 1 掩码其实只是一个 mov
blend(混合)做的事是:逐 lane 独立地选择,从两个输入里挑一个的值。前面那个例子是「AND 后面跟一次 blend」,JIT 可以把两者折叠起来。更广义地看,blend 本身也有很多化简空间:有时候掩码或者某个输入让选择变得毫无悬念——全 1 掩码永远选同一个输入,那 blend 就退化成一次 mov;某个输入是全零时,它往往可以变成 AND 或 ANDN。AVX-512 还给得更多:常量掩码和清零行为可以直接编码进指令里。
dotnet/runtime#123146( @saucecontrol)在可移植 API 和硬件专用 API 两边统一地实现了这些化简。全 1 掩码的例子最直观:
[Benchmark]
public Vector512<int> BlendMaskAllOnes() =>
Avx512F.BlendVariable(Vector512.Create(_n), _values, Vector512.Create(-1));
.NET 10 要先广播 _n 构造第一个输入(vpbroadcastd)、从只读数据把掩码装进掩码寄存器(kmovq)、再执行 blend(vpblendmd)。但掩码全是 1,意味着永远选中第二个输入,所以 .NET 11 直接把第二个输入装进来就行:
; .NET 11
vmovups zmm0, [rcx+48] ; 就是掩码全 1 时必然选中的那个输入
39 字节 → 23 字节,一次广播、一次掩码装载和整条 blend 指令全部消失。
小结
这一章的两组改动,气质其实很像:
- 简化部分做的事是「证明+改写」:证明这段计算没必要(常量折叠)、证明这个分支恒真恒假(分支简化)、证明这个选择可以压进一条指令(if-conversion)、或者干脆认出一个固定的便宜模式(窥孔)。
- 向量化部分则是在问「这条指令能不能少搭点戏台」:广播能不能少加载几十字节常量、掩码能不能折进指令里不用额外 blend、blend 在掩码已知时能不能直接退化成一次搬运。
它们共同的特点是:单个改动往往只省几条指令、几个字节,但这些模式在热路径上数以千次地重复。JIT 的每一次升级,都是把这些小小的「弯腰捡钢镚」变成常态——而你什么都不用做,重新编译一次就好。
JIT 编译器(四):内在函数、寄存器分配、写屏障与 GC、冻结数据、JIT 吞吐
这一章钻进 JIT 的机房。内在函数、寄存器分配、写屏障、死代码清理——每一项都是单点不起眼、叠起来就是整个运行时气质的底层功夫。老规矩:改了什么、为什么更快、快了多少,一个 PR 一个 PR 地盘。
内在函数(Intrinsics)
所谓 intrinsic(内在函数),指 JIT 能认出来并特殊对待的托管 API。最常见的处理方式,是把对方法的调用直接替换成行为等价、但更快或更小的本机代码。
第一类改进:JIT 过去"认不出",现在认出来了。
dotnet/runtime#128678 改进了对泛型数学接口 IBinaryNumber<T>.Log2 的识别。Log2 计算整数以 2 为底的对数,等价于最高置位比特的下标,比如 Log2(16) 等于 4。过去 JIT 在类型规范化时丢掉了整数的符号信息,导致没法把这个操作直接按 intrinsic 导入;内联托管实现时结果尚可,但一旦内联失败,托管调用就原样留在那里。.NET 11 里 JIT 会查询精确类型:无符号或非负的有符号输入可以直接变成前导零计数或位扫描运算,负的有符号值则保留托管回退路径和原有的异常行为。
更经典的是 dotnet/runtime#122779 修复的 Enum.Equals。在 where T : Enum 的泛型方法里写 a.Equals(b),看着是强类型比较,实际上 enum 并没有 Equals(T) 方法,走的是从 System.Enum 继承的虚方法 Enum.Equals(object)——于是第二个参数得装箱,接收者也因具体枚举没重写该方法而要装箱,再一次虚调用。看似强类型的比较,实际是两次装箱加一次虚分派。.NET 11 让 JIT 认出这个调用后,直接折叠成对枚举底层整数值的比较:
[MethodImpl(MethodImplOptions.NoInlining)]
private static bool EqualsGeneric<T>(T a, T b) where T : Enum => a.Equals(b);
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| CountOrdinal_Generic | .NET 10.0 | 59.24 ns | 1.00 | 288 B |
| CountOrdinal_Generic | .NET 11.0 | 10.01 ns | 0.17 | – |
六次比较,.NET 10 装箱十二次、分配 288 字节;.NET 11 只剩一次整数比较。比值 0.17,接近 6 倍提速,分配清零。
顺带一提,NativeAOT 多年来一直靠 ILCompiler 里的 IL 重写补丁实现同等优化(NativeAOT 内部也用 JIT 做提前编译);如今 JIT 自己会了,dotnet/runtime#123086 便把那套重写机制连同配套 machinery 一并删除。
第二类:向量代码的生成质量。
dotnet/runtime#127329 改进了 Vector256.Sum 和 Vector512.Sum:JIT 现在在整个向量宽度上做归约,最后才合并各 lane 的部分和,避免了过去把宽向量拆成 128 位小块时反复提取、shuffle 的序列。x64 上这段代码从 63 字节缩到 39 字节。
dotnet/runtime#127402 把向量常量传播从 128 位扩展到 Vector256 和 Vector512。宽向量与已知哨兵值比较相等后,后续使用可以直接替换成该常量、继续折叠。示例里 (value + Vector256.Create(10)).GetElement(6) 被整体折叠成常量 16,vpaddd、提取指令、32 字节常量和相关控制流全部消失:85 字节变 59 字节。
第三类:补齐可移植向量 API。
.NET 一直希望"一次编写、到处按各自硬件优化"。@hez2010 的 dotnet/runtime#129627 新增了一批可移植 API:构造常见 lane 序列(如 [1, 2, 4, 8] 或 [a, b, a, b])、拼接半向量([a, b, c, d] 与 [w, x, y, z] 的下半段拼出 [a, b, w, x])、交错、反交错和反转,并让 JIT 把它们内在函数化。这些操作过去并非做不到,但你得知道该够哪个平台指令——比如手写 Zip 得去调 AdvSimd.Arm64.ZipLow 这类平台专属 API。现在代码只管声明变换意图,选指令的事交给 JIT。
第四类:让向量值待在该待的地方。
向量值本质是结构体,JIT 常做"结构体提升"(struct promotion),把字段拆成独立局部变量分别优化。这对普通结构体是好事,对应该整体待在向量或掩码寄存器里的值却是灾难——拆开意味着额外搬移,还可能糊掉本可以一次完成的整块存储,内联引入更多局部存储后尤其如此。dotnet/runtime#128013 在所有平台上把 SIMD 与掩码存储一致地标记为与 intrinsic 相关(包括 32 位 x86 和 x64 的掩码存储),让这些局部变量保持完整;dotnet/runtime#129563 又把这一原则延伸到被 bitcast 成 SIMD 类型的用户自定义结构体——牺牲这些局部变量的提升,换来向量表示得以保全。
典型受益者是 Vector2Double 这类"内存布局同硬件向量、对外暴露具名字段"的数值类型:先 bitcast 成 Vector128<double> 做 SIMD,再 bitcast 回来。.NET 10 里第一个 SIMD 结果要途经两个栈位置才能参与第二次加法,.NET 11 全程留在 xmm0,辅助方法从 52 字节缩到 22 字节。
dotnet/runtime#128350 则给了 x86/x64 寄存器分配器更多自由:FMA 和 AVX-512 三元逻辑指令有多种等价的操作数读写排列,选一个与周围寄存器现状匹配的排列,就能省掉原本必需的搬移。
泛型向量代码还有个坑:Vector128<T>.operator == 返回 bool,返回类型看不出元素类型,JIT 只能从操作数推。在包括基于内部 ISimdVector 抽象的辅助方法在内的某些泛型上下文里,JIT 查错了类型信息,没能把 operator 识别成 intrinsic,只好退回逐 lane 比较的托管实现。dotnet/runtime#130086 标记这些 operator 让元素类型取自第一个参数。忽略大小写的序数字符串比较器内部使用的泛型辅助方法因此受益:
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| OrdinalIgnoreCase | .NET 10.0 | 27.794 ns | 1.00 |
| OrdinalIgnoreCase | .NET 11.0 | 21.861 ns | 0.79 |
类型认对了,提速两成就自己送上门。
第五类:拥抱新硬件,也照顾老硬件。
x86/x64 侧收获颇丰:
- @saucecontrol 的 dotnet/runtime#124114 解决了不带 AVX-512 的 32 位 x86 上 uint 转 float/double 要走运行时辅助函数的问题——老的 x86 转换指令只认有符号整数,uint 的一半范围装不进有符号 32 位值,辅助函数因此而生。JIT 现在内联一段显式处理最高位的向量指令序列,省掉了调用及其寄存器和栈开销:
| 方法 | 运行时 | 平均值 | 比值 | 代码大小 |
|---|---|---|---|---|
| UInt32ToSingle | .NET 10.0 | 4.752 ns | 1.00 | 37 B |
| UInt32ToSingle | .NET 11.0 | 2.403 ns | 0.51 | 43 B |
| UInt32ToDouble | .NET 10.0 | 4.727 ns | 1.00 | 39 B |
| UInt32ToDouble | .NET 11.0 | 2.402 ns | 0.51 | 41 B |
耗时砍半;代码大了几字节,但省掉一次调用,划算。
- @alexcovington 的 dotnet/runtime#124804 加入 AVX-512 位矩阵乘 API。位矩阵把每个比特当一个元素、用位运算组合行列,在纠错和 CRC 计算里很有用,一条指令顶过去一长串移位、掩码和异或。
- @jamesburton 的 dotnet/runtime#128365 补上
AvxVnni.V512,把 AVX-VNNI 从 256 位扩展到 512 位操作数,量化机器学习模型常用的小整数点积一次能处理 64 字节而不是 32 字节。 - @saucecontrol 的 dotnet/runtime#126062 发现:当选择掩码本身来自向量、后续操作还需要向量形式时,硬转成 AVX-512 掩码寄存器反而更贵——老派的向量 blend 更短、占用资源更少。k 寄存器只有 8 个,部分微架构上写它们的指令还有端口争用。结果就是更简单的 vblendvps 形式,29 字节缩到 24 字节。
- dotnet/runtime#127048 更新了 JIT 的 x86/x64 浮点与 SIMD 成本模型:反映现代指令吞吐与编码体积,替换掉陈年的 x87 假设和"所有 intrinsic 一个价"的扁平成本。成本估准了,公共子表达式消除、循环展开这些决策才不会跑偏,512 位操作尤其受益。
- dotnet/runtime#130422 把"提取 lane 再 WithElement 插回"折叠成一条 insertps——它的立即数本来就能同时指定源 lane 和目标 lane,没必要先把 lane 2 shuffle 到标量位置再插入。
- @alexcovington 的 dotnet/runtime#125666 用"乘、加、置换归约"替换专用的 AVX 点积指令,在当代核心上吞吐更好:
| 方法 | 运行时 | 平均值 | 比值 | 代码大小 |
|---|---|---|---|---|
| PlaneDot | .NET 10.0 | 2.616 ns | 1.00 | 13 B |
| PlaneDot | .NET 11.0 | 1.365 ns | 0.52 | 31 B |
| QuaternionDot | .NET 10.0 | 2.640 ns | 1.00 | 13 B |
| QuaternionDot | .NET 11.0 | 1.326 ns | 0.50 | 31 B |
| Vector128Dot | .NET 10.0 | 2.597 ns | 1.00 | 13 B |
| Vector128Dot | .NET 11.0 | 1.366 ns | 0.53 | 31 B |
三个点积全部砍半,代码大一点也值。
- 字节向量乘法是老大难:x86 没有打包字节乘法指令,得用更宽的 16 位乘法拼、只留每个乘积的低字节。无法把整个运算加宽到更大向量时,.NET 10 只能把输入拆成两半,分别变宽、相乘、收窄再拼回。dotnet/runtime#126348(@saucecontrol)改用掩码和移位分离奇偶字节,全宽做两次 16 位乘法,再合并低字节:
| 方法 | 运行时 | 平均值 | 比值 | 代码大小 |
|---|---|---|---|---|
| Multiply | .NET 10.0 | 3.752 ns | 1.00 | 114 B |
| Multiply | .NET 11.0 | 2.174 ns | 0.58 | 73 B |
提取、变宽、收窄、重插的序列没了,时间与体积双降。
- dotnet/runtime#127094 让 Half 与 float 的标量转换在 AVX2 可用时走 F16C 的 vcvtps2ph / vcvtph2ps 指令:
| 方法 | 运行时 | 平均值 | 比值 | 代码大小 |
|---|---|---|---|---|
| HalfToSingle | .NET 10.0 | 2.506 ns | 1.00 | 104 B |
| HalfToSingle | .NET 11.0 | 1.380 ns | 0.55 | 14 B |
| SingleToHalf | .NET 10.0 | 2.598 ns | 1.00 | 134 B |
| SingleToHalf | .NET 11.0 | 1.351 ns | 0.52 | 19 B |
时间减半,代码从上百字节掉到十几字节。
- @Ruihan-Yin 的 dotnet/runtime#127536 完成了对 Intel APX(Advanced Performance Extensions)的支持。APX 除了扩充通用寄存器组,还给许多指令加了不覆写条件标志的形式,让寄存器分配器和指令调度器能同时保住活跃值与待用条件;CTEST 和 CFCMOV 还能无分支表达链式条件、用更短编码替代某些与零比较的形式。应用无需调用任何 APX 专用 API——硬件和操作系统暴露 APX 时,JIT 自动用上。
Arm64 一侧,传统代码生成与 SVE 齐头并进。
先交代背景:SVE(Scalable Vector Extension,可伸缩向量扩展)与 128 位 AdvSimd 不同,向量宽度不由指令集固定,每个处理器自选支持的宽度,同一份编译好的循环用谓词掩码处理"装得下多少算多少"的元素——特别适合循环次数不是特定向量宽度整数倍的场景。
先看一个朴素但有效的改动。dotnet/runtime#121986 改进 Arm64 上较大栈分配的清零:一次存储两个清零的 128 位向量寄存器,单条指令的清零量翻倍:
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| Stackalloc512 | .NET 10.0 | 13.65 ns | 1.00 |
| Stackalloc512 | .NET 11.0 | 9.557 ns | 0.70 |
| Stackalloc1024 | .NET 10.0 | 25.35 ns | 1.00 |
| Stackalloc1024 | .NET 11.0 | 14.332 ns | 0.57 |
| Stackalloc16384 | .NET 10.0 | 312.97 ns | 1.00 |
| Stackalloc16384 | .NET 11.0 | 162.656 ns | 0.52 |
分配越大收益越大,16 KB 时接近减半。
@jonathandavies-arm 贡献了一波指令选择改进,每个例子都省一条指令:
- dotnet/runtime#119758:与零的比较可以消费前一条算术/逻辑指令顺便设置的标志位,
and+cmp合并成ands。 - dotnet/runtime#123138:识别
(value >> 6) & 0x3F这类位提取模式,映射到专用 ubfx 指令。 - dotnet/runtime#123546:不会溢出的 int 转 long 加宽、紧接着又截断成更小整数类型时,去掉加宽——sxtw 刚扩到 64 位、sxtb 马上截回来,纯属浪费。
寄存器与内存之间也有账可算。dotnet/runtime#126803 把 64 位整数向量的 ToScalar 从 lane 提取指令 umov 换成更直接的 fmov Xd, Dn;ReadyToRun 代码里 dotnet/runtime#129589 把可重定位间接单元格加载从 adrp + add + ldr 折叠成 adrp + ldr #:lo12:,省掉单独的地址加法;dotnet/runtime#129932 重新启用了负的非缩放偏移下的 ldp/stp 成对指令形成——四个相邻存储折叠成两个 stp,辅助方法从 32 字节缩到 24 字节。
位计数同样受益。PopCount 数一个值里 1 的个数,TrailingZeroCount 数最低位 1 以下的零的个数。dotnet/runtime#128677 把两者导入为专用 Arm64 intrinsic,让后续优化看得见意图;硬件支持 FEAT_CSSC 扩展时,dotnet/runtime#130332 再把它们直接降到标量 cnt 和 ctz 指令。
比较掩码是个容易被忽视的优化点。可移植 SIMD 代码常这么写:比较两个向量、调 ExtractMostSignificantBits,然后问"有没有 lane 匹配""有几个匹配"或"第一个/最后一个匹配在哪"。dotnet/runtime#129688(@jonathandavies-arm)让 Arm64 认出这些消费模式,不再把完整标量掩码物化出来,直接在向量掩码上做水平归约。
SVE 与 SVE2 侧的进展同样密集。@snickolls-arm 的 dotnet/runtime#129852 移除了 Arm64 上 Vector<T> 的 128 位宽度上限,让运行时按进程实际的 SVE 向量长度决定类型大小(可伸缩 Vector<T> 在 .NET 11 仍是实验特性、默认关闭,所以这扩展的是实验模式的能力,不会加速默认配置)。公开 intrinsic 面也在扩大:@SwapnilGaikwad 的 dotnet/runtime#118957 暴露了奇 lane 浮点转换——"奇 lane"指转换第 1、3、5 个元素,处理交错数据的加宽/收窄时很有用;@ylpoonlg 的 dotnet/runtime#123890 和 dotnet/runtime#123892 加入了非临时 gather 加载与 scatter 存储——一次读写多个不连续地址(gather 之意),同时提示数据不必留在缓存(非临时之意)。让可伸缩循环真正跑起来的谓词也有多处打磨:dotnet/runtime#127538 为更多循环和内存访问模式生成硬件谓词掩码;@ylpoonlg 的 dotnet/runtime#126398 减少掩码操作的准备性搬移;@snickolls-arm 的 dotnet/runtime#128326 改善 SVE 掩码在 JIT 内部的流动,用指令的清零形式替代单独的常量准备;@a74nh 的 dotnet/runtime#127520 支持可伸缩向量与掩码常量;@snickolls-arm 的 dotnet/runtime#128148 用向量存储初始化可伸缩向量局部变量,替掉标量循环。
寄存器分配
CPU 的寄存器又快又少,生成的代码不停在寄存器和临时栈槽之间倒腾值。寄存器分配决定谁留在寄存器、谁被"溢出"(spill)到栈上——省掉一次溢出,就是同时省掉一次存储和稍后的一次重载。
dotnet/runtime#112740 处理一种特殊场景:小结构体的多个字段打包在同一个参数寄存器里传递。过去 JIT 得先把它溢出到临时栈槽、再逐字段重载;现在可以直接从寄存器提取字段。实测用 Memory<int>(Arm64 上它的 Length 恰好打包在参数寄存器的一部分里),每次调用都省掉一次栈往返:
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| MemoryLengthExtract_Loop | .NET 10.0 | 27.15 μs | 1.00 |
| MemoryLengthExtract_Loop | .NET 11.0 | 23.95 μs | 0.88 |
循环场景下稳定快一成出头,胜在无感、全场景生效。
两个更广谱的改动减少了不必要的复制与溢出:dotnet/runtime#125214 直接处理更多寄存器冲突;dotnet/runtime#125219 让短命值避开即将被后续操作覆写的寄存器。@SingleAccretion 的 dotnet/runtime#126552 移除了方法序言上的一条老限制,顺带消灭了那些只为满足编码规则而存在的跳转。
写屏障与 GC
先补一句背景:.NET 的 GC 是分代的——新对象出生在 gen0,扛过回收就晋升 gen1、gen2,GC 因此可以只回收年轻代、不必扫全堆。但有个隐患:年轻对象的引用可能被写进老对象的字段,只扫年轻代就会漏掉它们。为了保证不漏,每当一次写入可能制造这种引用,JIT 都会生成一小段代码去更新 GC 的记账信息——这就是 GC 写屏障。引用写入极其频繁,写屏障必须尽量便宜,能证明不需要时干脆整个省掉。
数组存储是个复合场景。.NET 数组是协变的,string[] 能当 object[] 用,所以往 object[] 存元素前必须校验实例类型——不然某个 TDerived1[] 伪装成基类数组后塞进 TDerived2,存成功了就出大乱子。过去这个校验加写屏障打包在运行时的数组存储辅助函数里,JIT 看不见内部。dotnet/runtime#126547 把辅助函数调用展开成它执行的一条条独立操作,协变检查和写屏障都暴露给 JIT;当 JIT 已知数组确切类型时,协变检查直接消除、写屏障照常优化:
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| CovariantStore_Loop | .NET 10.0 | 10.85 μs | 1.00 |
| CovariantStore_Loop | .NET 11.0 | 6.042 μs | 0.56 |
已知类型省掉类型检查,写入循环直接提速近一半。
写入有时逐条进行,有时成批发生——拷贝结构体就是典型。dotnet/runtime#128238 把 JIT 的堆目标分析从单条存储扩展到整个结构体拷贝;dotnet/runtime#128542 随后把过去"逐引用字段调用专用辅助函数"的拷贝方式,替换为引用存储加针对非引用数据的向量存储。两者配合,JIT 能为混有引用的 struct 选更高效的写屏障,其余数据用 SIMD 一次拷完。含一个 string、四个 long、又一个 string 的 MyStruct 整体赋值:
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| HeapStructCopy | .NET 10.0 | 4.132 ns | 1.00 |
| HeapStructCopy | .NET 11.0 | 3.071 ns | 0.74 |
写屏障选得更好、数据拷得更快,一次结构体赋值省下四分之一的时间。
dotnet/runtime#130535 处理不含引用的小结构体的等价场景:拷贝被拆成对相邻字段的多次写入后,可以合并成更少、更宽的写入——.NET 10 存 Int128 要分开存低半和高半,.NET 11 把值装进向量寄存器一次写完 16 字节,代码从 15 字节降到 14 字节。同样的思路也适用于源代码里逐字段赋值的情形:dotnet/runtime#126562 覆盖被提升的结构体局部变量,dotnet/runtime#130107 扩展到常量静态地址上的相邻字段——s_point.X = 1; s_point.Y = 2; 在 .NET 11 里把两个 32 位常量合并进 rax,一条 64 位存储写完。另外 dotnet/runtime#127487 让栈保护要求拷贝结构体参数时使用一致宽度的写入,后续更宽的读取不必等处理器调和重叠存储。
写屏障只是生成代码与 GC 交互的一面。压缩式回收时,GC 要先规划幸存对象搬到哪、再更新所有指向它们的引用;为此它记录对象地址、排序、把相邻的幸存者分组成"plug"(塞子)——活对象足够多时,给这些标记列表排序就成了回收里可观的一部分。近年 x86/x64 运行时对足够大的列表使用向量化的 vxsort 实现,.NET 11 里 @a74nh 的 dotnet/runtime#110692 把这一支持扩展到了 Arm64。
GC 元数据被划入哪一代,其影响不亚于单次回收的速度。分代 GC 的根基是"多数对象朝生暮死":gen0 和 gen1 合称短命代(ephemeral),回收频繁,理应不再重访早已熬进 gen2 的状态。这里要解释一下 dependent handle(依赖句柄):它把一个主对象和一个次对象关联起来,只要主对象可达、次对象就保持存活——ConditionalWeakTable<TKey, TValue> 正是构建在它之上。过去句柄自己不随引用对象一起"变老",两个对象早就成长寿对象了,每次短命代回收还是得去扫它。dotnet/runtime#78746 让依赖句柄相应老化、必要时移回年轻代,老句柄从此可以被年轻代回收安全跳过,又不损害可达性:
| 方法 | 运行时 | 句柄数 | 平均值 | 比值 |
|---|---|---|---|---|
| CollectGen0 | .NET 10.0 | 100000 | 1.522 ms | 1.00 |
| CollectGen0 | .NET 11.0 | 100000 | 255.5 μs | 0.17 |
| CollectGen0 | .NET 10.0 | 1000000 | 10.737 ms | 1.00 |
| CollectGen0 | .NET 11.0 | 1000000 | 310.7 μs | 0.029 |
比值 0.029,30 多倍的差距。重度使用 ConditionalWeakTable 的服务,gen0 回收的尾延迟会明显变好。
运行时知识与冻结数据
JIT 只能优化它知道的事实。有些事实来自它自己的分析,有些则来自运行时提供的契约:哪些辅助函数有副作用、新分配字符串的长度、某个数据对象是否永不再移动。
泛型虚调用(比如 baseReference.Foo<string>())可能需要运行时帮忙查找实现——既要按对象实际类型找,又要按泛型实参找。如果这个查找看起来可能有任意副作用,JIT 就不敢复用结果,出现几次就得执行几次,也无法把不变的查找提出循环。dotnet/runtime#122017 让 JIT 更精确地知道这些运行时辅助函数会抛哪些异常、除此之外有无副作用。于是循环外的重复查找可以共享,循环内的查找只做一次而不是十次:
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| GvmCseHoist | .NET 10.0 | 45.75 ns | 1.00 |
| GvmCseHoist | .NET 11.0 | 24.06 ns | 0.53 |
把"查找没有副作用"这份契约交给 JIT,省一半时间是它应得的回报。
性能剖析数据(profile data)是 JIT 学习"哪里重要"的另一条渠道。过去内联可能把重要工作藏进被内联的代码里,采集数据的插桩看不到。dotnet/runtime#119658 允许对被内联的代码同样插桩,让后续 PGO 驱动的编译看到更完整的热点路径。
JIT 吞吐与清理
生成代码的质量不是唯一追求,生成它花的时间同样重要——JIT 的每一项分析都有成本。dotnet/runtime#123856 从全局断言传播(Global Assertion Propagation)里删掉了记账成本收不回收益的检查和映射表。这是 JIT 开发中永恒的平衡术:保留足以支撑有意义优化的信息,砍掉代码质量收益趋近于零的分析开销。
dotnet/runtime#127363 让 PGO 与 OSR(on-stack replacement,方法在循环运行中被原地替换重编译)配合得更稳。OSR 的执行从方法中途开始而非正常入口,重建的剖析数据与实际可用路径未必对得上——JIT 现在会估计这些路径的可能性,而不是断言或干脆弃用剖析数据。
优化会留下不再可达的代码,死代码清理也得跟上。dotnet/runtime#126223 在方法的分支结构发生变化时再多扫一遍,捕捉被早前变换淘汰的块。dotnet/runtime#128515(@BoyBaykiller)反复合并等价的 return/throw 结尾,去掉重复的退出路径,有时还能暴露出更多可共享的代码。效果看 IsLinearWhiteSpace 这个例子:.NET 10 里尾合并只合并了返回 false 的路径、没合并两条返回 true 的路径,导致位测试只覆盖四个值中的三个、9 要单独比较;.NET 11 把 true 路径也合并了,四个值共用同一位测试,代码从 43 字节缩到 33 字节。
还有一类与死代码相关:永不返回的调用(比如必然抛异常的调用)会让其后的一切不可达。dotnet/runtime#128513 在内联之后就移除这种块里的剩余语句和出边、标记为以 throw 结尾,让死代码尽早暴露,交给上面的清理通道收尾。
从认出一次 Enum.Equals,到让一百万个依赖句柄不再拖累 gen0 回收,.NET 11 的 JIT 改进依然是那套哲学:该省的每一纳秒、每一字节都不放过,而读者要做的,只是升级。
启动与部署
在托管 Main 跑起来之前,有一大串事情必须抢先发生:本机 host 要找到应用的依赖项,CoreCLR 要加载足够多的类型和代码才能开始执行,还有一堆框架基础设施要做自我初始化。从这条链路上任意一环拿掉工作量,应用就能更早开工。
让 TPA 列表的构建少干点活
host 的第一步是读应用的 .deps.json,把里面的条目转换成路径,再拼出「受信任平台程序集」(TPA)列表。这个列表告诉 CoreCLR:哪些框架程序集、应用程序集可以用简单名字直接解析。
问题在于,这中间有几处开销是随着资产数量线性增长的,而不是随着「真正有用的工作」增长。.NET 11 连打三枪:
- dotnet/runtime#123568:只有当解析器真的在探测那个 servicing 目录时,才把每个资产拿去和它比对。
- dotnet/runtime#123919:构建 TPA 列表时,不再反复比较 servicing 目录名,也不再复制每一个依赖资产——省掉了大量分配。
- dotnet/runtime#125251:解析
.deps.json时把每个资产的目录分隔符规范化一次并记住结果,而不是每次用到都重新规范化一遍,进一步削减分配。
一句话:能提前算一次的,就别每次都算。
ReadyToRun 里的 Comparer<T>.Default / EqualityComparer<T>.Default
R2R(ReadyToRun)代码的意义在于:方法在能执行之前不必先被 JIT 编译。但之前初始化 Comparer<T>.Default 和 EqualityComparer<T>.Default 时会调用一个基于反射的辅助函数,它最终返回的具体比较器类型在 R2R 镜像构建时是未知的。
后果很尴尬:比较器的构造函数和比较操作会退化成解释执行。
dotnet/runtime#126204 改用可以被 R2R 提前编译的专用辅助函数,并确保所需的比较器类型被打进镜像里。于是这条路径重新回到编译过的快车道。
EventSource:把元数据发现搬到构建时
比「让初始化更快」更好的,是干脆不要初始化。
通常情况下,一个 EventSource 在初始化时要做两件事:发现自己的事件元数据、计算自己的 provider GUID。这些都是运行时的反射活儿。
dotnet/runtime#121180 加了一个内部源生成器:在框架自身被构建的时候就把这活儿干完,并把结果直接emit 出来,供框架里的各个 EventSource 实现使用(包括核心运行时 tracing 的那些)。这样一来,应用首次用到这些事件源时,反射和搭建成本直接归零。
NativeAOT:小块内存不再预占 64 KB 虚存
启动开销不只在托管堆上。NativeAOT 的 AllocHeap 通常只存放少量运行时元数据。但在 Windows 上,它的虚内存分配器会为每个块预留 64 KB 区域,哪怕这块内存一开始只需要 4 KB。
dotnet/runtime#122822 改成对这些小块直接用普通的 new / delete——让分配策略匹配实际用到的内存量级。
顺带一提:R2R 的活不只为桌面和服务器
上面提到的 R2R 改进,动机并不只是桌面/服务器启动。它也是「让 CoreCLR 成为 .NET 移动运行时」这项大工程的一部分。
从 .NET 11 开始,.NET MAUI 在 Android、iOS、Mac Catalyst 上全面转向 CoreCLR——这是最后一批还在用 Mono 的 MAUI 平台。这绝不仅仅是「换个执行引擎」那么简单:这些 App 现在和 ASP.NET Core、云服务、桌面 .NET 跑在同一个运行时上,共享同一套 JIT、GC、诊断基础设施、性能改进和 bug 修复。
同时还给移动端带来了 CoreCLR 的分层编译、ReadyToRun、以及基于 profile 的优化,并为 NativeAOT 打下了共同基础。这个组合很关键:R2R 和打包的 profile 负责预编译启动最关键的那批代码,而优化 JIT 则可以在允许动态编译的平台上,为热点方法生成更高质量的代码。前面提到的比较器特化,也是为了让更多代码留在编译路径上,而不是掉回解释器。
线程
线程是个横切关注点,几乎影响每一个应用和服务。不管是保护共享状态、排队工作,还是协调异步操作,底层机械里的一点小成本都会被迅速放大。所以这是每个 .NET 版本都要重新打磨的地方。
Monitor.Wait / Pulse:把条件变量贴到锁上
Monitor 是 lock 背后历史悠久的同步原语,提供了最广泛使用的互斥支持。它还支持发信号:一个线程可以用 Monitor.Wait 等待,另一个线程用 Pulse 唤醒它。
追踪这些等待者的内部对象叫「条件变量」(condition variable)。以前它被放在一个单独的 ConditionalWeakTable 里,每次都要查表——而这条路径本身已经是同步密集型的了。
dotnet/runtime#129083 把这个条件直接存在锁对象上,从这个路径里移除了那次 ConditionalWeakTable 查找。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| PingPong_MonitorWaitPulse | .NET 10.0 | 4.194 μs | 1.00 |
| PingPong_MonitorWaitPulse | .NET 11.0 | 3.517 μs | 0.84 |
一次 lock + Wait/Pulse 乒乓往返,省掉约 16% 的开销。
把 volatile 的「花生酱」刮掉
Monitor 那项改进针对的是一个具体的共享实现。但另一些成本是像花生酱一样抹开的——散落在大量代码里,每处都只有一点点。
dotnet/runtime#125274 从一大批库字段上移除了不必要的 volatile 标注——这些字段的正确性本来就由锁、Interlocked 或一次性初始化保证了。
为什么这能提速?在 x86/x64 上,硬件本身提供了强内存模型,这些标注一般不会生成额外指令,但它们仍会限制编译器的优化空间。而 Arm 允许更多的重排,JIT 为了保证 volatile 的语义往往需要插入内存栅栏(memory fence)。所以删掉冗余标注,在 Arm 上就是实打实地删掉 fence。
运行时哈希表:更窄的屏障、epoch 回收、xxHash
同样的思路也用在运行时自身的代码里:
- dotnet/runtime#125259:把运行时
HashMap里的全内存屏障换成实际所需的、更窄的 acquire / release 操作。 - dotnet/runtime#124822:VM 里很多哈希表(包括
EEHashTable)是被疯狂读取、偶尔才更新的。这个 PR 引入基于 epoch 的回收(epoch-based reclamation),让读方不必为了「保住旧的 bucket 数组」而被迫进入协作式 GC 模式。 - dotnet/runtime#129640:把这些表原先「一次一个字节」的哈希换成 xxHash 实现,一次吃四个字节。
线程池:小任务的调度开销
沿着同一条线,dotnet/runtime#122726 降低了线程池小工作项周围的调度开销。它做了这几件事:
- 去掉不必要的内存栅栏和共享状态更新;
- 每批次向线程池控制器汇报一次,而不是每个任务汇报一次;
- 减少在信号量上空转(spinning)的时间;
- 只有当排队的任务量显示确实需要时,才去申请新的 worker。
结果是协调开销更少,也不会有worker 刚被唤醒、队列就空了这种白忙活。
CA2027:那个让大规模服务出问题的 Task.Delay
前面我们聊过 runtime async 对 async/await 性能的巨大影响。但 .NET 11 里和 Task 相关的改进不止这些。
其中有个挺有意思的新分析器 CA2027,来自 dotnet/sdk#51452。它能指出一种我见过多次、并且在大规模服务里导致过不小性能问题的 Task.Delay 用法:
Task someTask = ...;
if (await Task.WhenAny(someTask, Task.Delay(timeout)) != someTask) // oops!
{
throw new TimeoutException();
}
写这段代码的人显然想实现一个超时。问题是:它泄漏了。
在理想情况(也是大概率情况)下,someTask 很快就完成了,但那个 Task.Delay 仍然挂着。每个 Delay 背后都有一个占着宝贵资源的 System.Threading.Timer,外加内存里的其它数据。如果 timeout 设得很长、这段代码又在热路径上,我们会积累成千上万个这样的 timer。 内存占用上涨不说,其它和 timer 打交道的调用也会被拖慢。
正确修法是改用 Task.WaitAsync(其实早在 .NET 6 就引入了),它提供了更高效的定时等待机制,并且正确处理了所有相关清理。CA2027 会识别这种问题的常见形态并推荐替换写法。
数值
BigInteger:limb 从 32 位拓宽到 64 位
BigInteger 是那种「很多应用可能永远用不到,但用到的地方往往找不到替代品」的类型。密码学、数论、编译器、以及需要解析/格式化/计算超出固定宽度整数范围的场景,都靠它撑着。但长期以来,它并没有得到和 .NET 其它核心类型那样持续的性能投入。好消息是,.NET 11 给它做了一次大改造。
dotnet/runtime#125799 重写了 BigInteger 实现的很大一部分:把它内部的 limb(即 backing 数组里存放的那些定长小块)从 uint 改成了 nuint(UIntPtr)。
这里先把原理讲清楚。BigInteger 在底层是用一个数组来表示任意大的整数的,数组里每个元素就是一个「limb」,可以理解为一个手算时的「位」。原来每个 limb 是 32 位,现在在 64 位机器上每个 limb 变成 64 位。
关键洞察在于:在 64 位平台上,对一个 64 位值做算术运算,成本通常和对应的 32 位运算没什么差别。那么既然一个 limb 能从 32 位变成 64 位,每一步就能多处理一倍的比特数,而花费的周期数还差不多。粗略地说,原本需要 N 次 limb 乘法/加法的地方,现在只要 N/2 次——这就是提速的主要来源。在 32 位机器上当然没什么变化。
除了换数据类型,这个 PR 还改进了围绕这些更宽 limb 的算法:Montgomery 乘法、ModPow 里的滑动窗口取幂、融合的按位运算步骤、额外的硬件 intrinsics、循环展开,以及缓存。
这些又建立在本版本此前的其它优化之上:
- dotnet/runtime#112178(作者 @kzrnm):超大数值转十进制文本更快;
- dotnet/runtime#112876(作者 @kzrnm):对足够大的操作数启用 Toom-Cook 乘法;
- dotnet/runtime#113005(作者 @kzrnm):改进移位和旋转。
顺便解释一下 Toom-Cook:朴素的乘法是每个 limb 乘每个 limb(复杂度约为 O(n²)),而 Toom-Cook 把每个操作数切成若干块,只做若干次较小的乘法再组合起来。当操作数大到一定程度后,它的总工作量就明显低于「遍历所有 limb 对」。
| 方法 | Limbs | 运行时 | 平均值 | 比值 |
|---|---|---|---|---|
| Divide_BelowBurnikelZieglerThreshold | 64 | .NET 10.0 | 2,954.3 ns | 1.00 |
| Divide_BelowBurnikelZieglerThreshold | 64 | .NET 11.0 | 1,493.8 ns | 0.51 |
| Divide_AboveBurnikelZieglerThreshold | 64 | .NET 10.0 | 10,766.7 ns | 1.00 |
| Divide_AboveBurnikelZieglerThreshold | 64 | .NET 11.0 | 6,303.4 ns | 0.59 |
| Multiply | 64 | .NET 10.0 | 2,359.5 ns | 1.00 |
| Multiply | 64 | .NET 11.0 | 1,328.2 ns | 0.56 |
| ShiftLeft | 64 | .NET 10.0 | 217.1 ns | 1.00 |
| ShiftLeft | 64 | .NET 11.0 | 121.6 ns | 0.56 |
| ParseLargeDecimal | 64 | .NET 10.0 | 9,111,926.1 ns | 1.00 |
| ParseLargeDecimal | 64 | .NET 11.0 | 3,899,478.0 ns | 0.43 |
| ToStringLargeDecimal | 64 | .NET 10.0 | 135,894,135.4 ns | 1.00 |
| ToStringLargeDecimal | 64 | .NET 11.0 | 7,868,359.3 ns | 0.058 |
| Divide_BelowBurnikelZieglerThreshold | 512 | .NET 10.0 | 2,894.6 ns | 1.00 |
| Divide_BelowBurnikelZieglerThreshold | 512 | .NET 11.0 | 1,482.0 ns | 0.51 |
| Divide_AboveBurnikelZieglerThreshold | 512 | .NET 10.0 | 10,751.7 ns | 1.00 |
| Divide_AboveBurnikelZieglerThreshold | 512 | .NET 11.0 | 6,317.8 ns | 0.59 |
| Multiply | 512 | .NET 10.0 | 68,063.6 ns | 1.00 |
| Multiply | 512 | .NET 11.0 | 35,443.5 ns | 0.52 |
| ShiftLeft | 512 | .NET 10.0 | 673.0 ns | 1.00 |
| ShiftLeft | 512 | .NET 11.0 | 309.4 ns | 0.46 |
| ParseLargeDecimal | 512 | .NET 10.0 | 9,149,793.0 ns | 1.00 |
| ParseLargeDecimal | 512 | .NET 11.0 | 3,891,313.0 ns | 0.43 |
| ToStringLargeDecimal | 512 | .NET 10.0 | 135,829,594.6 ns | 1.00 |
| ToStringLargeDecimal | 512 | .NET 11.0 | 7,880,631.1 ns | 0.058 |
结论:乘法、除法、移位基本都是一半左右的时间;而把一个十万位的大整数 ToString 成十进制文本,从 135 毫秒降到 7.8 毫秒——快了整整 17 倍,这大概是整个 .NET 11 里最夸张的单点提升之一。
说明:原文中
ParseLargeDecimal/ToStringLargeDecimal并不随Limbs参数变化(它们固定处理 100,000 位十进制数),表格按原文同时列出两组。
免转码:BigInteger 与 Complex 的 UTF-8 解析/格式化
除了内部改造,BigInteger 还新增了能避开转码的公开 API。如今越来越多的协议和存储格式直接把文本暴露成 UTF-8 字节,但以前的解析/格式化 API 只接受 UTF-16 的 char。于是调用方要么先把输入解码成一个临时字符串再解析,要么先格式化成字符、再把结果编码回字节。
dotnet/runtime#117745 给 BigInteger 和 Complex 都加上了直接的 UTF-8 解析与格式化,复用了原本用于 UTF-16 的泛型数值机制,让这些调用方可以直接操作自己的原始表示。
BigInteger → double / float 的小值快路径
dotnet/runtime#130721 改进了另一个 BigInteger 边界:转换为 double 和 float。
通用的转换逻辑需要:检查这个任意宽度的数量级、定位它最高的置位比特、再按目标浮点格式做舍入。但现实中的绝大多数 BigInteger 实例,其实远小于这套机制设计处理的对象。于是现在的实现会识别「能塞进 64 位」的值,直接走硬件原生的整数转换支持。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| SmallToDouble | .NET 10.0 | 2.873 ns | 1.00 |
| SmallToDouble | .NET 11.0 | 1.764 ns | 0.61 |
| SmallToSingle | .NET 10.0 | 3.548 ns | 1.00 |
| SmallToSingle | .NET 11.0 | 1.764 ns | 0.50 |
| LargeToDouble | .NET 10.0 | 2.863 ns | 1.00 |
| LargeToDouble | .NET 11.0 | 2.797 ns | 0.98 |
| LargeToSingle | .NET 10.0 | 3.559 ns | 1.00 |
| LargeToSingle | .NET 11.0 | 2.849 ns | 0.80 |
小值的 float 转换直接打个对折;大值也得了一点边角收益。
Number.BigInteger 与浮点解析/格式化
同样的 limb 拓宽红利也被推广到了核心浮点类型上。
解析一个超长的十进制输入、或者按很多位来格式化一个浮点值,一旦数值不再装得进普通的尾数,就需要临时借用任意精度算术。.NET 为此内部维护了一个单独的 Number.BigInteger。
dotnet/runtime#132577 把这个类型也改成了原生宽度的 limb 表示,降低了浮点解析、格式化和舍入过程中「每个 limb」的工作量。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| ParseLongFraction | .NET 10.0 | 8.592 μs | 1.00 |
| ParseLongFraction | .NET 11.0 | 3.569 μs | 0.42 |
| FormatSubnormal | .NET 10.0 | 6.884 μs | 1.00 |
| FormatSubnormal | .NET 11.0 | 1.237 μs | 0.18 |
解析长小数快了 2.4 倍,格式化非规格化数快了 5.5 倍。
Matrix4x4.GetDeterminant 的 SIMD 化
.NET 11 的改进当然不局限于 BigInteger 和浮点解析/格式化背后的标量表示,其它数值类型也在变好。拿 Matrix4x4 来说:一个 4×4 矩阵的行列式,本质上是多个互相独立的矩阵元素乘积的组合——这对 SIMD 来说是天然的用武之地。
dotnet/runtime#123954(作者 @alexcovington)给 Matrix4x4.GetDeterminant 加了一个 SSE 实现,把好几个这样的乘积并行算出来:
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| GetDeterminant | .NET 10.0 | 3.836 ns | 1.00 |
| GetDeterminant | .NET 11.0 | 2.645 ns | 0.69 |
TensorPrimitives.Asin:一次算一整组
System.Numerics.Tensors 这套 API 的设计目标是「对大量值做同一个数值运算」,同样是 SIMD 的天然场景。
dotnet/runtime#126052 给可移植的向量类型加了反正弦(inverse sine)的向量化实现,并在 TensorPrimitives.Asin 里使用。现在张量循环会同时对好几个输入求值一个多项式逼近(并对函数定义域 [-1, 1] 的两端做特殊处理),而不是逐个元素去调用 MathF.Asin / Math.Asin:
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| AsinFloat | .NET 10.0 | 33.72 μs | 1.00 |
| AsinFloat | .NET 11.0 | 8.240 μs | 0.24 |
| AsinDouble | .NET 10.0 | 35.80 μs | 1.00 |
| AsinDouble | .NET 11.0 | 10.975 μs | 0.31 |
float 版本快了 4 倍多,double 版本也有 3 倍多。
BitIncrement / BitDecrement:小心 legitimacy 的向量化
对浮点值来说,BitIncrement 和 BitDecrement 的作用是移动到紧邻的那个可表示的值。别看名字里带 Increment/Decrement,它们不能简单地加一减一——还得正确处理有符号零、无穷大和 NaN。
dotnet/runtime#123610 和 dotnet/runtime#123754 分别让 float/double 和 Half 可以一次处理多个值。其中 Half 路径直接操作原始的 ushort 位模式,避免了转成 float 再转回来的开销;两条路径都用向量掩码 + 条件选择,而不是对每个元素去调一个标量辅助函数。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| BitIncrementFloat | .NET 10.0 | 4.223 μs | 1.00 |
| BitIncrementFloat | .NET 11.0 | 1,071.1 ns | 0.25 |
| BitIncrementDouble | .NET 10.0 | 4.223 μs | 1.00 |
| BitIncrementDouble | .NET 11.0 | 2,140.6 ns | 0.51 |
| BitIncrementHalf | .NET 10.0 | 3.918 μs | 1.00 |
| BitIncrementHalf | .NET 11.0 | 573.6 ns | 0.15 |
float 快 4 倍,Half 快了接近 6.7 倍。
TensorPrimitives.Round:删掉一次多余的全量遍历
dotnet/runtime#124280 从 TensorPrimitives.Round 里拿掉一个更「机械」的成本:当 digits == 0 时,老代码会先调用一次完整的 rounding kernel,然后又走一遍另一个完整的 pass。直接返回就省掉了这次冗余遍历以及对目标缓冲区的重复覆写。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| RoundZero | .NET 10.0 | 882.7 ns | 1.00 |
| RoundZero | .NET 11.0 | 205.5 ns | 0.23 |
Half.CompareTo:把 NaN / 有符号零的特殊处理只做一次
Half 的比较也变快了。以前 Half.CompareTo 会分别去问「一个值是否小于、大于、等于另一个」,于是 NaN 和有符号零所需的特殊处理被重复了三次。
dotnet/runtime#131297 把这份工作只做一次,然后把底层比特排布成一种可以直接比较的形式,同时仍然把 +0 和 -0 视为相等。此外,在支持 AVX2 的 x64 上,它还把操作数转成 float(硬件做这个转换非常高效),让 CompareTo、<、<= 都更快。相等性判断仍然保持基于比特,因为那本来就是更便宜的做法。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| CompareTo | .NET 10.0 | 9.340 μs | 1.00 |
| CompareTo | .NET 11.0 | 5.745 μs | 0.62 |
| LessThan | .NET 10.0 | 7.514 μs | 1.00 |
| LessThan | .NET 11.0 | 5.672 μs | 0.75 |
Math.BigMul:直接映射到 x64 的 mulx / imul
两个 64 位整数相乘会产生 128 位结果,而 x64 有指令能直接给出高 64 位和低 64 位。
dotnet/runtime#117261(作者 @Daniel-Svensson)通过 X86Base.X64.BigMul intrinsic 把这些有符号和无符号形式暴露出来。这样 Math.BigMul 就能直接映射到 imul 或 mul,两个半结果直接在寄存器里返回,省掉了旧路径上那些额外指令和寄存器搬运。
| 方法 | 运行时 | 平均值 | 比值 | 代码大小 |
|---|---|---|---|---|
| Signed | .NET 10.0 | 2.210 ns | 1.00 | 65 B |
| Signed | .NET 11.0 | 1.344 ns | 0.61 | 12 B |
| Unsigned | .NET 10.0 | 1.446 ns | 1.00 | 39 B |
| Unsigned | .NET 11.0 | 1.323 ns | 0.92 | 12 B |
代码体积从 65 字节缩到 12 字节,有符号版本时间成本降了约 39%。
Guid / Decimal / IPAddress:「长度既定」技巧
定长格式的数值和标识符辅助函数受益于一个非常朴素的技巧:一次性确定 span 的精确长度,然后让 JIT 复用这个事实。
dotnet/runtime#119254(作者 @xtqqczze)把这个模式应用到了 Decimal、Guid 和 IPAddress 上,消除了重复的边界检查:
private const string Value = "a8098c1a-f86e-11da-bd1a-00112444be1e";
[Benchmark]
public bool TryParseExactD() => Guid.TryParseExact(Value, "D", out _);
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| TryParseExactD | .NET 10.0 | 15.94 ns | 1.00 |
| TryParseExactD | .NET 11.0 | 12.60 ns | 0.79 |
Guid.NewGuid 在 Linux 上也快起来了
Guid 每个 .NET 版本都在变好,.NET 11 里有好几项改进。
只要可能,.NET 会尽量在不同操作系统间保持相近的性能和行为,但底层功能常常就是委托给操作系统,于是也就带上了 OS 的性格。随机数生成就是个例子:Guid.NewGuid 用到的密码学安全随机数,历史上在 Linux 上比 Windows 慢一些,原因是它把 /dev/urandom 当作熵源。
dotnet/runtime#123540(作者 @reedz)把 Guid.NewGuid() 从这条文件描述符路径挪到了 getrandom() 系统调用,省掉了描述符的建立,也省掉了经由文件抽象的读取。
Random.Shuffle:少拷一次长度、少判一次自转
接着聊随机。dotnet/runtime#119890(作者 @hamarb123)从 Random.Shuffle 里去掉两件工作:一次不必要的 span 长度拷贝,以及一个用来跳过「元素和自身交换」的分支判断。
自转(self-swap)是无害的,而且也不常见;但为了检测它,却给每一次迭代都加了一个不可预测的分支。这个差异在短数组和小值类型上最明显,因为那里的 swap 本身就很便宜:
| 方法 | 长度 | 运行时 | 平均值 | 比值 |
|---|---|---|---|---|
| ShuffleSmallValueType | 16 | .NET 10.0 | 138.9 ns | 1.00 |
| ShuffleSmallValueType | 16 | .NET 11.0 | 89.34 ns | 0.64 |
| ShuffleSmallValueType | 4096 | .NET 10.0 | 25,925.1 ns | 1.00 |
| ShuffleSmallValueType | 4096 | .NET 11.0 | 14,151.08 ns | 0.55 |
不管是 16 个元素还是 4096 个元素,基本都是接近一半的时间。
Random.Next:一个刻意为之的 NoInlining
Random 自身还得了一个小而精准的代码生成修复。Random.InternalSample 里包含一个处理器天然难以预测的条件,这种条件用条件指令(conditional instruction)实现比用分支更好。
我们前面聊过 JIT 的 if-conversion 支持本来可以做这个变换——可惜它目前不支持在循环内部做 if-conversion,而被内联的 Random.Next 调用恰恰非常常见地出现在循环里。
dotnet/runtime#131714 于是给这个辅助方法打上 [MethodImpl(MethodImplOptions.NoInlining)],以保住无分支的形式;等将来 JIT 能在循环内做 if-conversion 了,这个标注可以再重新评估。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| Next | .NET 10.0 | 5.954 μs | 1.00 |
| Next | .NET 11.0 | 3.275 μs | 0.55 |
全球化
很多全球化相关的 API 都建立在定位和解释成本高昂的数据之上。比如 DateTime.Now 依赖时区转换数据,而大小写转换和解析则依赖原生全球化服务以及特定 culture 的表。
TimeZoneInfo:把年度转换数据缓存起来
dotnet/runtime#119662 围绕上面这个观察,对 TimeZoneInfo 做了一次大规模重构。
确定一个偏移量并不总是一次固定的算术运算:夏令时规则可能逐年变化,历史规则里也可能包含多次转换和特殊情况。但一旦某个时区某年的转换规则被解释过了,同年其它转换就可以复用它。同样,DateTime.Now 所用的本地偏移量,在两个转换时刻之间是不可能变化的。
于是现在:转换操作会复用缓存的每年度转换数据,而不是反复遍历 adjustment rules;而 DateTime.Now 会把当前生效的 UTC 偏移量连同「下一次需要重算的时刻」一起缓存。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| ConvertTimeFromUtc | .NET 10.0 | 45.13 ns | 1.00 |
| ConvertTimeFromUtc | .NET 11.0 | 19.44 ns | 0.43 |
| ConvertTimeToUtc | .NET 10.0 | 51.97 ns | 1.00 |
| ConvertTimeToUtc | .NET 11.0 | 20.23 ns | 0.39 |
| GetLocalNow | .NET 10.0 | 76.41 ns | 1.00 |
| GetLocalNow | .NET 11.0 | 34.39 ns | 0.45 |
时区转换和 DateTime.Now 全部降到原来的一半以下。
Invariant casing:先走托管的 ASCII 路径
dotnet/runtime#120685 把 invariant 大小写转换里的两类成本拆开了。
在常规全球化配置下,ToUpperInvariant 和 ToLowerInvariant 现在会先尝试一条托管的 ASCII 路径。这样对 ASCII 文本做大小写转换,就可以避免或推迟 ICU 的初始化——ICU 正是 .NET 用于 culture-aware 全球化的那个原生库。
而在 invariant 全球化模式下(ICU 根本没加载),这条托管路径同样提升了 ASCII 大小写转换的吞吐。非 ASCII 输入当然仍需走相应的全球化路径。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| ShortAscii | .NET 10.0 | 18.12 ns | 1.00 | 40 B |
| ShortAscii | .NET 11.0 | 14.62 ns | 0.81 | 40 B |
| LongAscii | .NET 10.0 | 248.38 ns | 1.00 | 304 B |
| LongAscii | .NET 11.0 | 39.22 ns | 0.16 | 304 B |
短字符串小幅提升,139 个字符的长字符串从 248 ns 降到 39 ns,快了 6 倍多,分配量保持不变。
更小的几处:按需分配、换掉装箱哈希表
- dotnet/runtime#123886:
DateTimeFormatInfo的「日期词表」(date-word table)只为真正包含这类词汇的 culture 分配。 - dotnet/runtime#122918:把时区表和编码表所用的、带同步且装箱的
Hashtable缓存,换成强类型的ConcurrentDictionary。
往返 "O" 格式与 DateTime.ToString("G")
往返格式 "O" 总是恰好包含七位小数秒,正好对应一秒里的 10,000,000 个 tick。dotnet/runtime#129005 于是把这几位数字直接按 tick 解析,绕开了「先转成 double,再做除法、乘法和舍入」的老路。
格式化那一侧同样受益于特化。dotnet/runtime#129374 让 invariant 的 DateTime.ToString("G") 走既有的定长格式快速路径,跳过通用的 culture-aware 格式化器。注意 DateTimeOffset 仍保留通用路径,因为它的偏移量会改变输出:
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| DateTime_ToString_G | .NET 10.0 | 65.54 ns | 1.00 |
| DateTime_ToString_G | .NET 11.0 | 29.76 ns | 0.45 |
如果你问一个性能优化工程师:.NET 里最值得抠的是哪块?答案大概率不是某个炫技的算法,而是字符串、Span、查找与比较。原因很简单——它们无处不在。JSON 解析要走 UTF-8,二进制数据要 Base64,日志要 Split,校验要 IndexOfAny,路由要正则。这些代码每快一点点,整个应用就跟着抖三抖。
在 .NET 11 里,这一块依然是重头戏。下面我按原文的两大部分——Strings and Spans 和 Searching and Comparing——逐条拆给你看:改了什么、谁改的、为什么更快、快了多少。
字符串与 Span:到处都在跑的那点代码
UTF-8:先把 Arm64 上的「逐元素回退」干掉
UTF-8 到处都是:Web 协议、JSON 载荷、磁盘文件。而 .NET 的 string 是 UTF-16,所以这两者之间的转换必须足够快,这是我们所有人的痛点。
编码(UTF-16 → UTF-8)时,必须校验 UTF-16 的代理对(surrogate pair),同时统计并转换。在 .NET 10 里,Arm64 上的向量化实现在「统计结果字节数」和「检查代理对是否配对」这两件事上,仍然要逐个元素检查。文本里补充字符(supplementary characters,也就是 emoji 那类)一多,代价就很难看——每一个含代理对的向量都会掉进这条逐元素慢路径。
dotnet/runtime#121981 来自 @ylpoonlg,把计数和代理对校验改成了向量宽度的运算(vector-wide operations)。顺带一提,这个 PR 还把 x86 和 Arm64 的大部分实现统一了,只在指令集确实不同的地方保留少量平台专属的小 helper。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| ValidWithSurrogatePairs | .NET 10.0 | 2.994 μs | 1.00 |
| ValidWithSurrogatePairs | .NET 11.0 | 856.8 ns | 0.29 |
这份 benchmark 的输入刻意做到每个 ASCII 字符后面都跟一个代理对,把被删掉的那部分逐元素开销放大到了极致:快了 3 倍多。
UTF-8 解码:只在「真的有非 ASCII」时才去算位置
反方向(UTF-8 → UTF-16)也有改进。UTF-8 解码时,ASCII 字节可以直接搬到 UTF-16 字符上;但一旦遇到第一个非 ASCII 字节,就得动用完整的多字节解码器。所以向量循环需要两样东西:一个快速的「这一批里有没有非 ASCII」的判断,以及在确实存在时,它的精确位置。
问题在于:.NET 10 的做法是对每一个全 ASCII 向量都去算那个位置,而全 ASCII 恰恰是最常见的快路径——这属于纯浪费。
dotnet/runtime#121382 同样来自 @ylpoonlg,改成:先做便宜的向量宽度测试,测试通过了再去算 lane 索引。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| Utf8GetCharCount | .NET 10.0 | 443.5 ns | 1.00 |
| Utf8GetCharCount | .NET 11.0 | 210.6 ns | 0.47 |
全 ASCII 输入下,每个向量都能留在便宜路径上:直接砍掉一半以上。
Base64 编码:带换行的 Convert.ToBase64String 终于吃到向量化
Base64 常用于让二进制数据穿过「只认文本」的格式和协议。编码器天然按「3 字节进、4 字符出」的分组工作,但换行选项(MIME 风格,每 76 字符插入一个 \r\n)需要额外在边界处停下。老实现把这个格式化交给了一条独立的标量路径——也就是说,你一开换行,就自动掉出快车道。
dotnet/runtime#123403 把基于 span 的优化版 Base64 编码器带给了 Convert.ToBase64String(..., Base64FormattingOptions.InsertLineBreaks):每一行都用同一套向量化内核处理,只在行的外围处理分隔符。
| 方法 | 运行时 | 字节长度 | 平均值 | 比值 |
|---|---|---|---|---|
| ToBase64String_InsertLineBreaks | .NET 10.0 | 57 | 60.95 ns | 1.00 |
| ToBase64String_InsertLineBreaks | .NET 11.0 | 57 | 23.91 ns | 0.39 |
| ToBase64String_InsertLineBreaks | .NET 10.0 | 570 | 560.66 ns | 1.00 |
| ToBase64String_InsertLineBreaks | .NET 11.0 | 570 | 194.99 ns | 0.35 |
不管长短,都是接近 3 倍的提升。
Base64 解码:DecodeFromUtf8InPlace 也向量化了
解码方向同样被照顾到了。Base64.DecodeFromUtf8InPlace 是原地解码——直接用解码后的字节覆盖编码输入。在 .NET 10 里,它还在用标量循环,尽管非原地的 DecodeFromUtf8 早就有了 AVX-512、AVX2、AdvSimd、SSSE3 多条路径。
原地解码为什么能安全地向量化?原文给了一个很漂亮的论证:Base64 的比例决定了它安全——读 4 字节只写 3 字节,所以写游标永远落后于读游标;每一次向量存储(包括它零填充的越界部分)都结束在下一个向量加载之前或恰好同时,绝不会覆盖还没读到的源数据。
于是 dotnet/runtime#131333 直接复用了已有的解码 helper 来走原地路径。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| Decode | .NET 10.0 | 10.70 μs | 1.00 |
| Decode | .NET 11.0 | 2.256 μs | 0.21 |
快了将近 5 倍,而且代码反而变少了。
CommonPrefixLength:让短的在前面也别吃亏
MemoryExtensions.CommonPrefixLength 比较两个 span,返回它们开头有多少个元素相同("hello" 和 "help" 就是 3)。它内部用了一个 helper,把较长的那个输入截断到较短者的长度。
dotnet/runtime#121104 来自 @xtqqczze,简化了这个 helper:如果需要,先把第二个 span 缩短,然后总是把第一个 span 切到第二个的长度。这样一来,无论哪个输入原本更长,JIT 看到的都是同一组显式的长度关系——于是它能做的优化也一致了。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| ShorterFirst | .NET 10.0 | 45.16 ns | 1.00 |
| ShorterFirst | .NET 11.0 | 25.43 ns | 0.56 |
| LongerFirst | .NET 10.0 | 26.40 ns | 1.00 |
| LongerFirst | .NET 11.0 | 26.31 ns | 1.00 |
「长在前」本来就已经很高效了,这次改的是「短在前」:两种顺序现在基本跑平。
Encoding.GetEncoding:把读写锁换成 ConcurrentDictionary
文本处理常常从拿一个 Encoding 开始。Encoding.UTF8 这类属性访问很快;而老的代码页(code page)需要先注册 CodePagesEncodingProvider。在 .NET 10 里,这个 provider 的各种表——包括注册后被 Encoding.GetEncoding(string) 使用的名称查找表——用的是读写锁。
dotnet/runtime#125001 把这些缓存换成了 ConcurrentDictionary 实例,预热后的 provider 查找不再需要获取读锁。这是一类典型的「并发下才看得见」的收益。
string.Concat:认出数组和 List,直连 span 实现
string 本身也有改进。dotnet/runtime#130361 来自 @prozolic,它让 string.Concat(IEnumerable<string?>) 在收到 string[] 或 List<string?> 时,直接把它们的连续存储交给基于 span 的实现,而不是走通用的枚举路径。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| Array | .NET 10.0 | 4.230 μs | 1.00 | 5.7 KB |
| Array | .NET 11.0 | 3.285 μs | 0.78 | 5.67 KB |
| List | .NET 10.0 | 6.703 μs | 1.00 | 5.71 KB |
| List | .NET 11.0 | 3.253 μs | 0.49 | 5.67 KB |
List 的收益尤其明显——直接砍半。分配量本身没有变化(结果字符串总得分配),但省掉的是枚举器和中间缓冲的开销。
最后一颗小糖:span[start..] 的 IL 从 21 字节缩到 9 字节
原文作者 Stephen Toub 说他最喜欢的就是这类「小到到处都生效」的改进,dotnet/roslyn#82729 就是典型。
以前你写 span[start..],编译器会把它降级成等价于 span.Slice(start, span.Length - start) 的形式。JIT 这些年一直在努力把它优化成 span.Slice(start) 的样子,但——如果 C# 编译器一开始就直接发出后者,大家都轻松。现在它就是这么做的。差别直接体现在 IL 上:
// 之前
ldloc.0
ldloc.1
ldloc.0
call instance int32 System.Span<char>::get_Length()
ldloc.1
sub
call instance System.Span<char> System.Span<char>::Slice(int32, int32)
// 之后
ldarg.1
call instance System.Span<char> System.Span<char>::Slice(int32)
| 方法 | 运行时 | 平均值 | 比值 | 代码大小 |
|---|---|---|---|---|
span[start..](之前) |
— | — | — | 21 字节 |
span[start..](之后) |
— | — | — | 9 字节 |
别小看这 12 个字节:每一个切片表达式都会省下它们,而且 JIT 再也没有「猜不猜得准」的负担了。
查找与比较
先说正则:为什么它值得单独讲一大段
以某种形式做「搜索」,是程序最常干的事之一。而搜索文本时,正则是极其常见又好用的表达方式。.NET 的正则支持这些年进步巨大:.NET 5 和 .NET 7 有大笔投入,之后每个版本都没落下,.NET 11 也一样。
要理解下面的改进,先记住一件事:Regex 实例创建时,需要解析模式,并把它变成能用来实际搜索的形态。正则语言表达力极强,同一个模式有多种写法,有些写法处理起来更高效。所以解析过程中,Regex 会对解析树做一系列简化和优化,把它变成理想形态,同时学习这个模式的事实(比如算出任何可能匹配的最小/最大长度),以便后续进一步优化。
关键在于:每一种变换都可能为其他变换创造新的机会;但由于变换是按顺序应用的,这些机会有时会被错过。.NET 11 的一大主题,就是让这些 pass 看得更清、跑得更全。
收尾清理 pass:把公共前缀提出来
dotnet/runtime#125289 给编译型和源生成的正则,在「整个模式的优化已经重塑完模式之后」再加了一道最终的清理 pass。
拿模式 [ab]+c[ab]+|[ab]+ 举例。在一段全是没有 c 的长串 a 的输入上,.NET 10 的源生成匹配器会先把整串扫一遍找第一个分支,找不到 c 而失败,然后再把同一串扫一遍找第二个分支——同样的字符被扫了两次。这道收尾清理 pass 会把公共的 [ab]+ 提取出来,只留下 c[ab]+ 作为可选后缀:扫描一遍就够了。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| SharedPrefix | .NET 10.0 | 550.9 ns | 1.00 |
| SharedPrefix | .NET 11.0 | 282.8 ns | 0.51 |
翻倍。
忽略大小写的 alternation:前缀从 "htt" 变成 "http"
除了增加 pass,还有一批改动是在改善这些分析 pass 能看见什么。
比如模式 (http|https) 配 IgnoreCase。出于一些「不太有意思的历史原因」,.NET 10 的引擎只能提取出 "htt" 作为可搜索前缀——但它本可以提取 "http"。dotnet/runtime#124881 改进了这一点,让引擎能跳过远多于从前的假候选。
这份 benchmark 的输入里,在最终匹配之前有 25,000 个后面不跟 p 的 "htt" 前缀:
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| IgnoreCaseAlternation | .NET 10.0 | 415.0 μs | 1.00 |
| IgnoreCaseAlternation | .NET 11.0 | 7.012 μs | 0.017 |
59 倍。多认一个字符,就是这么夸张。
捕获组挡住了前缀识别:让 \b(in)\b 也能搜 "in"
pass 们在寻找各种模式时,有时候一点点小东西就会挡住它们的视线,导致优化被错过。dotnet/runtime#124842 改进的正是这样一个场景:捕获组妨碍了可搜索前缀的识别。
对于 \b(in)\b 配 RegexOptions.IgnoreCase,现在引擎能发现它可以按 ordinal-ignore-case 去搜 "in" 这个字面串——括号不再碍事。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| IgnoreCaseCapturedPrefix | .NET 10.0 | 277.7 μs | 1.00 |
| IgnoreCaseCapturedPrefix | .NET 11.0 | 7.286 μs | 0.026 |
约 38 倍。
多字面量前缀:改用 SearchValues<string> 整串搜索
上面几个例子说明了一件事:对正则处理最有影响力的事情之一,就是提升引擎「找到下一个可能的匹配起点」的能力,并优化那个搜索。dotnet/runtime#124736 正是干这个的——它对编译型、源生成、以及 NonBacktracking 正则,改进了引擎在「若干字面量前缀中任选其一」时的搜索方式。
比如 agggtaaa|tttaccct。.NET 10 的源生成器会先在偏移 3 处搜 [ag],然后再检查附近字符是否为 [gt]。对一段全是 a 的输入来说,这是个极弱的过滤器——几乎每个位置都成了候选。
.NET 11 的生成器改为:用 SearchValues<string> 去搜完整的 agggtaaa 和 tttaccct 字符串。而且它不是无脑切换——一个频率启发式会判断:只有在区分大小写的分支、且预计整串搜索能比字符集过滤拒绝更多假候选时,才选择这条路。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| Match | .NET 10.0 | 861.9 μs | 1.00 |
| Match | .NET 11.0 | 9.251 μs | 0.011 |
| Miss | .NET 10.0 | 861.4 μs | 1.00 |
| Miss | .NET 11.0 | 9.647 μs | 0.011 |
无论命中还是落空,都是约 90 倍的提升。
找到位置之后:证明「不用回溯」,直接测最终位置
找到下一个可能匹配的起点只是前半场;到了那个位置你还得试着匹配,这部分也想优化。
考虑模式 \b\w+n\b。\w+ 本身可以匹配 n,所以这个循环不能被自动当成原子的(atomic)。通常的流程是:贪婪地匹配完循环、匹配 n 失败,然后回溯着去找下一个可以匹配 n 的位置。
但——如果 n 后面的东西(这里是单词边界)根本不可能被这个循环匹配到,那次搜索就是白做的。dotnet/runtime#125636 教会编译型和源生成引擎去证明这一点,然后直接测试最终位置,而不是沿着循环已匹配的内容往回搜。同样的思路也适用于其他「循环后跟一个字面量」的情形,只要引擎能证明更早的位置不会改变结果。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| Matching | .NET 10.0 | 3.441 μs | 1.00 |
| Matching | .NET 11.0 | 3.118 μs | 0.91 |
| NonMatching | .NET 10.0 | 83.656 μs | 1.00 |
| NonMatching | .NET 11.0 | 69.984 μs | 0.84 |
命中路径小幅提升,不匹配路径省掉约 16%——因为那正是回溯最狠的场景。
锚定模式:连一个字符都不用看就能判死
有时候,一个匹配在检查输入的任何一个字符之前就可以被排除。当匹配从位置 0 开始、模式是固定长度、且带有前导 \A(或非 multiline 的 ^)和尾随 \z 时,只有当整个输入的长度恰好等于那个长度时才可能匹配。
dotnet/runtime#120916 在计算出的最大长度等于最小必需长度时,为编译型和源生成引擎把这次长度检查直接提到最前面。下面这个例子:模式要求正好 512 个字符,而输入有 513 个。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| AnchoredReject | .NET 10.0 | 41.06 ns | 1.00 |
| AnchoredReject | .NET 11.0 | 16.05 ns | 0.39 |
不到四成的耗时。
RegexOptions.Compiled:补上源生成早就有的两招
一直以来,团队都尽量让 RegexOptions.Compiled(发 IL)和源生成器(发 C#)背后的两个编译器保持 1:1。但确实有少数地方它们分叉了,通常是因为一方能轻松用上目标语言的某个特性,而另一方没有。
第一个分叉:alternation 的分派。 如果多个从左到右的原子分支各自以不同的字面字符开头,引擎理论上可以「读一个字符,直接跳到对应分支」,而不是挨个试。C# 这边我们发的是一个 switch,C# 编译器会用各种策略把它降级成 IL;而在 .NET 10 及更早,发 IL 的路径没有 C# 编译器可用,就直接跳过了这个优化。.NET 11 里,dotnet/runtime#122959 直接发出了与 C# 编译器等价的实现,把这个优化也带给了 RegexOptions.Compiled。
private readonly Regex _regex = new(@"(?>a0|b1|c2|d3|e4|f5|g6|h7|i8|j9|k10|l11|m12|n13|o14|p15)", RegexOptions.Compiled);
这个模式有 16 个分支,benchmark 专门去命中最后一个分支(这也是顺序试探最吃亏的情况):
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| DispatchToFinalBranch | .NET 10.0 | 233.9 μs | 1.00 |
| DispatchToFinalBranch | .NET 11.0 | 159.5 μs | 0.68 |
省掉约三分之一。
第二个分叉:反向引用(backreference)。 一个区分大小写的反向引用,比如 ([a-z]+)-\1 里的 \1,问的是「接下来的输入是否等于本次匹配中之前捕获到的文本」。源生成的正则早就用优化过的 SequenceEqual 做这个比较了,而 RegexOptions.Compiled 没有。dotnet/runtime#123914 让它也用上了。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| Backreference | .NET 10.0 | 168.4 ns | 1.00 |
| Backreference | .NET 11.0 | 49.59 ns | 0.29 |
快了 3 倍多。
Ascii.Equals:长度 8 到 15 也能向量化
搜索当然不只有正则。.NET 里还有很多方法负责查找和比较,其中一些在 .NET 11 也有明显提升。
Ascii 类提供了一组针对 ASCII 文本的校验与操作优化 helper。像 Equals 这样的成员在 .NET 10 已经向量化了,但 .NET 11 的 dotnet/runtime#123115 进一步保证了长度在 8 到 15 之间的输入也能被向量化——之前这段「不上不下」的长度会掉回标量路径。
| 方法 | 运行时 | 长度 | 平均值 | 比值 |
|---|---|---|---|---|
| Equals_Matching | .NET 10.0 | 8 | 3.834 ns | 1.00 |
| Equals_Matching | .NET 11.0 | 8 | 1.966 ns | 0.51 |
| Equals_Matching | .NET 10.0 | 15 | 6.177 ns | 1.00 |
| Equals_Matching | .NET 11.0 | 15 | 2.398 ns | 0.39 |
长度 8 翻倍,长度 15 快了 2.6 倍。别嫌绝对值小——8~15 字节正是标识符、短 token 的常见长度。
SequenceEqual 认识 Guid 和 Int128 了
dotnet/runtime#130644 也改进了相等性比较,这次是 Guid 或 Int128 的 span 上的 SequenceEqual。以前 SequenceEqual 把它们当成任意结构体,一个元素一个元素地比。这个 PR 教会运行时:它们的按位表示是固定的,完全可以按原始字节来比较。于是它们能走进为基元类型准备的那条优化内存比较路径——包括 JIT 的循环展开和向量化。
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| GuidEqual | .NET 10.0 | 2.960 ns | 1.00 |
| GuidEqual | .NET 11.0 | 2.077 ns | 0.70 |
| Int128Equal | .NET 10.0 | 3.547 ns | 1.00 |
| Int128Equal | .NET 11.0 | 2.077 ns | 0.59 |
两者在 .NET 11 都收敛到了同一个 2.077 ns——这正是「走同一条内存比较路径」的直接证据。
string.Split:一次打包比较,跳过两倍输入
.NET 11 里 string.Split 也有改进。在产出结果字符串之前,Split 得先找到分隔符字符并记下位置。.NET 10 里这个搜索已经向量化了:不是一次看一个 UTF-16 字符,而是载入一整个向量,并行比较所有 lane 和分隔符,再把比较结果转成掩码来定位。
.NET 11 在 x86/x64 上,dotnet/runtime#125379 来自 @hamarb123,把 ASCII 分隔符的「无匹配路径」做得更便宜:它载入两个 UTF-16 字符向量,把它们的 16 位元素打包成一个字节向量,然后只检查这个合并后的向量。没匹配?那它就用一次打包比较跳过了两倍的输入;只有可能存在匹配时,才去做确定精确位置所需的完整 16 位比较。
(同样的打包技术在别处也早有应用,比如各种 SearchValues<T> 的实现。)
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| SplitNoSeparators | .NET 10.0 | 786.0 ns | 1.00 |
| SplitNoSeparators | .NET 11.0 | 404.7 ns | 0.51 |
直接翻倍——而且这是「一个分隔符都没有」的最坏情况,也就是扫描量最大的情况。
Arm64:用 shrn 一步把掩码压成标量
一个相关的 Arm64 文本搜索改进来自 dotnet/runtime#126678。
背景:向量比较会产生一个向量——不匹配的 lane 全 0,匹配的 lane 全 1。要找到第一个或最后一个匹配,就得把这些位压缩成一个标量值,然后数它的前导零或尾随零。x86 上有 movemask 指令可以直接干这活;Arm64 没有等价指令,老实现需要一串移位、加宽操作和一次水平加法来凑出来。
.NET 11 改用 shrn(Arm64 的 shift-right-and-narrow 指令)直接把相关位打包。SearchValues<char> 用的就是这些 helper,所以下面这份 benchmark(在输入末尾找匹配)能走到受影响的代码。
.NET 10 的匹配索引路径需要这一串:
; Arm64 ; .NET 10
cmeq v16.16b, v16.16b, #0
movi v17.16b, #0x80
and v16.16b, v16.16b, v17.16b
ldr q17, [MASK]
ushl v16.16b, v16.16b, v17.16b
uxtl2 v17.8h, v16.16b
shl v17.8h, v17.8h, #8
uaddw v16.8h, v17.8h, v16.8b
addv h16, v16.8h
umov w2, v16.h[0]
mvn w2, w2
rbit w2, w2
clz w2, w2
.NET 11 里,等价的工作变成了这样:
; Arm64 ; .NET 11
cmeq v16.16b, v16.16b, #0
mvn v16.16b, v16.16b
shrn v16.8b, v16.8h, #4
fmov x2, d16
rbit x2, x2
clz x2, x2
lsr w2, w2, #2
从 13 条指令缩到 7 条,还省掉了一次常量加载。这类改动不会出现在 API 文档里,但 Arm 机器上每一个 IndexOfAny 都会受益。
新增:一整套「查空白」的 span API
MemoryExtensions 早就提供了基于 span 的搜索:一个或多值用 IndexOfAny,连续范围用 IndexOfAnyInRange,还有 Except、Contains 以及这些操作的「最后一个」变体。比如 span.IndexOfAnyInRange('0', '9') 就是找下一个 ASCII 数字。
空白字符也很常被搜索,但 char.IsWhiteSpace 认可的字符分散在 Unicode 的多个区域,不构成一个连续范围。为了避免每个调用方都自己构造同一个 SearchValues<char>,dotnet/runtime#111439 来自 @AlexRadch,为 ReadOnlySpan<char> 新增了:
ContainsAnyWhiteSpaceIndexOfAnyWhiteSpaceIndexOfAnyExceptWhiteSpaceLastIndexOfAnyWhiteSpaceLastIndexOfAnyExceptWhiteSpace
它们共享同一个基于 SearchValues<char> 的实现,为解析器、校验器、trim 代码以及其他文本处理代码把这些搜索向量化。
不过原文也顺手给了一个很中肯的提醒:向量化不总是赢。拿 trim 来说——要去掉开头的空白,代码需要找到第一个不是空白的字符。这个字符可能在字符串很深的地方,但最常见的情况是几乎没什么可 trim 的。此时标量循环看一两个字符就能返回,而向量化 helper 有固定的启动成本。
结论仍然是值得向量化,因为那点开销很小,而要扫的东西多时收益可以很大。下面这份对比(同在 .NET 11 下,256 个空格)就是「有很多要扫」的情形:
| 方法 | 平均值 | 比值 |
|---|---|---|
| Scalar | 127.87 ns | 1.00 |
| Vectorized | 13.14 ns | 0.10 |
10 倍。而这个方法特别适合的一类场景是:校验输入中「不包含任何空白」——那必须搜索整个输入,而这正是这些方法的向量化大放异彩的地方。作为例子,dotnet/runtime#127123 用它加速了 "X" 格式 GUID 的解析:
| 方法 | 运行时 | 平均值 | 比值 |
|---|---|---|---|
| ParseExactX | .NET 10.0 | 120.6 ns | 1.00 |
| ParseExactX | .NET 11.0 | 87.10 ns | 0.72 |
省掉约 28%。
Span<T>.Sort:终于不再装箱你的结构体比较器
和搜索紧密相关的另一件事是排序。很多年前,MemoryExtensions 里加上了面向 Span<T> 的排序方法。有意思的是,它加的不是 Sort<T>,而是 Sort<T, TComparer> where TComparer : IComparer<T>。
这个签名本意很好:调用方可以提供一个结构体比较器,从而不必分配委托或基于类的比较器;而且因为比较器是受约束的值类型,JIT 应该能把比较内联进排序的热循环。
但实践中,实现把这个结构体装箱成了 IComparer<T>——既分配了内存,又把每一次比较变回了接口调用。这一点当时就知道,只是要避免装箱所需的泛型实现技巧,在当年会带来过高的运行时和代码体积成本。
那些配套成本后来被解决了,于是 dotnet/runtime#116109 来自 @2A5F,现在让值类型比较器在 Span<T>.Sort 中全程不装箱地传递。JIT 可以为该比较器特化排序例程,并把比较内联进去。
private readonly struct DescendingComparer : IComparer<int>
{
public int Compare(int x, int y) => y.CompareTo(x);
}
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| Sort | .NET 10.0 | 11.62 μs | 1.00 | 88 B |
| Sort | .NET 11.0 | 3.533 μs | 0.30 | – |
快了 3 倍多,而且分配彻底归零(– 表示零分配)。
原文也诚实地补了一句权衡:泛型特化会增加生成的代码量,非常大的比较器结构体复制起来也可能更贵;这个优化针对的正是这个 API 本来设计时所瞄准的那些小的、无状态或轻状态的结构体。
小结:这一章的主题其实是「别做多余的功」
把上面十几条串起来看,你会发现它们讲的几乎是同一件事:
- UTF-8:别在快路径上算位置,别逐元素回退(#121981、#121382)
- Base64:别因为一个选项就掉进标量路径(#123403、#131333)
- 正则:别扫两遍(#125289)、别用太短的前缀(#124881)、别被捕获组挡住眼(#124842)、别用弱过滤器(#124736)、别无谓回溯(#125636)、别看字符就知道不行(#120916)、别让 IL 版落后于 C# 版(#122959、#123914)
- 比较与搜索:别把长度 8~15 排除在向量化之外(#123115)、别把
Guid当普通结构体(#130644)、别一次只跳一个向量(#125379)、别用十几条指令凑movemask(#126678)、别让用户自己造SearchValues(#111439) - 排序:别装箱(#116109)
没有一个是什么惊天动地的新算法,全都是把本来就不该做的功去掉。但正是这些「去掉」,让 .NET 11 在几乎所有文本处理负载上都往前挪了一大步——而你要做的,通常只是升级运行时。
集合与 LINQ
这一章涵盖三块内容:集合与 LINQ、I/O、网络。它们看起来离得很远,但这一版 .NET 11 的优化思路出奇地一致——别再重复计算那些你已经知道的东西。
一个集合往往比 IEnumerable<T> 能表达的知道得更多:它知道自己的 Count、知道自己的存储是连续的、知道自己的比较器、知道自己的哈希表长什么样。同样,一个 LINQ 迭代器可以知道自己代表多少个元素、知道自己的操作是怎么组合起来的。把这些信息保住,就能省掉枚举、省掉临时存储、省掉反复哈希。听起来像老生常谈,但这一版把它做到了极致。
ImmutableArray:把活交给运行时
dotnet/runtime#119896(来自 @prozolic)把 ImmutableArray.Create 改成走 Array.Copy,而不是手写一个逐元素的循环。
手写循环的代价在于:每一次索引、每一次赋值都要走一遍。而 Array.Copy 可以被运行时针对元素类型和大小做特化——对 blittable 数据走优化的批量内存拷贝,对需要 GC write barrier 的引用类型,则在运行时调好的拷贝辅助函数里完成写屏障。所以这个改动既简化了托管代码,又把 ImmutableArray 接入了那套优化实现。拷贝越大,收益越明显。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| CreateSlice | .NET 10.0 | 552.5 ns | 1.00 | – |
| CreateSlice | .NET 11.0 | 277.7 ns | 0.50 | – |
大数组的创建耗时为 .NET 10 的一半。
dotnet/runtime#118932(同为 @prozolic)做的是类似的事:当另一侧序列是数组、List 或另一个 ICollection<T> 时,让 ImmutableArrayExtensions.SequenceEqual 留在优化路径上。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| SequenceEqual | .NET 10.0 | 925.8 ns | 1.00 | – |
| SequenceEqual | .NET 11.0 | 122.2 ns | 0.13 | – |
序列相等比较快了约 7.6 倍。
Array.FindAll:结果很小时,别倒贴
Array.FindAll 干的是反过来的活:它要产出一个新集合。结果是,当匹配项很少时,临时存储的开销比结果本身还大。
dotnet/runtime#120336(@Henr1k80)让 Array.FindAll 把最开始四个匹配项收进一个内联的栈缓冲区,而不是先建一个中间的 List<T>。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| FindAllMatch(Size=4) | .NET 10.0 | 27.61 ns | 1.00 | 112 B |
| FindAllMatch(Size=4) | .NET 11.0 | 9.212 ns | 0.33 | 40 B |
| FindAllMatch(Size=5) | .NET 10.0 | 36.93 ns | 1.00 | 176 B |
| FindAllMatch(Size=5) | .NET 11.0 | 11.102 ns | 0.30 | 48 B |
四个及以内的匹配项完全不需要中间 List,速度和分配都降到约三分之一。
Dictionary 与 HashSet:抠掉虚调用和边界检查
Dictionary<TKey, TValue>.Remove 之前漏掉了一个「查找和插入早就有了」的优化。dotnet/runtime#125884 为值类型 key 在常见的默认比较器场景下加了一条精简循环。因为这条路径不需要虚比较器调用,JIT 能把更多状态留在寄存器里;引用类型 key 和自定义比较器继续走通用路径。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| Remove | .NET 10.0 | 5.285 ns | 1.00 | – |
| Remove | .NET 11.0 | 4.321 ns | 0.82 | – |
Guid 键的删除快了约 18%。
dotnet/runtime#125893 改的是 HashSet<T> 内部的碰撞链遍历:用无符号比较来测试 entry 索引与数组长度的关系。这个比较本身就证明了后续的数组访问一定在范围内,于是 JIT 可以删掉范围检查。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| ContainsHits | .NET 10.0 | 7.870 μs | 1.00 | – |
| ContainsHits | .NET 11.0 | 7.388 μs | 0.94 | – |
改动很小,但 4096 次命中查找稳定快 6%。
dotnet/runtime#128988(@prozolic)消掉了 OrderedDictionary<TKey, TValue> 删除时的第二次哈希表查找。通过 ICollection<KeyValuePair<TKey, TValue>> 接口删除一个键值对时,实现必须先找到 key,再验证它存的值等于传入的值。这两步都过了之后,entry 索引其实已经在手里了——再查一次 key,等于把哈希计算和碰撞链遍历白做一遍。新路径直接删掉那个已知条目。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| Remove_ExplicitInterface | .NET 10.0 | 220.7 ms | 1.00 | – |
| Remove_ExplicitInterface | .NET 11.0 | 179.0 ms | 0.81 | – |
1 万条数据的批量删除快了约 19%。
UnionWith:布局兼容时,直接克隆
dotnet/runtime#122952 更进一步:当两张哈希表的布局兼容时,不用再一个个插。正常情况下,UnionWith 枚举源、逐个插入每个元素,边算哈希、边查重、还可能在途中扩容。但如果目标是空的、两边用的比较器又兼容,那么源里的每个条目在目标所需的等价规则下本来就是唯一的——于是可以走 HashSet<T> 拷贝构造函数的快通道,克隆整块存储,而不是逐条重建同一张表。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| FreshDestinationUnionWith | .NET 10.0 | 46.207 μs | 1.00 | 252.27 KB |
| FreshDestinationUnionWith | .NET 11.0 | 2.433 μs | 0.05 | 76.07 KB |
空目标集合的 UnionWith 快了约 19 倍,分配降到 30%。
FrozenDictionary:用现成的 Count 定容量
dotnet/runtime#128300(@AndrewP-GH)针对的是集合构造。构建一个 FrozenDictionary<TKey, TValue> 时,如果输入本身不是字典,第一步得先把元素收进一个普通 Dictionary<TKey, TValue>,用它解决重复键问题,之后才挑选最终的冻结表示。问题在于:.NET 10 里这个临时字典是在逐步增长的,哪怕源的 Count 明明唾手可得。这个 PR 拿这个 Count 作为字典的初始容量,避免了反复分配、拷贝和 rehash。
| 方法 | 运行时 | 分配 | 分配比值 |
|---|---|---|---|
| FromArray | .NET 10.0 | 347.17 KB | 1.00 |
| FromArray | .NET 11.0 | 127.16 KB | 0.37 |
4096 条数据构造 FrozenDictionary,分配降到 37%。
SetEquals:能线性扫就别重建
SetEquals 判断两个集合是否含相同元素(与插入顺序无关)。通用实现需要一个临时的可变集合,以便处理重复项和任意枚举顺序。但如果另一侧本来就是比较器兼容的哈希集合,这个「重建」纯属多余。
dotnet/runtime#126309(@aw0lid)为 ImmutableHashSet<T>.SetEquals 加了针对兼容的 ImmutableHashSet<T> 和 HashSet<T> 输入的零分配直通路径:比较器一致时,只要两边数量相同、且一侧的每个元素都能在另一侧找到,就可以判定相等。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| EqualImmutableHashSet | .NET 10.0 | 775.6 μs | 1.00 | 158.16 KB |
| EqualImmutableHashSet | .NET 11.0 | 559.7 μs | 0.72 | – |
| EqualHashSet | .NET 10.0 | 478.6 μs | 1.00 | 157.99 KB |
| EqualHashSet | .NET 11.0 | 230.9 μs | 0.48 | – |
两万次比较场景全部零分配,最快的路径快了 2 倍。
有序集合有个类似的场景。SetEquals 的入参可以是任意 IEnumerable<T>——那个序列可能无序、可能含重复值,所以 ImmutableSortedSet<T> 过去会先把它拷进一个临时 SortedSet<T> 再比。可是当入参是另一个使用相同排序比较器的有序集合时,两边都是唯一值、枚举顺序也一致:只要先比 Count,相同就同步推进两个枚举器,第一对不等的元素即证明不同,走到结尾就证明相同。
dotnet/runtime#126549(@aw0lid)为 ImmutableSortedSet<T> 识别了这个场景,跳过临时 SortedSet,一次线性遍历直接比两个有序序列。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| SetEquals_EqualImmutableSortedSet | .NET 10.0 | 767.7 μs | 1.00 | 430.02 KB |
| SetEquals_EqualImmutableSortedSet | .NET 11.0 | 117.6 μs | 0.15 | – |
1 万元素的相等判定快了约 6.5 倍,分配归零。
SortedSet:视图的 Clear 少折腾
SortedSet<T>.GetViewBetween 返回的是一段视图——实际上是另一个 SortedSet<T> 上的「活的窗口」:通过视图做的修改会影响原集合。所以清空一个视图不能直接把视图替换成空集合,必须把该区间内的原始节点逐个找到并删除。
dotnet/runtime#126410(@prozolic)减少了这一步的临时存储:实现会预先给待删元素列表定好大小,并按索引遍历,而不是反复从临时列表里移除并让它收缩。
| 方法 | 运行时 | 分配 | 分配比值 |
|---|---|---|---|
| GetViewBetweenThenClear | .NET 10.0 | 193.15 KB | 1.00 |
| GetViewBetweenThenClear | .NET 11.0 | 103.93 KB | 0.54 |
清空视图的分配降到 54%。
LINQ:让迭代器把知道的说出来
集合经常是通过 LINQ 被消费的。虽然算子都在 IEnumerable<T> 这个通用抽象上工作,但 LINQ 内部的迭代器可以保住关于数据源和已应用操作的有用事实。这些事实有时能让查询根本不枚举源就给出答案。
看这个例子:source.Append(x).Skip(10).LastOrDefault()。LINQ 查询是惰性的,真正的搜索要等 LastOrDefault 向 Skip 迭代器要最后一个元素才开始。但如果 source.Append(x) 只有 10 个或更少的元素,Skip(10) 必然把它们全部丢掉,留下一个空序列,LastOrDefault 只能返回默认值。而 Append、Prepend、Concat 迭代器在底层源能报出数量时,可以廉价地报出自己的总数。dotnet/runtime#123306(@prozolic)让 Skip 的「取最后一个元素」路径把这个数量拿出来和被跳过的数量比一下,直接报告「没有元素」,而不是去搜一个自己已经知道是空的序列。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| AppendSkipLastOrDefault | .NET 10.0 | 44.89 ns | 1.00 | 144 B |
| AppendSkipLastOrDefault | .NET 11.0 | 16.32 ns | 0.36 | 112 B |
快了近 3 倍,分配也少了一截。
Enumerable.Sum 这一版也有改进。它早就在用 SIMD 了:主循环一次处理四个向量,并在两个累加器之间交替,省掉额外的挪动指令。但 Sum 还承诺结果溢出时抛异常——于是每条向量加法旁边,实现都会根据两个输入和结果的符号,去更新另一个追踪各 lane 是否溢出的向量;每处理完四个向量,循环就检查一次这个追踪向量,需要的话跳到抛异常路径。
问题是溢出本来就罕见,所以常见路径上这次检查加分支几乎总是在确认「什么都没发生」。dotnet/runtime#127429 在 .NET 11 里把这件重复劳动删掉了:把溢出信息在整个向量处理过程中累积起来,等所有向量循环结束后只检查一次。检查溢出的语义不变,但正常路径不必每四个向量就停下来看一眼。这个 PR 还顺带简化了方法遍历输入的方式——用基于 span 的向量加载替换不安全的引用和索引运算,把已处理元素逐段切掉,最后剩下的标量元素用 foreach 处理。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| SumInt | .NET 10.0 | 5.609 ns | 1.00 | – |
| SumInt | .NET 11.0 | 4.731 ns | 0.84 | – |
32 个 int 的求和快 16%。
Enumerable 的 Min 和 Max 同样早就用 SIMD 一次看好多个值了,但处理 byte、sbyte、short、ushort 输入时,最后还是要把末尾的向量拷到栈上、一个值一个值地比。dotnet/runtime#127995 把这最后一步也留在向量指令里,用 shuffle 把各 lane 合并起来。元素类型越小,单个向量里装的值越多,收益也就越大。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| MaxByte(Length=16) | .NET 10.0 | 7.546 ns | 1.00 | – |
| MaxByte(Length=16) | .NET 11.0 | 2.176 ns | 0.29 | – |
| MaxByte(Length=64) | .NET 10.0 | 7.827 ns | 1.00 | – |
| MaxByte(Length=64) | .NET 11.0 | 2.059 ns | 0.26 | – |
byte 求最大值快了近 4 倍,而且长度 16 和 64 几乎一样快——因为已经纯向量化了。
FullJoin:终于把五种连接补齐
LINQ 从一开始就有 Join 和 GroupJoin,.NET 10 补上了呼声很高的 LeftJoin 和 RightJoin。到了 .NET 11,dotnet/runtime#127236 加上了 FullJoin。 这几个算子的区别在于保留哪些未匹配元素、以及如何表示匹配:
Join只输出键匹配的配对。GroupJoin输出每个左侧元素,外加一个包含其匹配右侧元素的序列;无匹配时该序列为空。LeftJoin输出匹配对,也输出未匹配的左侧元素,右侧用默认值占位。RightJoin反过来:匹配对 + 未匹配的右侧元素,左侧用默认值占位。FullJoin输出匹配对,以及两侧所有未匹配元素,缺失的一侧用默认值。
在 .NET 11 之前,应用通常靠 GroupJoin + SelectMany + Concat 拼出来,还要再回头搜一遍第一个输入才能找出没有匹配的右侧元素。内置算子同时省掉了这套迭代器组合和那次重复搜索。
| 方法 | 平均值 | 比值 | 分配 | 分配比值 |
|---|---|---|---|---|
| FullJoin_Manual | 27.615 ms | 1.00 | 3.23 MB | 1.00 |
| FullJoin_New | 1.702 ms | 0.06 | 1.56 MB | 0.48 |
每组 1 万条数据,手写的 FullJoin 模拟要 27.6 ms,内置算子只要 1.7 ms——快了 16 倍。
AsyncEnumerable:修掉那个 O(N²)
前面 Skip 的例子说明了 LINQ 的很多优化是怎么来的:一个算子把信息流给后续算子,后续算子据此再做优化。做法通常是把额外信息挂到 System.Linq 里那些具体内部 IEnumerable<T> 实现的属性上。同步的 Enumerable 经过多个版本已经积累了非常多的特化迭代器,尤其关注那些能显著降低算法复杂度的地方。而 .NET 10 才「出厂自带」的 AsyncEnumerable,一开始这类机制少得多——对于以 I/O 为主的异步序列,这通常无所谓。
但拼接(Concatenation)是个重要的例外。 .NET 11 之前,每次调用 AsyncEnumerable.Append 都会在上一调用产生的序列外面套一个新的迭代器。看一个只追加三个值的链:
var sequence = AsyncEnumerable.Empty<int>()
.Append(0)
.Append(1)
.Append(2);
要产出 0,枚举得穿过三层嵌套迭代器;产出 1 穿两层;产出 2 穿一层。也就是说,产出三个值大约要 3 + 2 + 1 步迭代器。如果是 1000 次 append,这就变成 1000 + 999 + … + 1,约 50 万步,而不是大约 1000 步。 一般地,枚举 N 个值需要 O(N²) 的工作量。
多年前 Enumerable 就是靠特化各类拼接枚举器、让足够的信息流过去,把链式遍历从 O(N²) 降到 O(N) 解决的。在 .NET 11 里,dotnet/runtime#122389 把这套做法应用到了 AsyncEnumerable 上:这些算子把额外的元素或序列累积到一个扁平表示里,而不是为每个 LINQ 算子再加一层包装。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| AppendChain | .NET 10.0 | 8.253 ms | 1.00 | 187.57 KB |
| AppendChain | .NET 11.0 | 28.49 μs | 0.00345 | 136.77 KB |
1000 层 Append 链快了约 290 倍——这就是从 O(N²) 降到 O(N) 的样子。
I/O
I/O 性能常被等同于底层设备的速度,但传输只是操作的一部分。一次命中缓存的文件读取可能几微秒就完成,很多重定向管道可能并发活跃,压缩可能完全在内存里对已有数据操作。在这些情况下,围绕操作的管理层开销可能和搬数据本身一样重要。
这些开销包括:搭好对应的同步或异步 OS 机制、让状态活到操作完成、分配和拷贝临时缓冲区、在调用方的数据和基于流的 API 之间做适配。.NET 11 在每一层都砍掉了一些活。
重定向进程输出:别再占用线程池线程
在 Windows 上,「overlapped I/O」(重叠 I/O)是那种「操作现在开始、操作系统稍后投递完成通知」的异步模型。只要 .NET 在 Windows 上以异步方式做 I/O,它都尽量用对应的重叠 I/O API,而不是把同步 API 当异步用(也就是排一个工作项,让线程池线程阻塞在 I/O 上)。但一直有些漏网之鱼。
重定向的子进程输出过去用的是同步管道句柄,所以对一个 Process 的 stdout 或 stderr 流调 ReadToEndAsync,实际上每条管道还是需要一个线程池线程阻塞在原生 read 里。在 .NET 11 中,dotnet/runtime#125643 改为以重叠读取的方式打开父进程侧的 stdout 和 stderr 端,同时保持子进程端为同步(控制台应用预期如此)。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| ReadOutputConcurrently | .NET 10.0 | 587.8 ms | 1.00 | – |
| ReadOutputConcurrently | .NET 11.0 | 541.8 ms | 0.92 | – |
16 个进程并发读取 8 MB 输出的场景快 8%。
RandomAccess.Read:一个比特位省掉整条回调
我们常把要修的机制叫做「async over sync」(在同步之上套异步)。反过来的「sync over async」可能更糟:它意味着一个线程阻塞着等另一个线程干活——这是循环等待和死锁的必要配料之一,也是服务可扩展性的主要瓶颈,所以我们只要可能就尽量避免。但在确实避不开的地方,至少可以把它做得更好。
RandomAccess.Read 就是这样一个场合:它和一个为异步 I/O 打开的 Windows 文件句柄配合使用时。Windows 要求在打开文件时就指定 I/O 是否重叠,而如果是重叠的,那么即便是在调用方会阻塞到读完成的同步 Read 场景下,Windows 依然要求这次读取走它的 OVERLAPPED 机制。 这种重叠 I/O 避不开,但可以让它更便宜。
过去 .NET 既给这个操作配一个事件让调用线程去等,又注册一个 I/O 完成回调。而现在 dotnet/runtime#126845 用了一个 Windows 有文档记载的约定:把 OVERLAPPED.hEvent 的最低位置 1,就告诉 Windows 在操作完成时发信号给事件、但不要再往 I/O 完成端口排一个完成包。调用线程可以在文件句柄缓存的事件上等,拿到结果,自己做清理。这样就去掉了回调、回调的协调逻辑以及每次操作的分配,而同步等待本身不变。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| Read4K | .NET 10.0 | 4.564 μs | 1.00 | 176 B |
| Read4K | .NET 11.0 | 2.747 μs | 0.60 | – |
4 KB 同步读取快了 40%,而且零分配。
高层的小分配:TextWriter、TarWriter、ZipArchive、FileInfo
更高层也有一些更小的分配收益。比如 dotnet/runtime#121508 让把 TextWriter.NewLine 赋值成它已有的值时变成空操作,并为标准的 "\n" 和 "\r\n" 共享数组,避免每次重新做一次 char[] 转换。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| SetSameValue | .NET 10.0 | 8.281 ns | 1.00 | 32 B |
| SetSameValue | .NET 11.0 | 2.168 ns | 0.26 | – |
重复设置同一个换行符,从 32 B 分配变为零分配。
归档和文件 API 也在砍临时分配。GNU tar 头部里,条目名和链接目标用的是定长字段;两者任一放不下时,TarWriter 就得额外发一条包含长值的元数据记录。 .NET 10 里,TarWriter 先把那个值编码进一个新分配的 byte 数组,再把字节和一个 null 终止符写进一个新的 MemoryStream——因为流不知道最终大小,写的过程中还得自己分配和扩容后备数组。
有了 dotnet/runtime#123835,.NET 11 改为先算出包含终止符在内的精确 UTF-8 大小,分配恰好这么大的一个数组,直接编码进去,再在这个数组之上构造 MemoryStream。这样既去掉了临时的编码数组,也去掉了流的扩容和拷贝。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| WriteLongName | .NET 10.0 | 731.1 ns | 1.00 | 1.36 KB |
| WriteLongName | .NET 11.0 | 616.7 ns | 0.84 | 608 B |
写入长文件名,分配降到 44%。
ZipArchive 同样在消灭临时缓冲。ZIP 归档末尾有一个描述各条目的中央目录。 读这个目录时,过去每个 ZipArchive 都会分配一个新的 4 KB 缓冲区,还有若干条需要合并或切片数据的路径会额外分配数组。有了 dotnet/runtime#123836,.NET 11 从 ArrayPool<byte> 租借中央目录缓冲区,并用 span 和 memory 取代那些额外数组和几处展开写的循环。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| ReadCentralDirectory | .NET 10.0 | 553.5 ns | 1.00 | 5.05 KB |
| ReadCentralDirectory | .NET 11.0 | 258.1 ns | 0.47 | 1.13 KB |
读中央目录快了 2 倍多,分配降到 22%。
最后一个例子:FileInfo.MoveTo 在移动文件前会检查源目录是否存在。它过去专门构造了一个 DirectoryInfo 就为了读它的 Exists 属性——而 Directory.Exists 明明存在、而且不需要这次分配。dotnet/runtime#123893 在 .NET 11 里改用了它。
| 方法 | 运行时 | 分配 | 分配比值 |
|---|---|---|---|
| MoveTo | .NET 10.0 | 332 B | 1.00 |
| MoveTo | .NET 11.0 | 236 B | 0.71 |
文件移动的分配降到 71%。
把压缩器暴露出来:绕过适配流的缓冲
.NET 提供了非常好的高层抽象来处理各种数据和 I/O,其中最显眼的就是 Stream——它为读写各种数据源和格式提供了简单灵活的机制:MemoryStream、FileStream、CryptoStream、ZLibStream、SslStream,等等。
但真正在意性能的人,有时候想往下一层走,直接操作底层原语。比如 ZLibStream、DeflateStream、GZipStream、BrotliStream 这些压缩流,都各自维护着输入和输出的缓冲区。可如果调用方自己就有输入输出缓冲区、或者想用池来管理它们呢?
在 .NET 11 中,dotnet/runtime#123145 公开了底层的 DeflateEncoder/DeflateDecoder、ZLibEncoder/ZLibDecoder 和 GZipEncoder/GZipDecoder 类型,沿用了已有的 BrotliEncoder/BrotliDecoder 模式。这些流类型本来就是这些编解码器的包装,现在你可以直接用它们。它们支持分块的 Compress、Decompress、Flush 操作,也支持一次性的 TryCompress 和 TryDecompress,让调用方能自己提供并复用缓冲区,不必穿过适配流和它们的缓冲。
网络
网络是很多应用的基本盘,也直接处在可扩展服务的热路径上。整个栈上的改进叠加起来,效果来得很快。
Happy Eyeballs:别把延迟串成一串
在栈的最底层,连接和 socket 建立并承载字节流。一个 socket 其实并不连接到主机名,它连接的是 IP 地址加端口。 解析一个主机名可能得到多个候选地址,包括来自 DNS A 记录的一个或多个 IPv4 地址,以及来自 AAAA 记录的一个或多个 IPv6 地址。当 SocketAsyncEventArgs.RemoteEndPoint 是一个 DnsEndPoint 时,现有的 Socket.ConnectAsync 实现会完成解析,然后按顺序依次尝试得到的地址:先连第一个,只有失败了才轮到下一个。
这在第一个地址可达时工作得很好。但一次失败的 TCP 连接不总是很快被报告。 如果那条路由上发出的包被直接丢掉,这次尝试可能会一直挂到超时——哪怕同一主机的另一个地址本来可以立刻连上。
这个问题在同时有 IPv4 和 IPv6 的机器上尤其明显。客户端通常希望 IPv6 能用就优先用,但一条坏掉或配置错误的 IPv6 路径,会让应用先干等一个长超时,才去试 IPv4。业界把应对办法叫做 Happy Eyeballs(快乐眼球),核心思路是让多次连接尝试在时间上重叠:不是在下一个候选前面排队付出上一个的全部延迟,而是在短暂延迟后启动另一次尝试,采用第一个成功的连接,其余尝试随后被取消或丢弃。这会多消耗一些资源,但能大幅削减连接建立的长尾延迟。
在 .NET 11 中,dotnet/runtime#106374 给接受 SocketAsyncEventArgs 的静态 Socket.ConnectAsync 重载加了一个可选的、类似 Happy Eyeballs 的策略。新的重载接受一个 ConnectAlgorithm:ConnectAlgorithm.Default 保持原有的顺序行为,ConnectAlgorithm.Parallel 请求新策略。
当请求并行连接、目标是地址族未指定的 DnsEndPoint、且机器同时支持 IPv4 和 IPv6 时,.NET 会分别发起 IPv4 和 IPv6 的 DNS 查询,并为每个地址族并发跑一个连接循环。每个族内部的地址仍然按顺序尝试,但两个族不再互相等待。第一个成功的连接成为 ConnectSocket,之后由另一个族建立起来的连接会被释放。如果一个族失败,另一个仍可继续;只有两边都连不上时,操作才报告失败。并行模式可能短暂地建立起两个连接,而在第一个候选就能迅速连上时,默认模式仍然更省。
考虑到这个改动的性质,很难做真实基准测试,但可以想办法。作者在同一端口上创建了 IPv4 和 IPv6 两个监听器,但让基准测试只从 IPv4 监听器接受连接。在他的机器上,localhost 解析结果中 ::1 排在 127.0.0.1 之前,所以客户端 socket 默认会先试 IPv6,失败了才试 IPv4。基准测试的 setup 把 IPv6 监听器的 accept backlog 填满,使后续客户端连接请求停滞。
| 方法 | 平均值 | 比值 |
|---|---|---|
| Default | 511.054 ms | 1.000 |
| Parallel | 1.060 ms | 0.002 |
并行算法不被停滞的 IPv6 尝试拖住,1 毫秒多就连上了 IPv4 监听器;默认算法要等约半秒。作者自己评价:有点人造,但意思传达到了。
Socket.Blocking:连接建立后翻回阻塞模式
socket 上另一个有意思的改进和 Socket.Blocking 有关。所有现代网络栈都实现的「Berkeley sockets」有个阻塞/非阻塞模式的概念。 通常默认是阻塞模式(.NET 也是如此):一次 recv() / Socket.Receive 会同步阻塞到有数据可读(或 socket 关闭)。另一种是非阻塞:非阻塞 socket 上的 recv 永远立即返回,不管有没有数据;如果这个操作在阻塞模式下本会阻塞,非阻塞模式下就返回错误码 EAGAIN 或 EWOULDBLOCK,应用可据此决定稍后重试。
接下来是 .NET 的异步操作。在 Windows 上,Winsock 提供了重叠 API,.NET 的 Socket API 能用也确实在用。但在 Unix 上,我们只有标准的 recv 之类的函数,还有 epoll(Linux)和 kqueue(macOS)这类机制,能高效地同步等待大量文件描述符的活动。因此,在 Unix 上 .NET 实现异步 socket 操作的方式是:把 socket 置为非阻塞状态,尝试那次同步操作(比如 Socket.ReceiveAsync 对应 recv),如果操作还不能完成、返回了 EAGAIN/EWOULDBLOCK,就把这次操作的信息排进基于 epoll/kqueue 的机制,等它发信号时再重试。 这在概念上很像 Windows 上用 I/O 完成端口做重叠 I/O 的方式。
麻烦来了:为了在 socket 上实现异步操作,我们得把 socket 翻到非阻塞模式,那如果有人在 Socket.ReceiveAsync 之后接着调 Socket.Send 怎么办? 第一个操作已经把 socket 翻成非阻塞了,第二个要不要翻回去?事实是这么做风险很大——因为多线程使用,竞态条件让它既难做对又昂贵(昂贵是因为需要额外的同步)。.NET 最早在 Linux 上落地时做了一个决定:这次翻转是单程票,一旦非阻塞就永远非阻塞。 第一次执行异步操作时翻转,之后就留在那儿。
那如果有人在已经翻过之后真的发起了同步的 Receive/Send 呢?我们就自己用 sync over async 来模拟阻塞,基本上就是执行异步操作然后阻塞等它完成。内部实现能做得比真的创建任务再阻塞更便宜,但作为机制,本质是一样的。
早期我们对这个做法是安心的,理由是:一个人如果开始用异步操作,多半会继续用下去,中间偶尔夹一两个同步操作问题不大。 这么多年下来基本被验证了……除了一个场景。
在某些系统里,一个相当常见的模式是:初始连接是异步的,之后却全是同步的发送和接收。结果就是每一个操作都在付那份开销:你调 ConnectAsync,我们把 socket 翻成非阻塞,之后每一次 Receive/Send 都在付模拟阻塞的成本。
好消息是,这个场景恰好也是能简单安全地翻回去的:我们可以在 ConnectAsync 交还控制权之前翻回阻塞模式。对于静态的 ConnectAsync 重载,调用方在 ConnectAsync 把 socket 交给它之前根本拿不到连通 socket 的引用;对于实例重载,规定在 ConnectAsync 期间并发使用该 socket 做发送/接收本身就是错误用法。所以在所有情况下,我们都可以在完成代表该操作的 Task 之前把它翻回阻塞模式。这正是 dotnet/runtime#124200 在 .NET 11 里做的事。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| SynchronousRoundTripAfterConnectAsync | .NET 10.0 | 199.2 μs | 1.00 | – |
| SynchronousRoundTripAfterConnectAsync | .NET 11.0 | 161.6 μs | 0.81 | – |
客户端在回复到达前调 Receive 时,.NET 10 必须用 Unix socket poller 模拟等待,.NET 11 可以直接等在原生阻塞 recv 里——1000 次往返快 19%。
Winsock 扩展函数指针缓存:从加锁链表到写时复制数组
Windows 在异步 socket 操作上有另一个顾虑。前面提到 Windows 提供使用重叠 I/O 的函数,比如 AcceptEx、ConnectEx、DisconnectEx、WSARecvMsg,这些是已安装的 Winsock 提供程序给出的扩展函数。.NET 动态查找它们的函数指针,并按 socket 的地址族、socket 类型和协议做缓存。 每个 Socket 实例在第一次需要这些函数时去查这个缓存。
在 .NET 10 里,这个缓存是一个加锁保护的小型全局 List<T>。这个列表很少超过几条,而且几乎每次查找都会命中之前初始化过的条目——但即便是这些只读命中,也要获取同一把锁。当大量 socket 并发开始它们的第一个异步操作时,所有这些查找都被迫在锁上排队。
dotnet/runtime#124997 把这个缓存改成写时复制(copy-on-write)数组。常见的读路径直接取数组快照、无锁扫描。未命中时仍然会加锁,双重检查最新的数组,然后发布一个包含新增条目的新数组。因为只有遇到新的「地址族 + socket 类型 + 协议」组合时才会新增条目,未命中很罕见,预热之后的路径不再被串行化。
SslStream:中间数组和 BIO 拷贝
拿到 socket 连接之后,下一步往往是用 SslStream 铺上 TLS。
先看客户端证书协商。服务器可以在它的 CertificateRequest 消息里,带上它所接受的证书颁发机构的可分辨名称(distinguished name)列表。 SslStream 会把每个编码后的 X.500 名称转成一个 X500DistinguishedName,最终交给证书选择逻辑。.NET 10 里,每个名称都要分配一个 byte[]:Windows 实现是在原生 SSPI 缓冲区上建一个 span 再调 ToArray,macOS 实现则把每个 Core Foundation 的 CFData 值拷进一个新的托管数组。
而 X500DistinguishedName(ReadOnlySpan<byte>) 构造函数从 .NET 5 起就有了,只是这两条 SslStream 路径都早于它,加进来时没同步更新。有了 dotnet/runtime#123904,.NET 11 去掉了这些中间数组:两个实现都改为直接在原生编码上取一个 ReadOnlySpan<byte> 传给 X500DistinguishedName 构造函数。macOS 实现会在这个 span 使用期间保持 CFData 句柄存活,但每个颁发机构的托管拷贝不再需要了。
第二个 macOS 相关的改动:客户端证书选定后,macOS 要求 SslStream 把叶子证书及其中间证书的原生句柄打包成一个 Core Foundation 数组。.NET 10 里,SslStream 先分配一个足够容纳整条链的 IntPtr[],把句柄填进去,再用这个数组创建原生 CFArray。dotnet/runtime#123905 在 .NET 11 把互操作层改为接受 ReadOnlySpan<IntPtr>:SslStream 在 Span<IntPtr> 里构造句柄列表,128 张证书以内的链用 stackalloc,只有更大的链才回退到托管数组。典型的证书链远小于这个数,所以常规的协商路径根本不再分配那个临时 IntPtr[]。
Linux 上的 SslStream:自定义 BIO,把拷贝彻底消掉
一个更大的 Linux 改动,从加密数据的稳态路径上彻底移除了拷贝。
背景:SslStream 在 Linux 上用 OpenSSL,而 OpenSSL 传统上通过一种叫做 BIO 的内存缓冲区与调用方交换数据。 在 .NET 10 里,加密过程先把密文写进一个 OpenSSL 内存 BIO,然后 .NET 再把它拷进待发送的缓冲区。解密则是反方向:.NET 把收到的密文拷进内存 BIO,OpenSSL 解密之后,SslStream 再把明文从自己的缓冲区拷进调用方的缓冲区。
dotnet/runtime#128245 用自定义 BIO 替换了那些内存 BIO,让它能直接指向托管缓冲区。在 .NET 11 里,OpenSSL 可以把加密输出直接写进 SslStream 将要发送的那个缓冲区;在常见情况下,也能把解密后的明文直接写进调用方提供的缓冲区。这个改动还把 setup、OpenSSL 操作和 cleanup 从四次原生调用合并成一次。OpenSSL 依然做它自己的 TLS 内部处理,SslStream 也为 TLS 警报或放不下的输出这类异常情况保留了回退缓冲区,但正常的应用数据路径不再有多余的中转拷贝。
下面的基准测试只用公开 API 就能复现这个影响:在计时之外建立一次 TLS 1.3 连接,然后每个方向各发送一条 16 KB 的 TLS 记录。在 WSL 2 下的 Ubuntu 24.04 x64 上,16 KB 往返提升了 16%。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| RoundTrip | .NET 10.0 | 68.04 μs | 1.00 | – |
| RoundTrip | .NET 11.0 | 57.39 μs | 0.84 | – |
TLS 1.3 上 16 KB 双向往返快了 16%。
HttpClient:请求头、分隔符、trailers
再往上走,HttpClient 的核心 HTTP 实现 SocketsHttpHandler 把协议处理架在 TLS 和底层 socket 之上。
开启 AutomaticDecompression 后,SocketsHttpHandler 会在请求上声明支持的编码,检查响应的最终 Content-Encoding,识别出 gzip、deflate 或 Brotli 时,就返回一个 HttpContent——它的流会在调用方读取时解码压缩的传输字节。
dotnet/runtime#122676 在创建 handler 时就预先算好合并后的 Accept-Encoding 值。在调用方没有自己提供该请求头的常见情况下,它直接加上这个合并值,从而避开了一个 HttpHeaderValueCollection、它背后的 list 和 header 存储对象,以及对每个启用算法的集合枚举。响应侧,在没有 Content-Encoding 时用 TryGetValues 避免物化集合;需要解压时,包装器直接接管原来的 content-header 集合,移除已失效的 Content-Length 和它消耗掉的那个编码,并保留前面的编码,而不是把每个头都拷进一个新集合。
| 方法 | 运行时 | 分配 | 分配比值 |
|---|---|---|---|
| GetAsync | .NET 10.0 | 3.44 KB | 1.00 |
| GetAsync | .NET 11.0 | 2.87 KB | 0.83 |
带 gzip 自动解压的 GET 请求分配降到 83%。
SocketsHttpHandler 还有别的改进。HTTP 内容常常只由一个值组成,但有时一个请求或响应需要把几块独立内容打包在同一个 body 里——这就是 multipart 内容。 比如一次 HTML 表单提交可能包含几个文本字段和一个文件;每个都成为独立的一部分,有自己的头部和内容,而这些部分的集合作为一条 HTTP 消息体发送。接收方需要知道哪一部分在哪里结束,所以消息里用了一个 boundary(边界):一个被特意选成不太可能在内容本身里出现的令牌。
.NET 10 里,MultipartContent 把这个 boundary 保存为字符串,每次序列化内容时都要重建开头和结尾的分隔符字符串、把它们编码成字节,还要单独写各部分之间的分隔符片段。在 .NET 11 中,dotnet/runtime#124963 改为在创建 MultipartContent 时就一次性构造并编码好开头和结尾分隔符,序列化路径之后直接复用和写入这些缓存好的字节。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| Serialize | .NET 10.0 | 78.49 ns | 1.00 | 296 B |
| Serialize | .NET 11.0 | 41.90 ns | 0.53 | 64 B |
序列化 multipart 内容快了一倍,分配降到 22%。
还有几处分配削减,去掉的是「为了填充或检查另一个集合而创建的集合」。例如 dotnet/runtime#122677 把 HTTP/3 trailers 直接写进最终的 HttpResponseHeaders 集合,消掉一个临时的元组 List。Trailers 是响应体之后发送的头部,通常携带校验和之类在写初始头部时还不确定的信息。
另一些路径只需要在已有存储上取个临时视图。dotnet/runtime#131142 让 SocketsHttpHandler 通过 span 检查可用的 HTTP/2 和 HTTP/3 连接,在空闲连接驱逐时避免把每个 list 拷成数组。dotnet/runtime#123034 则类似地改了 HeaderUtilities.DumpHeaders——它被头部集合的 ToString 使用——改为接受 params ReadOnlySpan<HttpHeaders?>,从 HttpRequestMessage.ToString() 和 HttpResponseMessage.ToString() 中去掉了一个小数组分配。
Uri:向量化扫描、一次建串
.NET 11 的改进也体现在 Uri 上。拿 https://user@example.com:8443/files/report%20Q3?q=%E4%BD%A0%E5%A5%BD#summary 来说:在 Uri 能暴露 Scheme、UserInfo、Host、Port、AbsolutePath、Query、Fragment 之前,它得先找到 :、/、@、?、# 这些分隔符,然后逐个校验被分隔出来的组件。ASCII 通常可以原样保留,%20 需要按组件决定是反转义还是保留,而 query 里百分号编码的 UTF-8 需要解码并做 Unicode 感知的规范化。
有了 dotnet/runtime#124433,.NET 11 在更多分隔符查找工作上使用 IndexOfAny 和 SearchValues,一次一个向量地检查长 span,而不是一个字符一个字符地走。而当组件边界已知之后,dotnet/runtime#119435 把反复的保留字符和不安全字符测试合并成一次优化过的 SearchValues 查找。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| LongHost | .NET 10.0 | 218.1 ns | 1.00 | 56 B |
| LongHost | .NET 11.0 | 112.9 ns | 0.52 | 56 B |
| EscapedAscii | .NET 10.0 | 442.6 ns | 1.00 | 448 B |
| EscapedAscii | .NET 11.0 | 208.1 ns | 0.47 | 368 B |
长主机名解析快了约 2 倍,大量转义字符的 URL 也快了 2 倍多、分配降到 82%。
找到组件之后,Uri 还要判断它的文本是否已经是规范形式、还是需要转义或规范化。字母和数字是压倒性最常见的字符,但在 .NET 10 里它们仍然要穿过更通用的字符测试。 有些调用方还会重复做一次规范化检查,而那个答案在解析阶段其实已经确定了。dotnet/runtime#121270 为 ASCII 字母和数字加了快路径,并把早先的结果记下来,让 .NET 11 不必再做一遍同样的检查。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| Parse | .NET 10.0 | 61.86 ns | 1.00 | 56 B |
| Parse | .NET 11.0 | 44.64 ns | 0.72 | 56 B |
普通 HTTPS URL 的解析快 28%。
非 ASCII 输入可能要求对 URI 的多个部分做规范化。比如 Unicode 字符出现在 path、query 还是 fragment 里,可能需要保留、也可能需要不同的百分号编码方式。在 .NET 10 里,解析和重建是交织在一起的:Uri 分别规范化这些组件,并在过程中不断扩展自己存储的字符串。除了让「偏移量记账」变得复杂之外,这些单独的规范化结果和字符串拼接大约会造出五个临时字符串。
在 .NET 11 中,dotnet/runtime#122038 把重建工作和随后的校验分离开来:Uri 把 path、query 和 fragment 规范化进同一个 builder,只创建一次最终字符串,然后在这个已完成的字符串里校验组件边界。host 仍然单独处理,但其余组件不再各产出一个中间字符串。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| Parse | .NET 10.0 | 547.6 ns | 1.00 | 936 B |
| Parse | .NET 11.0 | 386.9 ns | 0.71 | 432 B |
含非 ASCII 组件的 URL 解析快 29%,分配降到 46%——直接省掉了那几个临时字符串。
小结
把这三块放在一起看,.NET 11 在这几个领域的优化其实可以归纳成三条很朴素的原则:
第一,信息已经在那儿了,就把它用起来。 ImmutableArray 知道自己的长度、FrozenDictionary 的源知道自己的 Count、AsyncEnumerable 的 Append 链知道自己有多长、Skip 的最后一个元素路径知道自己必然是空的。这些都不是新知识,只是过去没有被传递下去。
第二,重复的工作可以合并或后移。 Sum 的溢出检查从每四个向量一次改成循环结束后一次;OrderedDictionary 的第二次哈希查找直接删掉;Uri 从「边解析边拼接、大约五个临时字符串」改成「一次建串、一次校验」。
第三,临时的东西能省就省。 栈缓冲区(Array.FindAll)、ArrayPool(ZipArchive)、stackalloc(证书链)、写时复制数组(Winsock 函数指针缓存)、按精确大小一次分配的数组(TarWriter),以及用自定义 BIO 把整段中转拷贝砍掉(SslStream)。它们各自省的不多,但都落在热路径上。
ConnectAlgorithm.Parallel 这种 Happy Eyeballs 式的改动,和 RandomAccess.Read 上「把 OVERLAPPED.hEvent 最低位置 1」这种改动,性质完全不同:前者是算法层面的策略选择,后者是对操作系统一个文档化约定的利用。 .NET 11 在这一章里的改进,两层都做了。
JSON
JSON 的读写器,本质上大部分时间都在做一件事:扫描文本。写入的时候要找"哪些字符需要转义",读取的时候要找"空白在哪、一个 token 到哪结束"。.NET 11 把这几处扫描换成了更高效的实现。
Utf8JsonWriter:用 SearchValues 预计算转义集合
先说写。使用默认编码器时,Utf8JsonWriter 需要找出那些不能直接原样拷进 JSON 的字符——比如引号、控制字符。.NET 10 里,这个搜索是走 JavaScriptEncoder.Default 完成的,每找一个字符都要过一遍编码器逻辑。
dotnet/runtime#129781 给 .NET 11 换成了一组预先算好的 SearchValues(对应默认转义规则)。SearchValues 是 .NET 8 引入的类型,它会为一组目标字符预先生成最优的向量化搜索表,后面每次搜索都能一次比对多个字节。于是写入器可以直接在输入上搜,不再绕编码器。
找到要转义的字符之后,还要写出 \" 或 \u0022 这样的序列。.NET 10 的转义辅助方法拿到的是整个剩余的 destination,所以每写一个字节/字符都要重复做一次边界检查。
dotnet/runtime#129803 改成只传"已知可写的那段范围"。这样 JIT 只需要证明一次"这个转义序列装得下",后续每个 store 的边界检查就都能被消掉。
benchmark 用一段 2048 个双引号的字符串(也就是每个字符都要转义的最坏情况):
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| Write | .NET 10.0 | 30.83 μs | 1.00 | – |
| Write | .NET 11.0 | 7.875 μs | 0.26 | – |
全转义场景下,.NET 11 只用了 .NET 10 的 26% 时间,快了近 4 倍。你平时序列化字符串里带引号、换行、中文之外的控制字符越多,这块的收益越明显。
Utf8JsonReader:一次跳过一整段空白
再说读。JSON 允许在 token 之间放无意义的空白,缩进过的文档里会出现成片的空格和换行。.NET 10 的 Utf8JsonReader 是一个字节一个字节地看过去的。
dotnet/runtime#129701 把 SkipWhiteSpace 改成用 IndexOfAnyExcept,配上一个只包含四种 JSON 空白字节的 SearchValues(空格、制表符、回车、换行)。于是 .NET 11 可以一次跳掉整段空白,直接停在下一个可能是 token 起点的字节上。
benchmark 是一份 WriteIndented = true 序列化出来的文档(2048 个字符的 Message + 256 个 int):
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| Read | .NET 10.0 | 7.418 μs | 1.00 | – |
| Read | .NET 11.0 | 5.945 μs | 0.80 | – |
读取整份缩进 JSON 快了 20%。注意这里是 0.80 而不是 0.26——因为读的时候真正耗时的还有解析 token 本身,空白只是其中一部分。但只要你线上在用缩进格式(日志、配置文件、人肉可读的 API 响应),这 20% 就是白拿的。
Diagnostics
创建 Activity、轮询指标、写日志——这些可观测性代码的成本是刻意付出的,目的是让生产系统变得可理解。但问题是:这些代码往往就在"每个请求、每次依赖调用、每条日志"的路径上。每次一点点固定开销,量大了就很可观。所以一个很重要的原则是:关掉或者没人看的埋点,必须"用才付费"(pay for play)。
W3C Trace Context:trace-id 校验与 baggage 编码
先补个背景。分布式追踪里,一个 trace 跟着请求穿过应用甚至多个服务,路径上的每个操作可以用一个 Activity 表示。这些 Activity 各有自己的 span ID,但共享同一个 trace ID,追踪系统靠它把 span 串成一次请求。
W3C Trace Context 标准定义了这些标识怎么在服务间传递,最典型的就是 HTTP 的 traceparent 头。其中 trace ID 是 32 个小写十六进制字符,且不能全为 0。应用可能需要对每个请求解析并校验这个 ID。
.NET 10 里,DiagnosticSource 是用一个循环逐字符检查的:既要看是不是十六进制字符,又要看是不是非 0。两个条件,一个循环,一个字符一个字符来。
dotnet/runtime#119673 把那个循环换成了两次 ContainsAnyExcept 搜索:一次找"有没有 0–9 / a–f 之外的字符",一次判断"是不是整串都是 0"。这种搜索一次能检查多个字符。同一个 PR 还顺手把 W3CPropagator 里手写的 tracestate / baggage 字符校验循环也换成了 SearchValues<char>,并且改了 baggage 的编码方式:先搜第一个需要转义的字符,如果一个都没有(大多数情况如此),就整段一次性 append,不用再逐字符判断、逐字符追加。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| ExtractTraceParent | .NET 10.0 | 45.41 ns | 1.00 | – |
| ExtractTraceParent | .NET 11.0 | 9.137 ns | 0.20 | – |
| InjectBaggage | .NET 10.0 | 96.69 ns | 1.00 | – |
| InjectBaggage | .NET 11.0 | 47.895 ns | 0.50 | – |
解析 traceparent 快了 5 倍,注入 baggage 快了 2 倍。 这是一条"每个请求都要付"的开销,在高 QPS 服务上,省下来的是实打实的 CPU。
Process 启动:环境变量封送与 posix_spawn
Process 的开销是另一种形态。启动进程需要把托管字符串和环境字典翻译成操作系统要的格式;查询进程信息则经常要跨到 native API,只为拿一小块数据。
先看启动。Unix 上新进程通过 argv 和 envp 接收参数和环境,两者都是"以 null 结尾的指针数组,指向以 null 结尾的字符串",envp 里每一项形如 key=value。而 ProcessStartInfo 暴露的是托管字符串和托管字典,所以 Process.Start 得把这些数据全部封送过去。
.NET 10 的做法是:先把每个 key 和 value 拼成一个新的托管 key=value 字符串,再把这些字符串收进一个中间数组;然后构造 argv 和 envp 时,又为指针数组做一次 native 分配、为每个 UTF-8 字符串再各做一次 native 分配。
dotnet/runtime#126201 改成了:先一趟扫描数出需要多少个指针、总共多少 UTF-8 字节;然后为 argv 和 envp 各分配一块 native 内存,这块内存里同时装指针表和所有字符串数据,直接往里写。中间的托管字符串、中间数组、以及每个字符串各自的 native 分配和释放,全都没了。
再看进程创建本身。传统 Unix 模型是 fork 出一个父进程的逻辑副本,再在子进程里 exec 换成目标程序。虽然有写时复制,fork 不会真的立刻复制全部内存,但操作系统仍然要复制进程状态和页表——对一个大的多线程应用,这部分工作相当可观。
dotnet/runtime#126063 让 .NET 11 在 macOS 上改用 posix_spawn 处理常见情况。posix_spawn 把"创建进程"和"加载可执行文件"合成一个操作交给操作系统,同时仍然能描述所需的 stdin/stdout/stderr 重定向、工作目录和信号状态。只有需要切换用户/组凭据的启动仍然走 fork + exec,因为 macOS 的 posix_spawn 做不了所需的 setuid / setgid。
| 方法 | 运行时 | 平均值 | 比值 | 分配 | 分配比值 |
|---|---|---|---|---|---|
| StartWithEnvironment | .NET 10.0 | 1.484 ms | 1.00 | 48.16 KB | 1.00 |
| StartWithEnvironment | .NET 11.0 | 1.435 ms | 0.97 | 14.48 KB | 0.30 |
| StartAndWaitForExit | .NET 10.0 | 1.371 ms | 1.00 | 16.95 KB | 1.00 |
| StartAndWaitForExit | .NET 11.0 | 1.362 ms | 0.99 | 14.48 KB | 0.85 |
(benchmark 带 256 个环境变量。进程创建本身主导了耗时,时间上几乎没变化,但封送分配的下降非常明显:带环境的启动从 48.16 KB 降到 14.48 KB,只剩 30%。频繁拉起子进程的服务,GC 压力会小很多。)
ProcessName:只查名字,就别把整个对象都填上
进程跑起来之后,Process 实例能暴露一堆信息。这些 OS 数据被收集并缓存在一个内部的 ProcessInfo 对象里,好让各个属性分摊成本。
问题在于:.NET 10 的 Linux / macOS 上,你只问一句 ProcessName,也会触发整套机制把整个对象和它上面所有的东西都填上——而你明明只需要一个名字。Process.ToString() 因为包含进程名,同样吃这个亏。
dotnet/runtime#126449(来自 @tmds)在 .NET 11 里加了一条更窄的操作系统查询,专门取名字。ProcessName 和 ToString() 走这条路径,不再顺带收集其余的进程元数据。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| GetProcessName | .NET 10.0 | 332.13 μs | 1.00 | – |
| GetProcessName | .NET 11.0 | 11.90 μs | 0.04 | – |
从 332 微秒降到 11.9 微秒,快了约 28 倍。 这是本文里最夸张的一个数字之一——如果你有代码在循环里读进程名(监控、看门狗、诊断工具),这个改动直接是数量级的差别。
Native AOT:只为本地进程 API 付钱
Process 还支持查询另一台 Windows 机器上的进程,比如 GetProcesses(string machineName)。这条远程路径依赖 Windows 性能计数器基础设施,还牵扯远程 Registry 访问等一堆额外组件。一个只在本机 Process.Start 的应用,显然不该背上这些。
但在 .NET 10 里,好几个纯本地 API 都委托给了"同时支持远程"的重载:比如 GetProcessById(int) 是用 "." 去调机器名重载,其他辅助方法则在运行时才选择本地还是远程实现。关键在于:哪怕应用永远只走本地分支,trimmer 看到的是"存在通往两种实现的调用路径",于是必须保留远程进程和 PerformanceCounter 相关代码。结果就是,一个几乎只调了 Process.Start 的 Native AOT 应用,可执行文件里也带着这套用不上的支持代码。
dotnet/runtime#126338 给本地 API 提供了专用的、不引用远程实现的路径;远程实现改为通过一个委托访问,而这个委托只在真的要操作远程机器时才初始化。远程进程查询照样能用,但如果应用只用本地 API,trimmer 现在能证明远程机制及其依赖不可达,把它们全部剪掉。
示例程序只做一件事:启动 cmd.exe /c exit 并等待退出,然后 Native AOT 发布:
| 运行时 | 可执行文件大小 |
|---|---|
| .NET 10.0 | 1,599,488 字节 |
| .NET 11.0 | 1,326,080 字节 |
二进制小了约 273 KB(减少 17%)。 注意这只是一个几乎什么都没做的程序——省下的全是纯纯的"没用到的代码"。
Metrics:可观测仪器不再硬凑一个数组
System.Diagnostics.Metrics 里,Meter 创建仪器产生测量值,由监听器(比如 OpenTelemetry provider)消费。
仪器分两类:一类由应用在事件发生时主动更新;另一类是可观测仪器(observable instrument),注册一个回调,等监听器要采集时才计算当前值。这个 pull 模型很适合"队列深度"这类值——应用不用记录每次变化,只在被观测时报告一下当前深度。
ObservableGauge<T> / ObservableCounter<T> / ObservableUpDownCounter<T> 的回调可以返回三种形式:T、一个 Measurement<T>、或者一个 IEnumerable<Measurement<T>>。Measurement<T> 是"值 + 关联标签"的配对,可枚举形式让一次回调报告多个带标签的值。但前两种形式本质上永远只产生一个测量值。
.NET 10 里,ObservableInstrument<T> 仍然把这两种单值形式规范化成可枚举模型:每次监听器采集时,调用回调、把结果塞进一个新的单元素 Measurement<T>[]、再枚举这个数组来上报。
dotnet/runtime#128039(来自 @unsafePtr)让 .NET 11 识别出这两种内建的单值形式,直接把结果送给 MeterListener.NotifyMeasurement,数组和枚举全省了。可枚举形式保持原路径不变——因为那个序列归应用所有,里面合法地可能有任意多个测量值,运行时不能替你假设。
| 方法 | 运行时 | 平均值 | 比值 | 分配 | 分配比值 |
|---|---|---|---|---|---|
| Record | .NET 10.0 | 17.16 ns | 1.00 | 72 B | 1.00 |
| Record | .NET 11.0 | 4.511 ns | 0.26 | – | 0 |
快了近 4 倍,并且分配从 72 B 直接降到 0。 指标采集通常是定时轮询的,单次 72 字节看着不起眼,但乘上"仪器数 × 采集频率 × 运行时长",就是一笔持续的 GC 噪音。现在没了。
MemoryCache:干掉性能计数器里的 false sharing
先解释一下 false sharing(伪共享),这是历年 .NET 性能博文里的常客。现代处理器在内存和缓存之间以固定大小的块(cache line,常见 64 字节)搬运数据。一个核心要写某个位置,必须先独占包含该位置的 cache line,同时让其他核心持有的同一行副本失效。
哪怕两个核心写的根本不是同一个值,这也会出问题。 想象内存里挨着的两个 long 字段,核心 A 反复更新第一个,核心 B 反复更新第二个。两个字段逻辑上毫无关系,但只要它们落在同一条 cache line 上,A 的每次写都会让 B 的副本失效,反之亦然。这一行的所有权就在两个核心之间来回弹,扩展性被死死卡住——明明没有任何共享,却付出了共享的代价,所以叫"伪共享"。
System.Runtime.Caching.MemoryCache 维护着一堆性能计数器:gets、hits、misses、adds、removes、trims 等等。.NET 10 里,这些计数器被存成一个小 long[] 的元素。于是数组头(含长度)和好几个互不相关的计数器,全都挤在同一条 cache line 上。高负载下,执行不同缓存操作的核心在更新不同计数器时,却在争抢同一条 cache line 的所有权。而且通过数组访问计数器,还额外要加载一次数组长度做边界检查。
dotnet/runtime#131470 的做法是:把数组换成具名字段,并把这些字段排布到不同的 cache line 上。同一个操作天然会一起更新的计数器可以留在一起,互不相关的则强行分开。这是故意多花一点内存做 padding,换取争用下的 cache line 不再来回弹;具名字段顺带也省掉了数组边界检查。
benchmark 用 32 个线程并发执行 256,000 次 Get:
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| Get | .NET 10.0 | 75.83 ns | 1.00 | – |
| Get | .NET 11.0 | 51.76 ns | 0.68 | – |
32 线程下快了 32%。 这类优化的典型特征是:单线程几乎看不出变化,线程越多、核越多,差距越大。
Logging:JSON 日志复用线程级缓冲区
日志是又一条 per-event 的诊断路径。
Microsoft.Extensions.Logging 的 EventSource provider 在 JsonMessage keyword 打开时,成本被削下来了一截。dotnet/runtime#131229 让 EventSourceLogger.ToJson 复用一个 [ThreadStatic] 的 MemoryStream 和 Utf8JsonWriter,每条日志事件上这两处分配都省掉了,剩下的主要就是返回的那个 JSON 字符串本身。超过 1 KB 的缓冲区不会被保留在线程上(避免长期占住大块内存)。
| 方法 | 运行时 | 平均值 | 比值 | 分配 | 分配比值 |
|---|---|---|---|---|---|
| Log | .NET 10.0 | 506.4 ns | 1.00 | 1.92 KB | 1.00 |
| Log | .NET 11.0 | 400.5 ns | 0.79 | 1.15 KB | 0.60 |
每条日志快 21%,分配降 40%。 日志这种东西,量大到你根本不会去数它——但正因为量大,每条省 0.77 KB,累积起来非常可观。
Cryptography
ValueAsnReader:让 ASN.1 解析不再层层包对象
背景先说清楚。ASN.1 是证书、公私钥以及众多密码学结构使用的二进制数据描述格式。 它的编码是嵌套的:读一个 sequence,会得到一个覆盖该 sequence 内容的新 reader,而这个内容里可能还有更多 sequence。
问题就在这:每嵌套一层,就要新建一个 reader 对象包住同一批字节。深一点的证书结构,解析过程中会产生大量短命对象。
dotnet/runtime#125254 新增了 ValueAsnReader——基于 span 的 ref struct,是 AsnReader 的对应物。既然是 ref struct,它只能活在栈上,可以安全地持有对原始数据的视图(Span<byte>),不需要在堆上分配,也能按引用传递而不复制。
配套的两个 PR:dotnet/runtime#125346 把这个表示应用到选定的 RSA、PKCS/CMS、ECC 和 X.509 解码器;dotnet/runtime#125528 把它贯穿到生成的密钥加载器里,让各层解析能通过引用传递原始数据的视图,而不是反复把同一批字节包进新的 reader 对象。
benchmark 解析一个三层嵌套的 DER 结构(三组 sequence,各含一个 integer),两种写法一一对应:
| 方法 | 运行时 | 平均值 | 比值 | 分配 | 分配比值 |
|---|---|---|---|---|---|
| ReadWithEscapingAsnReader | .NET 11.0 | 79.81 ns | 1.00 | 240 B | 1.00 |
| ReadWithValueAsnReader | .NET 11.0 | 31.05 ns | 0.39 | – | 0.00 |
同样的结构,用 ValueAsnReader 只要 39% 的时间,且零分配(240 B 降到 0)。注意这个对比是在同一个 .NET 11 上跑的——也就是说这是新 API 带来的增益,不是版本升级的增益。
ASN.1 字符串:把校验和转码向量化
ASN.1 定义了好几种字符集受限的文本类型。比如 IA5String 就是 ASCII,VisibleString 只允许从空格到 ~ 的可打印 ASCII 字符。编码或解码这类值时,既要拷贝数据,又要拒绝超出允许范围的字符——两件事都得逐字符做。
dotnet/runtime#131109 把 IA5String 和 VisibleString 的校验和转码都向量化了,一次检查和拷贝多个字符。dotnet/runtime#131170 把同样的思路应用到大端 UCS-2 的 BMPString。
benchmark 用 1024 个 'A' 的文本(最规整、最能吃满向量宽度的输入):
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| Read(VisibleString) | .NET 10.0 | 1.243 μs | 1.00 | – |
| Read(VisibleString) | .NET 11.0 | 112.7 ns | 0.091 | – |
| Write(VisibleString) | .NET 10.0 | 1.556 μs | 1.00 | – |
| Write(VisibleString) | .NET 11.0 | 152.8 ns | 0.098 | – |
读快了约 11 倍,写快了约 10 倍。 这种量级的提升,来源就是"一次一个字符"变成"一次一批字符"。
dotnet/runtime#131616 更进一步,把方案推广到字符集不连续的 PrintableString 和 NumericString。这类校验有不止一个可接受区间,但依然可以一次对整个向量做分类,只在必要处回退到标量检查。
| 方法 | 运行时 | 平均值 | 比值 | 分配 |
|---|---|---|---|---|
| Read(PrintableString) | .NET 10.0 | 1.242 μs | 1.00 | – |
| Read(PrintableString) | .NET 11.0 | 263.9 ns | 0.21 | – |
| Write(PrintableString) | .NET 10.0 | 1.555 μs | 1.00 | – |
| Write(PrintableString) | .NET 11.0 | 428.3 ns | 0.28 | – |
读快了近 5 倍,写快了 3.6 倍。 比连续字符集那两个涨幅小一些,这很合理——多区间分类的向量逻辑更复杂,回退到标量的情况也更多。
SHA-1 one-shot:AssemblyName.GetPublicKeyToken
当所有输入一次性可用时,哈希就没必要保留可复用的状态对象。
SHA-1 已经不再适合用于签名新内容这类安全决策,但 .NET 为了兼容性标识符仍然需要它——最典型的就是程序集的 public key token(公钥令牌,即公钥 SHA-1 哈希的末 8 字节反序,是强命名程序集标识的一部分)。既然只是算个标识,不涉及秘密,就可以大幅简化实现。
dotnet/runtime#120674 给内部实现加了一条 one-shot 路径:哈希状态、工作区和 padding 缓冲区全部放在栈上。AssemblyName.GetPublicKeyToken() 现在就用这条路径,因为它手上有完整的公钥,一次操作就能算完。
| 方法 | 运行时 | 平均值 | 比值 | 分配 | 分配比值 |
|---|---|---|---|---|---|
| GetPublicKeyToken | .NET 10.0 | 1.458 μs | 1.00 | 664 B | 1.00 |
| GetPublicKeyToken | .NET 11.0 | 721.4 ns | 0.49 | 296 B | 0.45 |
快了 2 倍,分配减少 55%。 剩下的 296 B 主要是返回的 token 数组本身。
AES 密钥封装:一次创建 cipher,整轮复用
AES key wrap 用来在存储或传输密钥之前先把它加密起来。 封装或解封一个密钥,需要反复应用 AES 很多次。
.NET 10 在 Windows 和 Apple 平台上,每一步都走一个通用辅助方法:创建一个 native AES cipher → 处理一个块 → 销毁这个 cipher。外层的 Aes 对象本身可以复用,但内部一次 key wrap 操作仍然在反复做这套 native 的创建和清理,而且重复次数随密钥材料的大小增长——材料越大,重复越多。
dotnet/runtime#129921(来自 @vcsjones)把 Windows 实现改成创建一个 native cipher,整个 wrap/unwrap 操作复用它;dotnet/runtime#129911 对 Apple 的实现做了同样的事。
benchmark 用固定的 AES-256 密钥封装 4096 字节明文:
| 方法 | 运行时 | 平均值 | 比值 | 分配 | 分配比值 |
|---|---|---|---|---|---|
| EncryptKeyWrapPadded | .NET 10.0 | 2.497 ms | 1.00 | 264 KB | 1.00 |
| EncryptKeyWrapPadded | .NET 11.0 | 111.3 μs | 0.045 | 88 B | 0.00033 |
| DecryptKeyWrapPadded | .NET 10.0 | 2.473 ms | 1.00 | 264 KB | 1.00 |
| DecryptKeyWrapPadded | .NET 11.0 | 123.5 μs | 0.050 | 88 B | 0.00033 |
封装从 2.497 ms 降到 111.3 μs,快了 22 倍;解封快了 20 倍;分配从 264 KB 降到 88 B——只剩原来的 0.033%。 这是本段最狠的一个数字,也很好理解:省掉的是"成百上千次 native cipher 的创建与销毁"。
证书吊销检查:CRL 缓存与 AIA 下载去重
证书验证的耗时,有时并不在签名数学上,而在周边流程。
比如在 Linux 上做吊销检查时,下载的证书吊销列表(CRL)会被持久化到磁盘。之后的链构建因此可以省掉网络请求,但仍然需要:打开文件、读取、解析编码后的 CRL、并创建一个新的 native 句柄。
dotnet/runtime#123562 在 .NET 11 里加了一个有界的、内存中的已解析 CRL 缓存。重复查找可以直接复用 native CRL 句柄;同时用 LRU 淘汰 + GC 辅助老化防止缓存无限增长、把条目永久留住。
AIA(Authority Information Access,权威信息访问) 是相关问题:证书里可以给出一个 URL,用于下载缺失的颁发者证书;而多个并发的链构建可能同时发现同一个缺失的颁发者,于是各自发起一次下载。
dotnet/runtime#130456 复用了上面那套缓存基础设施,让这些链构建共享同一次异步下载,不再发重复请求。失败的下载不进缓存;旧的成功响应在后台刷新;Linux 上现在也把每次链构建的 AIA 下载限制为两次,与 Windows 对齐,从而给"一条链能触发多少网络工作"设了上界。
写在最后
原文最后,Stephen Toub 用了一个很妙的双关:"几百项性能改进之后,.NET 11 确实是 one louder(音量又大了一格)"——致敬《This Is Spinal Tap》里那个把音量旋钮刻到 11 的经典梗。
他的态度也很明确,值得原样传达给读者:如果这篇文章里任何一个例子长得像你自己应用里的代码,请下载最新的 .NET 11 release candidate,去测你自己的负载。变快了,我们很想听;变慢了,我们也一样想听。 如果他漏掉了什么、或者你对 .NET 12 还能怎么"再拧一格"有想法,团队同样是"we're all ears"(洗耳恭听)。
而站在 .NET 开发者的角度,这里最值得强调的一点其实是:上面所有的优化,几乎都不需要你改一行业务代码。 没有新 API 要学(除了可选的 ValueAsnReader),没有开关要打开,没有配置项要调。把目标框架升到 .NET 11,重新编译部署,这些改进就自动生效了——升级即白拿。
当然,"平均变快"不等于"你的热点一定变快"。不同应用的热点路径差异极大,所以给中文读者一个实操建议:升级之后,用 BenchmarkDotNet 把你自己的关键路径复测一遍。原文里几乎每个 benchmark 都附带了完整可运行的代码和命令行(dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0 这个双运行时对比的写法尤其好用),你可以照着这个模板,把自己的序列化、进程启动、日志、指标采集、证书校验等路径各写一个小 benchmark,跑一遍 net10.0 vs net11.0。这样你拿到的不是别人的平均数,而是你自己这份负载的真实收益——如果真的发现某条路径变慢了,那也正好是给 .NET 团队提 issue 的最好材料。
Happy coding!
原文链接:Performance Improvements in .NET 11
本文为中文整理编译版,数据与结论均来自微软官方博文,作者 Stephen Toub。所有 PR 编号均已转为 GitHub 链接,建议顺着点进去看原始讨论与 reviewer 的实现细节——那才是真正的学习材料。
如果你读完打算动手,先别急着改代码:把项目升到 .NET 11,用同样的负载跑一遍你自己的基准,看看哪些优化真的命中了你的热路径。绝大多数时候,答案会是「什么都不用做」。
欢迎大家扫描下面二维码成为我的客户,扶你上云


浙公网安备 33010602011771号