.NET 11 性能改进
摘要
.NET 博客文章详细介绍了 .NET 11 框架中的众多性能改进,重点强调了运行时和库的优化,以提升速度和效率。
<p>正如 .NET 即将发布新版本的传统,Stephen Toub 撰写了一篇长文,详细介绍了这对性能改进的意义。</p>
<p><a href="https://lobste.rs/s/udwhz2/performance_improvements_net_11">评论</a></p>
查看缓存全文
缓存时间: 2026/09/15 19:16
# .NET 11 性能改进 - .NET 博客
来源:https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-11/
在像《办公室》(The Office)和《公园与游憩》(Parks and Recreation)这样的电视剧将伪纪录片风格深植于数百万观众心中之前,有克里斯托弗·格斯特(Christopher Guest)。他并非这一类型的开创者,但被广泛认为是对其影响最大的实践者之一,而且在我看来,无人能出其右。《等待古uffman》(Waiting for Guffman)和《最佳表现》(Best in Show)我看了无数遍。但最让我念念不忘、稍受触动就会引用的,是《摇滚万万岁》(This Is Spinal Tap)。如果你看过,你就知道接下来要说什么了(如果没看,你现在的周末有安排了)。这部电影讲述了一支名为 Spinal Tap 的虚构英国老摇滚乐队的纪录片,其成员正是我们心中摇滚巨星浮夸形象的写照。在一个令人难忘的场景中,吉他手(Nigel)带电影制作人(Marty)参观他最珍视的设备,尤其展示了一台与众不同的放大器:它的旋钮不止于十。这引出了全片最著名的对话之一:
> **Nigel:**“你看,大多数家伙,你知道,会调到十。你这里调到十,拧到头,拧到头,拧到头,你吉他调到十。从那里你还能去哪儿?去哪儿?”
**Marty:**“我不知道。”
**Nigel:**“哪儿也去不了。正是如此。我们要做的是,如果我们需要额外推力越过悬崖,你知道我们怎么做吗?”
**Marty:**“调到十一?”
**Nigel:**“十一。正是如此。响一点。”
这便是 .NET 11。它响了一点,又经过一年的性能优化,让运行时和库变得更快了。当然,Nigel 特制放大器的前提是荒谬的,正如随后的几句对话所体现的:
> **Marty:**“为什么不直接把十调得更响,让十成为最大数字,然后再响一点点?”
**Nigel:**(停顿)“……这些能到十一。”
相比之下,.NET 11 实际上是更高一级、更响一点。接下来的部分充满了真实的改进。移除了一个边界检查,一个不再发生的分配,一个不再获取的锁,一个比一年前运行周期更少的循环,这里一个比较被折叠为常量,那里一个冗余检查被提升到循环外,几条指令融合为一条,绕过了一次系统调用,一次数组复制交给了 SIMD,诸如此类。真实的性能优化就是这样进行的,积累一次又一次的改进,每一次都在前一次的基础上叠加,直到整个东西可测量、可证明地变得“更响”。
因此,在本文中,正如我过去在 .NET 10 (https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-10/)、.NET 9 (https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-9/)、.NET 8 (https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/)、.NET 7 (https://devblogs.microsoft.com/dotnet/performance_improvements_in_net_7/)、.NET 6 (https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-6)、.NET 5 (https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-5)、.NET Core 3.0 (https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-core-3-0)、.NET Core 2.1 (https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-core-2-1) 和更早的 .NET Core 2.0 (https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-core) 中做过的那样,我们将从容不迫地浏览数百项改进。这将是一篇长文。它就应该这么长。拿起你喜欢的热饮,坐好,让我们把音量调高。
## 基准测试设置
与往年一样,本文充满了展示各项改进的微基准测试。它们几乎都使用了 BenchmarkDotNet (https://www.nuget.org/packages/BenchmarkDotNet),并且每个测试都编写为自包含的,以便你亲自尝试。首先确保安装了 .NET 10 (https://dotnet.microsoft.com/download/dotnet/10.0) 和 .NET 11 (https://dotnet.microsoft.com/download/dotnet/11.0)(大多数基准测试比较的是相同代码在两个版本上的运行情况),并在一个全新的 `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>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.13.*" />
</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),因此让我们从那里开始。
## JIT
在改进 .NET 性能的所有领域中,很少有像即时(JIT)编译器这样具有如此广泛影响的了。C#、F# 和 Visual Basic 通常先编译为中间语言(IL),而 JIT 最终将该 IL 转换为 CPU 执行的本机指令。因此,JIT 改进可以惠及应用程序和库代码中出现优化模式的任何地方,通常无需更改源代码或重新编译应用程序本身。即使移除一条指令或证明一个检查是不必要的,当代码处于非常热的路径上时,这些累积起来也能产生显著效果。
### 消除抽象
作为开发者,我们热爱我们的抽象。它们让我们能够编写干净、可重用、面向对象的代码,但我不希望为运行时的每一个抽象付出代价。当运行时证明其效果不可观察时,它可以撤销一个抽象。它可以查看一个虚调用并确定它将调用的具体方法,查看一个堆分配并识别该对象从未离开当前堆栈帧,或查看一个接口转换并重用该方法早期已建立的类型事实。这个过程被称为“消除抽象”。.NET 多年来在这个领域持续改进,这在 .NET 11 中继续进行。
每当你在 C# 中编写 `interface` 时,你都在创建一个契约,一个承诺:任何实现该接口的类型都可以替代任何其他类型。这种灵活性非常有价值,因为例如,它使我们能够编写 `IEnumerable`,让它可以同样好地在数组、列表、其他集合、LINQ、自定义迭代器等上工作。但 CPU 对这些契约一无所知;它只知道如何执行指令。将“调用此接口引用指向的任何方法”转换为实际的机器指令需要特殊的机制。
考虑这个示例:
```csharp
// dotnet run -c Release -f net11.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
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 对象的开头,在方法表的已知槽位 `Speak` 处进行索引,并调用在那里找到的函数指针:
```assembly
; 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 可以看透层层抽象。作为 .NET 开发者,我们持续依赖 JIT 复杂的内联启发式方法,该方法权衡被调用者的 IL 大小、被调用者执行的具体工作、方法的调用频率、来自常量参数的预期收益以及数十个其他因素。对于虚调用,JIT 需要知道调用的实际目标是什么;它需要“去虚化”。在某些情况下,它可以静态地确定这一点,即它拥有精确的类型知识。例如,如果 JIT 能证明 `animal` 始终是 `Dog`,无论它是刚刚用 `new Dog()` 分配的:
```csharp
Animal animal = GetSomeAnimal();
animal.Speak();
...
static Animal GetSomeAnimal() => new Dog(); // 可内联
```
或者因为变量的类型是密封类:
```csharp
Dog animal = GetSomeAnimal();
animal.Speak();
...
sealed class Dog { ... } // animal 不可能是 Dog 以外的类型
```
或者使用 NativeAOT 和全程序编译,如果它看到 `Animal` 是抽象的,并且整个应用程序中唯一派生自 `Animal` 的类型是 `Dog`:
```csharp
Animal animal = GetSomeAnimal();
animal.Speak();
...
abstract class Animal { ... }
class Dog : Animal { ... } // 没有其他这样的派生类型
```
或者其他此类验证,它可以发出对 `Dog.Speak()` 的直接调用,然后内联器可以尝试。但对于其他无法通过静态分析证明的情况,JIT 转向配置文件引导优化(PGO)。PGO 听起来花哨,但概念上很简单。通过“分层编译”,当一个方法第一次被调用时,它可以用很少或没有优化来“即时”编译(这被称为 Tier 0)。JIT 可以在此次编译中包含额外的探针(想想“printf 调试”),让它跟踪关于代码性质的一堆有趣信息,记录实际运行时发生的情况:哪些分支被采用,在虚调用点或转换尝试处出现了什么具体类型,等等。如果该方法被调用足够多次或循环足够多次,运行时可以要求 JIT 生成一个新的优化版本(称为 Tier 1)。该编译然后可以整合在该配置文件收集到的所有学习成果。当然,JIT 仍然需要生成始终正确的代码。即使动态配置文件说 `animal` 100% 的时间是 `Dog`,也不能保证它将来总是 `Dog`;可能是前 1000 次调用传入了一个 `Dog`,但第 1001 次调用将传入一个 `Dolphin`。那么 JIT 如何整合这个学习成果呢?通过发出一个运行时检查。`Dog` 路径可以获得一个直接调用,这可能随后是可内联的,而其他路径保留原始的虚调用作为后备。速度来自于使常见情况变得微小,而正确性来自于保留不常见情况。
```csharp
// JIT 大约生成的代码
if (animal?.GetType() == typeof(Dog))
{
((Dog)animal).Speak(); // 去虚化,可内联
}
else
{
animal.Speak(); // 原始虚调用,希望很少发生
}
```
这种“猜测并验证”的模式被称为“保护去虚化”(GDV),它在实际工作负载中带来了许多最大的吞吐量收益。它不仅适用于虚分派,也适用于接口分派,后者实际上比虚分派更昂贵一些,因为一个类型可以实现任意数量的接口,这意味着接口槽位并不简单地映射到固定的虚表位置。
消除抽象还可以在揭示涉及何种对象时使对象创建更高效。通常,.NET 中的对象分配在垃圾收集堆上,由垃圾收集器(GC)跟踪,并在不再可访问时被收集。堆分配通常很快,通常有效地只是移动指针。然而,当没有足够的空间来移动指针时,它会变得昂贵得多,包括可能需要进行垃圾收集。每个分配的对象实际上也承担了所有收集的摊销成本,因为每个分配的对象最终都需要被清理。
“逃逸分析”是一种编译器技术,它让我们可以询问这个对象是否“逃逸”了当前方法。如果一个新分配对象的引用可以证明不逃逸,那么 JIT 可以更有效地分配它。它不必将其存储在 GC 堆上,因为没有任何东西可能需要再次引用该对象,因此它可以在栈上分配该对象,使分配和清理本质上都是免费的。栈分配比堆指针移动分配更快;它只是递减栈指针,而栈指针通常已经在寄存器中。更重要的是,这意味着零 GC 影响,因为栈帧在函数返回时会被原子地释放。
JIT 在过去的几个 .NET 版本中一直在逐步扩展逃逸分析,.NET 9 和 10 在栈分配委托和闭包、`Nullable` 临时变量以及小型辅助对象方面进行了重大投资。关键主题是,每一次错误的肯定逃逸,每一次 JIT 错误地得出对象可能逃逸而实际上并未逃逸的结论,都代表了一次本可避免的堆分配,我们希望削减这个错误肯定列表。在 .NET 11 中,JIT 通过多种方式精简了该列表。我们将从可空装箱开始。考虑这个基准测试:
```csharp
// 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) where T : IFormattable
{
if (value is IFormattable formattable)
return formattable.ToString(null, null);
return value?.ToString();
}
}
```
相似文章
微软正在启用可能损害游戏性能的 Windows 11 安全功能
微软正在向更多设备推出 Windows 11 的内存完整性安全功能,该功能增强了安全性,但可能降低游戏性能,尤其是在较旧的硬件上。
改进 C# 内存安全
微软宣布对 C# 16 中的 unsafe 关键字进行重新设计,以强制执行内存安全契约,使 unsafe 操作变得可见并由编译器强制执行,预览版将在 .NET 11 中发布,正式版在 .NET 12 中发布。
EF Core 11 让拆分查询更快
EF Core 11 通过移除集合查询中不必要的引用导航 JOIN,优化拆分查询,从而减少数据库开销并提升性能。
微软面向开发者的新版Windows进一步拥抱Linux
微软在Build大会上宣布了面向开发者优化的Windows 11体验,包括Windows版Coreutils(原生类Linux工具)、WSL容器(便于管理Linux容器)、集成AI的实验性智能终端,以及可快速配置的Windows开发者配置。
软件没有理由再慢了
该文章指出,LLMs和AI工具正在降低软件性能优化的门槛,使得之前因成本过高而无法实施的自定义适配(如JIT编译器和正则表达式引擎)成为可能。