.NET 11 性能提升解析
在《办公室》和《公园与游憩》等电视节目将伪纪录片形式深植于数百万观众脑海之前,克里斯托弗·格斯特早已声名鹊起。他虽非该类型的开创者,却公认是其中最具影响力的践行者之一,依我之见,无人能出其右。我观看《等待古夫曼》和《最佳影片》的次数多到数不胜数,但烙印最深、最易引发我脱口而出名台词的,当属《摇滚万岁》。
若你看过这部片子,想必已猜到我接下里的走向(若没看过,这下周末有着落了)。影片是一部虚构纪录片,讲述了一支名为“摇滚万岁”的英国老牌摇滚乐队,其成员集大众心中浮夸摇滚巨星的典型特质于一身。在片中令人难忘的一幕里,吉他手奈杰尔向导演马蒂展示他最珍爱的设备,特别炫耀了一台与众不同音箱:它的旋钮不止于十。这就引出了整部影片中或许被引用次数最多的一段对话:
奈杰尔:“你看,大多数人,你知道的,都把音量开到十。这里你开到十,一直开到顶,开到顶,再开到顶,你的吉他音量全是十。还能往哪儿加?往哪儿?”
马蒂:“我不知道。”
奈杰尔:“哪儿也没了。没错。我们要做的是,如果需要那临门一脚的爆发力,你知道我们怎么做?”
马蒂:“开到十一?”
奈杰尔:“十一。没错。再响一分。”
这就是 .NET 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 时一样,带大家从容地梳理其中的数百项改进。
这篇文章很长,这是有意的。请端上你热爱的饮品,安顿好自己,我们开始吧。
基准测试设置
和往年一样,本文包含大量微基准测试,用于展示各项单独的性能改进。几乎全部基于 BenchmarkDotNet 编写,且每个测试都是独立的,方便你自行复现。
首先,请确保已安装 .NET 10 和 .NET 11(大多数基准测试都对比同一份代码在两个版本上的表现),并在新的 benchmarks 目录中创建一个控制台项目:
dotnet new console -o benchmarks
cd benchmarks
把生成的 benchmarks.csproj 内容替换成下面这样,它同时面向两个版本进行多目标编译,让 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 "*"
照例声明一下:这些都是微基准测试,很多操作短到眨个眼就错过了。结果会因你的硬件、操作系统、运行时配置、机器当时恰好还在忙什么,甚至水星是否逆行而有所不同。
每一行托管代码最终都要经过即时编译器,那我们就从这里讲起。
JIT
若要提升 .NET 的性能,没有哪个环节的影响比即时(JIT)编译器更广泛。C#、F# 和 Visual Basic 通常先被编译为中间语言(IL),随后 JIT 将其转换为 CPU 执行的原生指令。因此,JIT 的优化收益可惠及任何存在该优化模式的应用程序和库代码,且通常无需修改源码或重新编译应用。即使只是删除一条指令,或证明某项检查是多余的,当代码位于极热的执行路径上时,这些微小改进也会累积出显著效果。
去抽象化
作为开发者,我们偏爱各种抽象。它们让代码写得干净、可复用且面向对象,但我们不希望为每次运行时都支付这些抽象的代价。当运行时能证明某些抽象的效果不可观测时,往往可以将其撤销。例如,它可以分析虚拟调用以确定具体调用的方法,识别出堆分配的对象从未离开当前堆栈帧,或在查看接口转换时复用方法早前已建立的类型事实。这一过程被称为“去抽象化”。多年来,.NET 在此领域的改进一直在稳步进行,.NET 11 也延续了这一趋势。
每当你在 C# 中编写 interface,就是在建立一份契约,承诺任何实现该接口的类型都可替代其他类型。这种灵活性极具价值,例如它让我们能够编写 IEnumerable<T>,并使其在数组、列表、其他集合、LINQ、自定义迭代器等场景下同样高效工作。但 CPU 并不理解这些契约,它只懂得执行指令。将“调用该接口引用所指向的任意方法”转化为实际的机器指令,需要专门的机制。请考虑以下示例:
// dotnet run -c Release -f net11.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private Animal _animal = Environment.TickCount >= 0 ? new Dog() : new Cat();
[Benchmark]
public int Speak() => _animal.Speak();
public abstract class Animal
{
public abstract int Speak();
}
private sealed class Dog : Animal
{
[MethodImpl(MethodImplOptions.NoInlining)]
public override int Speak() => 1;
}
private sealed class Cat : Animal
{
[MethodImpl(MethodImplOptions.NoInlining)]
public override int Speak() => 2;
}
}
在编译阶段,假设其他条件相同,JIT 无法确定 _animal 是 Dog 还是 Cat 类型。它生成的代码会从每个 .NET 对象开头的“方法表指针”(也称为“vtable 指针”,即对象类型句柄)中读取数据,根据 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 无法将 callee 内联。无论 Speak 做了什么,其代码都无法折叠到调用方方法中。
这就是性能问题所在。这些间接调用有开销,但更大的代价是丧失了内联的机会。内联不仅省去函数调用的开销,更重要的是它让被调用方的代码能享受和调用方一样的优化,比如常量传播、死代码消除、边界检查消除、进一步的去虚化等等。这意味着一连串看似无害的小型虚方法调用,在去虚化和内联之后,可能坍缩成寥寥几条指令,与原始源代码相比面目全非,成本也低得多。没有内联,每个被调用方都是一个黑盒;有了内联,JIT 就能看穿层层包装。
作为 .NET 开发者,我们一直在依赖 JIT 精巧的内联启发式策略,它会综合考量被调用方的 IL 大小、具体执行的工作、方法的调用频率、常量参数的预期收益,以及其他许多因素。对于虚方法调用,JIT 需要知道调用真正的目标是什么,也就是要进行“去虚化”。某些情况下,当它掌握了精确的类型信息时,可以静态地完成判断。例如,如果 JIT 能证明 animal 始终是一只 Dog,无论是因为它刚通过 new Dog() 分配:
Animal animal = GetSomeAnimal();
animal.Speak();
...
static Animal GetSomeAnimal() => new Dog(); // 可内联
还是因为该变量的类型是 sealed 类:
Dog animal = GetSomeAnimal();
animal.Speak();
...
sealed class Dog { ... } // `animal` 不可能不是 `Dog`
又或者在使用 NativeAOT 进行全程序编译时,JIT 看到 Animal 是抽象类,而整个应用中派生自 Animal 的类型只有 Dog:
Animal animal = GetSomeAnimal();
animal.Speak();
...
abstract class Animal { ... }
class Dog : Animal { ... } // 没有其他派生类型
或者其他类似的验证成立,JIT 就能直接发出对 Dog.Speak() 的调用,随后内联器便有机会大显身手。
但在其他静态分析无法证明的情况中,JIT 会转向基于剖面的优化(PGO)。PGO 听起来很玄乎,但概念上很简单。借助“分层编译”,当方法首次被调用时,可以用几乎零优化的方式“即时”编译(即 Tier 0)。JIT 在此编译中加入额外的探测点(类似“printf 调试”),以便追踪代码行为的各项关键信息,记录实际运行时发生的情况:哪些分支被执行,虚调用点或类型转换处出现的具体类型是什么,等等。如果该方法被调用足够多次数,或循环执行足够多轮,运行时就会要求 JIT 生成一个新的优化版本(即 Tier 1)。这次编译就能将剖面前期收集到的所有经验纳入考量。
当然,JIT 生成的代码必须永远正确。即便动态剖面显示 animal 在 100% 的情况下都是 Dog,也不能保证未来它始终是 Dog;可能前 1000 次调用传入的都是 Dog,而第 1001 次调用传入的是 Dolphin。JIT 该如何利用这些学习成果?通过生成运行时检查。Dog 路径可以使用直接调用,进而可能被内联;而其他路径则保留原有的虚调用作为后备。速度来源于让最常见情况的路径极度精简,正确性则来源于保留不常见情况的完整处理。
// 大致上 JIT 生成的代码
if (animal?.GetType() == typeof(Dog))
{
((Dog)animal).Speak(); // 非虚化调用,可内联
}
else
{
animal.Speak(); // 原始虚调用,理应较少发生
}
这种“猜测并验证”的模式被称为“受保护的直接调用”(GDV),在真实工作负载中贡献了许多最大的吞吐量提升。它不仅适用于虚方法分发,也适用于接口分发。后者开销更大,因为一个类型可以实现任意数量的接口,这意味着接口槽位无法简单映射到固定的虚函数表位置上。
非抽象化还能揭示对象的具体类型,从而使对象创建更高效。一般来说,.NET 中的对象分配在由垃圾回收器(GC)追踪的堆上,并在不再可达时被回收。堆分配通常很快,往往只是移动一个指针。但当可用空间不足以移动指针时,成本会急剧增加,甚至可能触发垃圾回收。此外,每个分配的对象都隐含承担了所有回收操作的摊销成本,因为每个对象最终都需要被清理。
“逃逸分析”是一种编译器技术,用于判断新创建的对象是否“逃逸”出当前方法。如果能证明新对象的引用不会逃逸,JIT 就能更高效地分配它。由于不可能有外部代码再次引用该对象,JIT 无需将其存储在 GC 堆上,而是将其分配在栈上,从而使分配和清理几乎零成本。栈分配甚至比堆上的指针移动更快,因为只需递减栈指针,而栈指针通常已寄存器中。更重要的是,这意味着对 GC 的影响为零,因为栈帧在函数返回时会被原子性地释放。
在过去几个 .NET 版本中,JIT 逐步扩展了逃逸分析,其中 .NET 9 和 10 在栈分配委托、闭包、Nullable<T> 临时变量以及小型辅助对象方面投入显著。核心思路是:每一次误判的逃逸(即 JIT 错误地认为对象会逃逸,而实际上不会)都意味着一次本可避免的堆分配,我们需要不断缩减这种误判列表。在 .NET 11 中,JIT 通过多种方式来削减这一列表。
我们先从可空值类型装箱说起。请看这个基准测试:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
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;
}
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| 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 字节装箱对象并未逃逸,于是彻底消除了这次堆分配。
枚举器的逃逸分析(Escape Analysis)借助一种称为条件逃逸分析(Conditional Escape Analysis, CEA)的机制得到了进一步改进。CEA 的支持在 .NET 10 中引入,而 .NET 11 扩展了该分析能够安全识别的模式集合。现有的逃逸分析旨在判断由分配产生的引用是否会流向 JIT 无法继续追踪的位置(例如未知的调用)。若有可能,该对象必须保留在堆上。这种分析必然较为保守,且很大程度上不具备流敏感性:如果对象在任何路径上都有可能被传入接口调用,分析器不会去证明包含该调用的路径与包含分配的路径是互斥的。
不幸的是,GDV 在优化针对 IEnumerable<T> 的 foreach 时恰恰会产生这种情况。如前所述,GDV 将接口调用转化为带有两个分支的类型检查:一个是针对可能集合类型的快速分支,另一个是包含原始接口调用的回退分支。沿快速分支进行的去虚拟化(Devirtualization)和内联通常会揭示出该集合类型的枚举器分配,而后续的枚举器守卫会保留诸如 IEnumerator<T>.MoveNext 之类的回退调用。现有分析器看到这些调用后,会得出局部分配的枚举器可能逃逸的结论。CEA 则会记录快速路径分配与后续守卫所测试的局部枚举器之间的关系。如果所有看似逃逸的情况都仅发生在类型检查失败之后,JIT 就可以将该区域克隆到一个热版本中,在该版本中这些检查已知会成功。在这个克隆体中,对象无法到达回退调用,因此可以进行栈分配,并往往提升为独立的标量局部变量。原始区域则保留为通用的慢速路径。
不过,.NET 10 尚无法处理一种情况:即 GetEnumerator() 的实现返回了另一个 GetEnumerator() 调用的结果。例如,转换为 IEnumerable<int> 的集合表达式会使用编译器生成的只读数组包装器,其结构正是如此:该包装器的 GetEnumerator() 委托给底层数组的 GetEnumerator。通过 dotnet/runtime#122946,.NET 11 中的 JIT 能够处理这种“链式调用”:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
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 item in s_readOnlyStatic) sum += item;
return sum;
}
[Benchmark]
public int ReadOnlyInstance()
{
int sum = 0;
foreach (int item in _readOnlyInstance) sum += item;
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 |
对于ReadOnlyStatic,JIT 编译器可以将static readonly 字段视为常量,该场景在 .NET 10 中已被优化。在 .NET 11 中,实例字段的场景也消除了 32 字节的枚举器分配,吞吐量提升至与静态字段相当的水平。
dotnet/runtime#121918 来自 @MichalPetryka,修复了另一种会让对象因取地址而被误判为逃逸的情况。IL 中的 constrained. 前缀让同一条泛型 callvirt 调用序列既能用于值类型也能用于引用类型:它可以避免对值类型装箱,而对引用类型则会解引用接收者并执行常规的虚方法分派。下面这个基准测试中,EqualityComparer<T>.Default 调用的 ObjectEqualityComparer<T>.Equals 就包含这样一个对 value.Equals(other) 的调用。此前的实现中,接收者是通过局部变量的地址做间接读取来表示的,而仅仅取这个地址就会把该局部变量标记为“已暴露”,导致新分配的 Value 无法被纳入栈分配的考量。现在接收者改为直接以值加载的方式表示,24 字节的堆分配随之消失。
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Collections.Generic;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static readonly Value s_other = new(42);
[Benchmark]
public bool Equals() => EqualityComparer<Value>.Default.Equals(new Value(42), s_other);
private sealed class Value(int value)
{
private readonly int _value = value;
public override bool Equals(object? obj) => obj is Value other && _value == other._value;
public override int GetHashCode() => _value;
}
}
| 方法 | 运行时 | 平均值 | 比率 | 分配 | 分配比率 |
|---|---|---|---|---|---|
| Equals | .NET 10.0 | 3.874 ns | 1.00 | 24 B | 1.00 |
| Equals | .NET 11.0 | 1.786 ns | 0.46 | – | 0 |
CEA 虽然能将非逃逸对象移出 GC 堆,但有时 JIT 还能更进一步,证明某项分配根本无需存在。泛型代码是这类优化机会的常见来源,其典型场景是装箱。以 ArgumentNullException.ThrowIfNull 方法为例,它接收一个 object value 参数。因此,当你调用如下方法时:
static void Test<T>(T value)
{
ArgumentNullException.ThrowIfNull(value);
...
}
若 T 被约束为非空值类型,传递 value 为 object 时就会发生装箱。ThrowIfNull 在 value 非 null 时实际上什么都不做(因为该方法的逻辑仅为 if (value is null) Throw();),此前的版本在优化代码中已成功消除这种装箱。但在 Tier 0 阶段,这一优化并未应用,ThrowIfNull 仍会触发分配。虽然这不会负面影响稳态吞吐量,却会在性能分析中产生不必要的干扰,并在启动阶段(此时该代码尚未晋升出 Tier 0)带来额外开销。在 .NET 11 中,dotnet/runtime#129392 让 Tier 0 阶段也支持了此项优化。
在虚方法分发方面,多个 PR 共同改善了泛型虚方法(GVM)的性能。来自 @hez2010 的 dotnet/runtime#120866 停止过早地将 ldvirtftn 的调用目标溢出到临时变量,并在允许的情况下让泛型虚方法的解析先于参数设置进行。随后,同一作者的 dotnet/runtime#122023 允许 JIT 对非共享 GVM 进行去虚化(devirtualize),并携带将间接调用转为直接调用所需的泛型上下文,从而可能进一步内联。而 dotnet/runtime#128702 将该支持扩展到共享 GVM 以及需要实例化桩(instantiating stub)的默认接口实现。尽管当新产生的直接调用被内联时,总代码量可能会增加,但这通常是期望的权衡:更多的实际计算工作将对优化器可见。请考虑以下基准测试:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
[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>();
}
}
将新分配的 Processor 实例强制转换为 IProcessor 会在 IL 层面产生一次接口泛型虚调用,但 JIT 现在能够识别接收者的确切类型,即使在共享 string 的场景下也是如此,这使得 .NET 11 能够对这两个调用进行去虚化并内联。进而,优化器将 Unsafe.SizeOf<T>() 识别为常量,并证明这个短命的 Processor 实例根本无需分配。
| Method | 运行时 | 平均值 | 比率 | 分配内存 | 分配比率 |
|---|---|---|---|---|---|
| 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 |
在此基础上,来自 @hez2010 的 dotnet/runtime#123183 让 ReadyToRun 编译能够解析并去虚化更多的非共享泛型虚调用(否则这些调用会保持间接形式),而同样来自 @hez2010 的 dotnet/runtime#130202 则把这一支持扩展到了 NativeAOT。NativeAOT 会把某些泛型虚目标表示为“胖指针”(这种指针不只是地址,通常包含地址加关联的元数据,在这里同时携带代码地址和泛型上下文);通过把这一转换推迟到精确类型去虚化有机会执行之后,JIT 就能把目标唯一且已知的接口调用点(针对非共享 GVM)转换为直接调用,进而有机会被内联。
类型信息还需要在 JIT 内部执行的变换过程中保留下来。如果 JIT 在重构表达式树时把某个引用表达式溢出到临时变量中,一旦丢失该表达式的精确类信息,原本可以去虚化的调用就会退化为不透明的虚调用。.NET 10 中就是这样:Value 被装箱,SetValue 通过 IValue 调用。来自 @hez2010 的 dotnet/runtime#128485 在临时变量上保留了类句柄和精确性信息。凭借仍然可用的这些信息,.NET 11 对该调用完成去虚化和内联,消除了装箱及其 24 字节的内存分配。
另外,dotnet/runtime#127433 放宽了内联器对 [Intrinsic] 类型(如 Span 和 Vector)被调用方的预算启发式规则。这类类型特意暴露了许多小而可组合的方法,作为通往 JIT 可识别操作的入口。如果这些包装方法保留为调用形式,调用方不仅要支付调用开销,还无法围绕其进行优化,因为优化器在此处看到了不透明的边界;若其被内联,导入器即可将其方法体替换为 intrinsic 节点,从而将该节点与周围的索引、边界检查及向量操作一并优化。因此,为这类包装方法提供更有利的预算,能促使更多方法保持可内联状态,让优化器接触到更多实际操作的底层逻辑。
委托(delegates)是 .NET 中实现抽象的核心机制之一:它允许我们传递代表可调用函数的对象,并携带相应的关联状态。去抽象化(Deabstraction)机制使得在某些场景下可以避免支付委托带来的开销。对于其余场景,我们依然希望这些委托尽可能轻量。dotnet/runtime#99200(由 @MichalPetryka 贡献)简化了 CoreCLR 的委托表示方式,从每个委托对象中移除了一个指针大小的字段,这在 64 位 CoreCLR 进程中为每个委托节省了 8 字节。此外,dotnet/runtime#129304(同样由 @MichalPetryka 贡献)通过重新排列现有四个字段,使相关值相邻,独立优化了 Native AOT 的委托布局。更新后的布局还让相等性检查和哈希码计算能更直接地访问所需的方法标识。
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
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();
private sealed class Target
{
public int GetValue() => 42;
}
}
| 方法 | 运行时 | 平均值 | 比率 | 分配内存 | 分配比率 |
|---|---|---|---|---|---|
| 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 | – | – |
来自dotnet/runtime#129410 的 @MichalPetryka 延续了 CoreCLR 的布局优化,将目标对象与方法指针相邻放置。由于在调用过程中这两者通常会被一起访问,这种相邻布局使得在 Arm64 等架构上可以进行成对加载(paired loads)。
运行时异步(Runtime Async)
十多年来,async 和 await 让我们能够写出与同步代码看起来几乎一样的异步代码:可以在 await 外套 try/catch,可以在前后使用局部变量,可以返回值,基本上可以按源码顺序理解方法的逻辑。但当执行遇到一个尚未完成的 await 时,方法不能原地留在当前栈帧里干等操作结束——线程需要被释放去做别的工作,而 await 之后的代码(包括后续需要的局部状态)必须保存在某个地方。在 C# 中,这项转换工作传统上由编译器负责。
我在《How async/await really works》一文中详细讲过这种转换的历史和原理。简单来说,编译器传统上会把 async 方法替换成一个小的入口方法,外加一个生成的状态机,其 MoveNext 方法包含转换后的用户代码。参数、需要在未完成的 await 之间存活的局部变量、被“腾出”的表达式中间值、awaiter、当前状态编号以及 method builder,都会变成堆上分配对象的字段。生成的 MoveNext 方法会执行用户代码,直到某个 awaiter 报告尚未完成。此时它会保存足够的信息以便知道从哪里、用什么值恢复执行,把 MoveNext 注册为 continuation,然后返回。当操作完成时,MoveNext 会被再次调用,根据保存的状态编号跳转到正确的位置(类似于 goto 加标签),从产生值的 awaiter 中取出结果,然后继续执行。如果所有 awaiter 都已完成,MoveNext 可以一路同步执行到底。当方法完成或抛出异常时,builder 会把结果、取消状态或异常写入返回的 Task、Task<T>、ValueTask 或 ValueTask<T>(极少数情况下是自定义的 task-like 类型)。
举个例子,看这个极简的方法:
static async Task<int> ReadLengthAsync(Stream stream, CancellationToken cancellationToken)
{
var buffer = new byte[4096];
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken);
return bytesRead;
}
虽然这段代码生成的结果会随时间变化,且在调试和 Release 构建中有所不同,但 C# 编译器的 lowering(降低/编译)过程大致如下:
[AsyncStateMachine(typeof(<ReadLengthAsync>d__0))]
static Task<int> ReadLengthAsync(Stream stream, CancellationToken cancellationToken)
{
<ReadLengthAsync>d__0 stateMachine = default;
stateMachine.builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine.state = -1;
stateMachine.stream = stream;
stateMachine.cancellationToken = cancellationToken;
stateMachine.builder.Start(ref stateMachine);
return stateMachine.builder.Task;
}
struct <ReadLengthAsync>d__0 : IAsyncStateMachine
{
public int state;
public AsyncTaskMethodBuilder<int> builder;
public Stream stream;
public CancellationToken cancellationToken;
private TaskAwaiter<int> awaiter;
public void MoveNext()
{
int result;
try
{
TaskAwaiter<int> localAwaiter;
if (state != 0)
{
byte[] buffer = new byte[4096];
localAwaiter = stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).GetAwaiter();
if (!localAwaiter.IsCompleted)
{
state = 0;
awaiter = localAwaiter;
builder.AwaitUnsafeOnCompleted(ref localAwaiter, ref this);
return;
}
}
else
{
localAwaiter = awaiter;
awaiter = default;
state = -1;
}
result = localAwaiter.GetResult();
}
catch (Exception e)
{
state = -2;
builder.SetException(e);
return;
}
state = -2;
builder.SetResult(result);
}
}
仅仅三行 C# 代码,编译器却生成了大量代码。在程序运行之前,编译器必须决定状态机结构、哪些值需要保持存活、需要多少 awaiter 字段,以及所有的挂起点如何在一个 MoveNext 调度中衔接。多年来,运行时和 JIT 已对最终生成的模式进行了深度优化,例如将 Task、状态机、延续和 ExecutionContext 合并为一次分配。然而,当 JIT 看到中间代码(IL)时,这些转换已经发生,留给它的是一套极为复杂、需要尽力优化的系统。
.NET 11 引入了重新划分这一职责的新方式,即被称为“运行时 async”的 async/await 基础设施重构。C# 编译器不再负责执行转换,而是由 JIT 接管。C# 编译器会为每个符合条件的 async 方法生成一个更小的、感知挂起点的 IL 契约,并在元数据中将该方法标记为 async。运行时和 JIT 随后处理那些依赖运行时知识的任务:创建对外可见的 Task 或 ValueTask,识别直接的 async 调用,决定每个挂起点上实际存活的值,布局延续对象,以及生成让方法挂起和恢复的控制流。本质上,转换逻辑从 C# 层移到了运行时,那里有更多优化空间的信息。
编程模型没有变化。这依然是 C# async/await;await 仍然遵循 awaiter 模式,异常和取消仍然通过返回的类 Task 对象暴露;ConfigureAwait 保持原有语义,同步完成依然是同步完成,依此类推。该功能的一个明确目标是实现 100% 的行为兼容性:一个 async 方法是由语言编译器还是由运行时进行降低(lowering)属于实现细节,任何可观察到的语义差异都视为 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# 编译器移到运行时还带来了一个额外好处:减小二进制体积。如前所述,传统 lowering 会为每个 async 方法生成一个入口方法、一个状态机类型、用于保存捕获状态的字段,以及 MoveNext 方法体。而 runtime async 留给运行时转换的方法体要小得多。下面这个极小的应用包含十个返回 Task<int> 的 async 方法,每个方法 await 下一个方法,并用同一份源码分别以编译器 lowering 和 runtime async 两种方式编译:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<AssemblyName>SizeProbe</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Features Condition="'$(RuntimeAsync)' == 'true'">$(Features);runtime-async=on</Features>
</PropertyGroup>
</Project>
// dotnet build -c Release -p:RuntimeAsync=false -o classic --no-incremental; dotnet build -c Release -p:RuntimeAsync=true -o runtime --no-incremental; Get-Item .\classic\SizeProbe.dll, .\runtime\SizeProbe.dll | Select-Object Directory, Length
Console.WriteLine(await Benchmarks.Layer0());
public class Benchmarks
{
public static async Task<int> Layer0() => await Layer1();
private static async Task<int> Layer1() => await Layer2();
private static async Task<int> Layer2() => await Layer3();
private static async Task<int> Layer3() => await Layer4();
private static async Task<int> Layer4() => await Layer5();
private static async Task<int> Layer5() => await Layer6();
private static async Task<int> Layer6() => await Layer7();
private static async Task<int> Layer7() => await Layer8();
private static async Task<int> Layer8() => await Layer9();
private static async Task<int> Layer9()
{
await Task.Yield();
return 42;
}
}
| Lowering | SizeProbe.dll | Ratio |
|---|---|---|
| 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。过去,C# 方法上的 async 关键字在编译时就会完全消除。现在,方法增加了一个新的 MethodImpl async 标志,在 IL 汇编指令中通过 async 修饰符体现,方法体则调用 System.Runtime.CompilerServices.AsyncHelpers 中的辅助方法。
乍看之下,ret 指令似乎不合常理:声明的方法签名返回 Task<int>,而 IL 求值栈上的实际值却是一个 int。这显然不是常规调用约定。VM 可以为 Task 返回型方法赋予两个相关联的身份,即两个 MethodDesc。其中一个对应托管代码其余部分看到的正常签名 Task<int> CallerAsync();另一个是 AsyncCall 变体,它实际上返回 int,并拥有一个用于延续(continuation)的隐式通道。两者指向同一个逻辑方法和元数据标记,但调用约定和职责各不相同。如果常规托管代码调用 CallerAsync,由 VM 生成的外层 thunk 会维持公共契约并返回 Task<int>。如果另一个运行时异步方法直接 await 它,JIT 可以转而调用 AsyncCall 变体,在调用同步完成时直接接收结果,或在挂起时接收延续。换言之,可以直接返回 T,从而避免分配 Task<T>。
这种配对机制在双向都有效。对于使用运行时异步编译的方法,AsyncCall 变体持有生成的(新版精简)IL,而公共的 Task 返回入口点则是一个适配器 thunk;对于传统编译的方法,公共方法持有其常规 IL,而 VM 可以为其创建一个 AsyncCall 适配器。这意味着运行时异步代码依然可以 await 现有库和由旧编译器生成的代码,这是实现 100% 兼容目标的关键能力。随着异步调用链中越来越多的部分采用运行时异步编译,性能收益会自然显现。
此时,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;
}
传统上,每个 async 方法都有编译器生成的独立状态机和各自的类 task 结果对象。C 挂起后最终完成其 task,唤醒 B 的状态机;B 随之完成自己的 task,再唤醒 A 的状态机;最后 A 完成调用方看到的根 task。多年来,为了降低这些对象和状态切换的开销,已经做了大量优化工作。
而在 runtime async 中,importer 能识别出“调用一个返回 Task 的方法,然后 await 这个 task”这种紧邻模式。在简单情况下,它可以直接调用被调方法的 AsyncCall 变体。当 yield 为 false、C 同步完成时,int 结果会以普通值的形式经由 B 和 A 一路传回,只有最外层边界才需要把它包装成调用方原本期待的 Task<int>。当 yield 为 true、C 挂起时,runtime 会为整条调用链链接 continuation 状态并最终恢复执行,无需在每个直接融合的边界上创建中间的 Task<int>。Task 契约并没有消失,只是被移到了真正需要 Task 的地方。
当然,runtime async 并不能让所有异步操作都做到零分配。它的作用是为 JIT 提供足够的信息,使其避免生成那些仅仅为了把结果从一个 async 方法直接传给下一个方法而存在的 task 对象。如果消费者把这个 task 存入集合、手动挂接 continuation,或以其他方式把它当作对象来观察,那么这个对象仍然需要存在。这个优化的核心是:不为那些实际上不可感知的边界买单。
仅仅两层调用,效果就已经很明显了:
// dotnet run -c Release -f net11.0 --filter "*"
// 项目还需要设置 `runtime-async=on` 功能开关。
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false)]
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static readonly Task<int> s_completed = Task.FromResult(42);
[Benchmark(Baseline = true), BenchmarkCategory("Completed")]
public Task<int> ClassicCompleted() => ClassicCompletedOuter();
[Benchmark, BenchmarkCategory("Completed")]
public Task<int> RuntimeCompleted() => RuntimeCompletedOuter();
[Benchmark(Baseline = true), BenchmarkCategory("Yielding")]
public Task<int> ClassicYielding() => ClassicYieldingOuter();
[Benchmark, BenchmarkCategory("Yielding")]
public Task<int> RuntimeYielding() => RuntimeYieldingOuter();
[RuntimeAsyncMethodGeneration(false)]
private static async Task<int> ClassicCompletedOuter() => await ClassicCompletedInner();
[RuntimeAsyncMethodGeneration(false)]
private static async Task<int> ClassicCompletedInner() => await s_completed;
private static async Task<int> RuntimeCompletedOuter() => await RuntimeCompletedInner();
private static async Task<int> RuntimeCompletedInner() => await s_completed;
[RuntimeAsyncMethodGeneration(false)]
private static async Task<int> ClassicYieldingOuter() => await ClassicYieldingInner();
[RuntimeAsyncMethodGeneration(false)]
private static async Task<int> ClassicYieldingInner()
{
await Task.Yield();
return 42;
}
private static async Task<int> RuntimeYieldingOuter() => await RuntimeYieldingInner();
private static async Task<int> RuntimeYieldingInner()
{
await Task.Yield();
return 42;
}
}
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Method)]
internal sealed class RuntimeAsyncMethodGenerationAttribute(bool runtimeAsync) : Attribute
{
public bool RuntimeAsync => runtimeAsync;
}
}
| 方法 | 平均值 | 比率 | 内存分配 | 分配比率 |
|---|---|---|---|---|
| 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、B 再调用异步方法 C 为例。C# 编译器为每个方法生成的转换代码会在 MoveNext 方法体周围包裹 try/catch 块,以便捕获未处理的异常并存入返回的 Task 中。假设 C 中的代码抛出了一个未处理异常,该异常会被自动生成的 catch 块捕获并存入返回给 B 的 Task 中;随后 B 中的 awaiter 从该 Task 对象中取出异常并抛出,接着又被 B 生成的 catch 块捕获并存入 B 自身的 Task,依此类推。因此,一个异常穿越 10 个这样的异步辅助方法时,会被抛出、捕获和存储 10 次,尽管源方法中并没有任何显式的异常处理逻辑,这一过程的开销极其高昂。而运行时原生异步模式不需要重新进入没有处理器的透传帧。在同步路径下,异常会像正常情况一样通过融合后的调用链进行回溯;在真实挂起之后,仅由一次分发循环捕获机制跳过那些没有处理器的 continuation 记录,最终一次性将失败状态标记到可观察的根 Task 上。
下面的基准测试测量了两种情况:完全同步地抛出异常,以及经过一次真实的 Task.Yield 挂起后再抛出异常。测试利用了一个编译器可识别的按方法生效的开关(RuntimeAsyncMethodGeneration),让经典异步方法和运行时异步方法跑在同一个进程、同一个 .NET 11 运行时上,唯一的区别是编译器对它们的降级方式。(注意这个特性是实验性的,并不是核心库公开的 API;和 C# 编译器认识的其他特性一样,它只是按名称和签名来识别。)
// dotnet run -c Release -f net11.0 --filter "*"
// The project also needs the `runtime-async=on` feature switch set.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
[Params(1, 10, 30)]
public int Depth;
[Params(false, true)]
public bool Yield;
[Benchmark(Baseline = true)]
public int Classic() => Invoke(ClassicThrowAsync(Depth));
[Benchmark]
public int Runtime() => Invoke(RuntimeThrowAsync(Depth));
private static int Invoke(Task<int> task)
{
try
{
return task.GetAwaiter().GetResult();
}
catch (InvalidOperationException)
{
return -1;
}
}
[RuntimeAsyncMethodGeneration(false)]
private async Task<int> ClassicThrowAsync(int depth)
{
if (depth == 0)
{
if (Yield) await Task.Yield();
throw new InvalidOperationException("uh oh");
}
return await ClassicThrowAsync(depth - 1);
}
private async Task<int> RuntimeThrowAsync(int depth)
{
if (depth == 0)
{
if (Yield) await Task.Yield();
throw new InvalidOperationException("uh oh");
}
return await RuntimeThrowAsync(depth - 1);
}
}
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Method)]
internal sealed class RuntimeAsyncMethodGenerationAttribute(bool runtimeAsync) : Attribute
{
public bool RuntimeAsync => runtimeAsync;
}
}