.NET 9 运行时中的新增功能
本文介绍了适用于 .NET 9 的 .NET 运行时中的新功能和性能改进。
文章目录
- 一、支持修剪的功能开关的属性模型
- 二、UnsafeAccessorAttribute 支持泛型参数
- 三、垃圾回收
- 四、控制流实施技术
- .NET 安装搜索行为
- 性能改进
- 循环优化
- 感应变量加宽
- Arm64 上的索引后寻址
- 强度降低
- 循环计数器可变方向
- 内联改进
- PGO 改进:类型检查和强制转换
- .NET 库中的 Arm64 矢量化
- Arm64 代码生成
- 更快的异常
- 代码布局
- 减少地址暴露
- AVX10v1 支持
- 硬件内部代码生成
- 浮点和 SIMD 运算的常量折叠
- Arm64 SVE 支持
- 盒子的对象堆栈分配
一、支持修剪的功能开关的属性模型
使用两个新属性可以定义 .NET 库(和您)可用于切换功能区域的功能开关。如果某个功能不受支持,则在使用本机 AOT 进行修剪或编译时,将删除不受支持(因此未使用)的功能,从而使应用程序大小更小。
- FeatureSwitchDefinitionAttribute 用于在剪裁时将 feature-switch 属性视为常量,并且可以删除由 switch 保护的死代码:
if (Feature.IsSupported)
Feature.Implementation();
public class Feature
{
[FeatureSwitchDefinition("Feature.IsSupported")]
internal static bool IsSupported => AppContext.TryGetSwitch("Feature.IsSupported", out bool isEnabled) ? isEnabled : true;
internal static Implementation() => ...;
}
When the app is trimmed with the following feature settings in the project file, Feature.IsSupported is treated as false, and Feature.Implementation code is removed.
在项目文件中使用以下功能设置修剪应用时,Feature.IsSupported 将被视为 false,并删除 Feature.Implementation 代码。
<ItemGroup>
<RuntimeHostConfigurationOption Include="Feature.IsSupported" Value="false" Trim="true" />
</ItemGroup>
- FeatureGuardAttribute 用于将功能开关属性视为使用 RequiresUnreferencedCodeAttribute、RequiresAssemblyFilesAttribute 或 RequiresDynamicCodeAttribute 批注的代码的守卫。例如:
if (Feature.IsSupported)
Feature.Implementation();
public class Feature
{
[FeatureGuard(typeof(RequiresDynamicCodeAttribute))]
internal static bool IsSupported => RuntimeFeature.IsDynamicCodeSupported;
[RequiresDynamicCode("Feature requires dynamic code support.")]
internal static Implementation() => ...; // Uses dynamic code
}
When built with true, the call to Feature.Implementation() doesn’t produce analyzer warning IL3050, and Feature.Implementation code is removed when publishing.
使用 true 生成时,对 Feature.Implementation() 的调用不会生成分析器警告 IL3050,并且在发布时会删除 Feature.Implementation 代码。
二、UnsafeAccessorAttribute 支持泛型参数
UnsafeAccessorAttribute 功能允许对调用方无法访问的类型成员进行不安全访问。此功能是在 .NET 8 中设计的,但在实现时不支持泛型参数。.NET 9 添加了对 CoreCLR 和本机 AOT 方案的泛型参数的支持。下面的代码显示了示例用法。
using System.Runtime.CompilerServices;
public class Class<T>
{
private T? _field;
private void M<U>(T t, U u) { }
}
class Accessors<V>
{
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_field")]
public extern static ref V GetSetPrivateField(Class<V> c);
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "M")]
public extern static void CallM<W>(Class<V> c, V v, W w);
}
internal class UnsafeAccessorExample
{
public void AccessGenericType(Class<int> c)
{
ref int f = ref Accessors<int>.GetSetPrivateField(c);
Accessors<int>.CallM<string>(c, 1, string.Empty);
}
}
三、垃圾回收
现在默认启用对应用程序大小的动态适应 (DATAS)。它旨在适应应用程序内存要求,这意味着应用程序堆大小应与长期数据大小大致成正比。DATAS 在 .NET 8 中作为可选功能引入,并在 .NET 9 中得到了显著更新和改进。
四、控制流实施技术
默认情况下,Windows 上的应用程序启用控制流强制技术 (CET)。它通过添加硬件强制堆栈保护来防止面向返回的编程 (ROP) 漏洞,从而显著提高安全性。这是最新的 .NET 运行时安全缓解措施。
CET 对启用 CET 的流程施加了一些限制,并可能导致较小的性能回归。有多种控件可以选择退出 CET。
.NET 安装搜索行为
现在可以配置 .NET 应用程序,使其应如何搜索 .NET 运行时。此功能可用于私有运行时安装,或用于更强地控制执行环境。
性能改进
对 .NET 9 进行了以下性能改进:
- 循环优化
- 内联改进
- PGO 改进:类型检查和强制转换
- .NET 库中的 Arm64 矢量化
- Arm64 代码生成
- 更快的异常
- 代码布局
- 减少地址暴露
- AVX10v1 支持
- 硬件内部代码生成
- 浮点和 SIMD 运算的常量折叠
- Arm64 SVE 支持
- 盒子的对象堆栈分配
循环优化
改进循环的代码生成是 .NET 9 的首要任务。现在提供以下改进:
- 感应变量加宽
- 索引后寻址
- 强度降低
- 循环计数器可变方向
归纳变量扩大和后索引寻址类似:它们都使用循环索引变量优化内存访问。但是,它们采用不同的方法,因为 Arm64 提供 CPU 功能,而 x64 不提供。由于 CPU/ISA 功能和需求的不同,为 x64 实施了归纳变量加宽。
感应变量加宽
64 位编译器具有一种称为归纳变量 (IV) 展宽的新优化。
IV 是一个变量,其值会随着包含循环的迭代而变化。在下面的 for 循环中,i 是一个 IV: for (int i = 0; i < 10; i++)。如果编译器可以分析 IV 的值在其循环迭代中如何演变,则它可以为相关表达式生成性能更高的代码。
请考虑以下循环访问数组的示例:
static int Sum(int[] nums)
{
int sum = 0;
for (int i = 0; i < nums.Length; i++)
{
sum += nums[i];
}
return sum;
}
索引变量 i 的大小为 4 字节。在程序集级别,64 位寄存器通常用于在 x64 上保存数组索引,在以前的 .NET 版本中,编译器生成的代码将 i 扩展到 8 个字节以进行数组访问,但继续将 i 视为其他位置的 4 字节整数。但是,将 i 扩展到 8 字节需要在 x64 上添加额外的指令。随着 IV 加宽,64 位 JIT 编译器现在在整个循环中将 i 加宽到 8 字节,省略了零扩展名。遍历数组非常常见,并且这种指令删除的好处很快就会增加。
Arm64 上的索引后寻址
索引变量经常用于读取内存的 Sequences 区域。考虑惯用的 for 循环:
static int Sum(int[] nums)
{
int sum = 0;
for (int i = 0; i < nums.Length; i++)
{
sum += nums[i];
}
return sum;
}
对于循环的每次迭代,索引变量 i 用于读取 nums 中的整数,然后 i 递增。在 Arm64 程序集中,这两个操作如下所示:
ldr w0, [x1]
add x1, x1, #4
ldr w0, [x1] 将 x1 中内存地址处的整数加载到 w0 中;这对应于源代码中 nums[i] 的访问。然后,加上 x1、x1、#4 将 x1 中的地址增加 4 个字节(整数的大小),移动到 nums 中的下一个整数。此指令对应于在每次迭代结束时执行的 i++ 操作。
Arm64 支持索引后寻址,其中“index”寄存器在使用其地址后自动递增。这意味着两条指令可以合并为一条,从而提高循环效率。CPU 只需要解码一条指令而不是两条指令,并且循环的代码现在对缓存更加友好。
更新后的程序集如下所示:
ldr w0, [x1], #0x04
末尾的 #0x04 表示 x1 中的地址在用于将整数加载到 w0 后递增 4 个字节。64 位编译器现在在生成 Arm64 代码时使用后索引寻址。
强度降低
强度降低是一种编译器优化,其中操作被替换为更快的、逻辑上等效的操作。此技术对于优化循环特别有用。考虑惯用的 for 循环:
static int Sum(int[] nums)
{
int sum = 0;
for (int i = 0; i < nums.Length; i++)
{
sum += nums[i];
}
return sum;
}
以下 x64 程序集代码显示了为循环正文生成的代码片段:
add ecx, dword ptr [rax+4*rdx+0x10]
inc edx
这些指令分别对应于表达式 sum += nums[i] 和 i++。rcx(ecx 保存此寄存器的低 32 位)包含 sum 的值,rax 包含 nums 的基址,rdx 包含 i 的值。要计算 nums[i] 的地址,请将 rdx 中的索引乘以 4(整数的大小)。然后将此偏移量添加到 rax 中的基址,加上一些填充。(读取 nums[i] 处的整数后,将其添加到 rcx 中,并且 rdx 中的索引递增。换句话说,每个数组访问都需要一个乘法和一个加法运算。
乘法比加法更昂贵,用后者代替前者是降低力量的典型动机。为避免在每次内存访问时计算元素的地址,您可以重写示例以使用指针而不是索引变量访问 nums 中的整数:
static int Sum2(Span<int> nums)
{
int sum = 0;
ref int p = ref MemoryMarshal.GetReference(nums);
ref int end = ref Unsafe.Add(ref p, nums.Length);
while (Unsafe.IsAddressLessThan(ref p, ref end))
{
sum += p;
p = ref Unsafe.Add(ref p, 1);
}
return sum;
}
源代码更复杂,但在逻辑上等同于初始实现。此外,程序集看起来更好:
add ecx, dword ptr [rdx]
add rdx, 4
rcx(ecx 保存此寄存器的低 32 位)仍然保存 sum 的值,但 rdx 现在保存 p 指向的地址,因此访问 nums 中的元素只需要我们取消引用 rdx。第一个示例中的所有乘法和加法都已替换为单个 add 指令,以将指针向前移动。
在 .NET 9 中,JIT 编译器会自动将第一个索引模式转换为第二个索引模式,而无需重写任何代码。
循环计数器可变方向
64 位编译器现在可以识别循环的 counter 变量何时仅用于控制迭代次数,并将循环转换为倒计时而不是向上。
在惯用的 for (int i = …) 模式中,counter 变量通常会增加。请考虑以下示例:
for (int i = 0; i < 100; i++)
{
DoSomething();
}
但是,在许多架构上,递减循环的计数器性能更高,如下所示:
for (int i = 100; i > 0; i--)
{
DoSomething();
}
对于第一个示例,编译器需要发出一条递增 i 的指令,然后发出一条执行 i < 100 比较的指令,如果条件仍然为真,则发出一个条件跳转以继续循环 — 总共有三条指令。但是,如果计数器的方向是颠倒的,则需要少一条指令。例如,在 x64 上,编译器可以使用 dec 指令来递减 i;当 I 达到 0 时,DEC 指令会设置一个 CPU 标志,该标志可以用作紧跟在 DEC 之后的跳转指令的条件。
代码大小的减少很小,但如果循环运行大量迭代,则性能改进可能会非常显著。
内联改进
其中之一。NET 对 JIT 编译器的内部内联的目标是尽可能多地消除阻止方法内联的限制。.NET 9 支持内联:
- 需要运行时查找的共享泛型。
例如,请考虑以下方法:
static bool Test<T>() => Callee<T>();
static bool Callee<T>() => typeof(T) == typeof(int);
当 T 是引用类型(如 string)时,运行时将创建共享泛型,这些泛型是 Test 和 Callee 的特殊实例,由所有 ref 类型 T 类型共享。为了实现此目的,运行时会构建将泛型类型映射到内部类型的字典。这些词典是按泛型类型(或按泛型方法)专用的,并在运行时访问以获取有关 T 和依赖于 T 的类型的信息。从历史上看,实时编译的代码只能针对根方法的字典执行这些运行时查找。这意味着 JIT 编译器无法将被调用方内联到 Test 中 — 即使两个方法都通过同一类型实例化,也被调用方的内联代码也无法访问正确的字典。
.NET 9 通过在被调用方中自由启用运行时类型查找来解除此限制,这意味着 JIT 编译器现在可以将被调用方等方法内联到 Test 中。
假设我们在另一个方法中调用 Test。在伪代码中,内联如下所示:
static bool Test<string>() => typeof(string) == typeof(int);
该类型检查可以在编译期间计算,因此最终代码如下所示:
static bool Test<string>() => false;
对 JIT 编译器内联的改进可能会对其他内联决策产生复合影响,从而显著提高性能。例如,内联 Callee 的决定可能使对 Test 的调用也被内联,依此类推。这产生了数百个基准测试改进,其中至少 80 个基准测试提高了 10% 或更多。
- 访问 Windows x64、Linux x64 和 Linux Arm64 上的线程本地静态。
对于静态类成员,该成员的一个实例正存在于该类的所有实例中,这些实例“共享”该成员。如果静态成员的值对于每个线程都是唯一的,则使该值为线程本地值可以提高性能,因为它无需并发基元即可从其包含的线程安全地访问静态成员。
以前,在本机 AOT 编译的程序中访问线程本地静态数据时,编译器需要向运行时发出调用,以获取线程本地存储的基址。现在,编译器可以内联这些调用,从而大大减少访问此数据的指令。
PGO 改进:类型检查和强制转换
默认情况下,.NET 8 启用动态按配置优化 (PGO)。NET 9 扩展了 JIT 编译器的 PGO 实现,以分析更多代码模式。启用分层编译后,JIT 编译器已将插桩插入到您的程序中以分析其行为。当它使用优化进行重新编译时,编译器会利用它在运行时构建的配置文件来做出特定于程序当前运行的决策。在 .NET 9 中,JIT 编译器使用 PGO 数据来提高类型检查的性能。
确定对象的类型需要调用运行时,这会带来性能损失。当需要检查对象的类型时,为了正确起见,JIT 编译器会发出此调用(编译器通常不能排除任何可能性,即使它们看起来不太可能)。但是,如果 PGO 数据表明某个对象可能是特定类型,则 JIT 编译器现在会发出一个快速路径,该路径以较低的成本检查该类型,并仅在必要时回退到调用运行时的慢速路径。
.NET 库中的 Arm64 矢量化
新的 EncodeToUtf8 实现利用 JIT 编译器在 Arm64 上发出多寄存器加载/存储指令的能力。此行为允许程序使用更少的指令处理更大的数据块。跨各个域的 .NET 应用应该会在支持这些功能的 Arm64 硬件上看到吞吐量改进。一些基准测试将其执行时间缩短了一半以上。
Arm64 代码生成
JIT 编译器已经能够转换其连续加载的表示形式,以使用 arm64 上的 ldp 指令(用于加载值)。.NET 9 将此功能扩展到存储操作。
str 指令将单个 register 中的数据存储到 memory,而 stp 指令存储一对 registers 的数据。使用 stp 而不是 str 意味着可以用更少的 store 操作完成相同的任务,从而缩短执行时间。减少一条指令似乎是一个很小的改进,但如果代码在循环中运行大量迭代,则性能提升会迅速增加。
例如,请考虑以下代码段:
class Body { public double x, y, z, vx, vy, vz, mass; }
static void Advance(double dt, Body[] bodies)
{
foreach (Body b in bodies)
{
b.x += dt * b.vx;
b.y += dt * b.vy;
b.z += dt * b.vz;
}
}
b.x、b.y 和 b.z 的值在循环体中更新。在程序集级别,每个成员都可以使用 str 指令进行存储;或者使用 STP,可以用一条指令处理其中两个 STORE (B.X 和 B.Y,或 B.Y 和 B.Z,因为这些对在内存中是连续的)。要使用 stp 指令同时存储到 b.x 和 b.y,编译器还需要确定计算 b.x + (dt * b.vx) 和 b.y + (dt * b.vy) 彼此独立,并且可以在存储到 b.x 和 b.y 之前执行。
更快的异常
CoreCLR 运行时采用了一种新的异常处理方法,该方法提高了异常处理的性能。新实现基于 NativeAOT 运行时的异常处理模型。此更改删除了对 Windows 结构化异常处理 (SEH) 及其在 Unix 上的模拟的支持。除 Windows x86(32 位)之外的所有环境都支持新方法。
根据一些异常处理微基准测试,新的异常处理实现速度提高了 2-4 倍。
默认情况下,新实现处于启用状态。但是,如果需要切换回旧版异常处理行为,可以通过以下任一方式执行此操作:
- 在 runtimeconfig.json 文件中设置为 System.Runtime.LegacyExceptionHandling to true 。
- 将 DOTNET_LegacyExceptionHandling 环境变量设置为 1 .
代码布局
编译器通常使用基本块来推理程序的控制流,其中每个块都是一段代码,只能在第一条指令处输入,并通过最后一条指令退出。基本块的顺序很重要。如果一个 block 以 branch 指令结尾,则控制流会转移到另一个 block。块重新排序的一个目标是通过最大化 fall-through 行为来减少生成代码中的 branch instructions 数量。如果每个基本块后面都跟着它最有可能的后继者,它就可以 “落入” 它的后继者中,而不需要跳跃。
直到最近,JIT 编译器中的块重新排序还受到 flowgraph 实现的限制。在 .NET 9 中,JIT 编译器的块重新排序算法已替换为更简单、更全局的方法。流图数据结构已重构为:
- 删除有关区块排序的一些限制。
- 将执行可能性嵌入到块之间的每个控制流更改中。
此外,随着方法的流程图的转换,配置文件数据会传播和维护。
减少地址暴露
在 .NET 9 中,JIT 编译器可以更好地跟踪局部变量地址的使用情况,并避免不必要的地址暴露。
当使用局部变量的地址时,JIT 编译器在优化方法时必须采取额外的预防措施。例如,假设编译器正在优化一个方法,该方法在对另一个方法的调用中传递局部变量的地址。由于被调用方可能会使用地址来访问局部变量,因此为了保持正确性,编译器会避免转换变量。寻址公开的局部变量会显著抑制编译器的优化潜力。
AVX10v1 支持
为 AVX10 添加了新的 API,这是 Intel 的新 SIMD 指令集。您可以使用新的 Avx10v1 API 通过矢量化操作在支持 AVX10 的硬件上加速 .NET 应用程序。
硬件内部代码生成
许多硬件内部 API 希望用户为某些参数传递常量值。这些常量直接编码到 intrinsic 的底层指令中,而不是加载到 registers 中或从内存中访问。如果未提供常量,则内部函数将替换为对功能等效但速度较慢的回退实现的调用。
请考虑以下示例:
static byte Test1()
{
Vector128<byte> v = Vector128<byte>.Zero;
const byte size = 1;
v = Sse2.ShiftRightLogical128BitLane(v, size);
return Sse41.Extract(v, 0);
}
调用 to Sse2.ShiftRightLogical128BitLane 中 size 的使用可以用常量 1 替换,在正常情况下,JIT 编译器已经能够进行这种替换优化。但是,在确定是生成 的 Sse2.ShiftRightLogical128BitLane 加速代码还是回退代码时,编译器检测到传递的是变量而不是常量,并过早地决定不“内部化”调用。从 .NET 9 开始,编译器会识别更多此类情况,并将 variable 参数替换为其常量值,从而生成加速代码。
浮点和 SIMD 运算的常量折叠
常量折叠是 JIT 编译器中的现有优化。常量折叠是指将可在编译时计算的表达式替换为它们计算的常量,从而消除运行时的计算。.NET 9 添加了新的常量折叠功能:
-
对于浮点二进制运算,其中一个操作数是常量:
x + NaN 现在折叠为 NaN。
x * 1.0 现在折叠为 x。
x + -0 现在折叠为 x。 -
对于硬件内部函数。例如,假设 x 是向量:
x + 矢量。Zero 现在折叠为 x。
x & 矢量。零现在折叠成向量。零。
x & 矢量。AllBitsSet 现在折叠为 x。
Arm64 SVE 支持
.NET 9 引入了对可扩展矢量扩展 (SVE) 的实验性支持,SVE 是 ARM64 CPU 的 SIMD 指令集。.NET 已经支持 NEON 指令集,因此在支持 NEON 的硬件上,您的应用程序可以利用 128 位矢量寄存器。SVE 支持灵活的矢量长度,最高可达 2048 位,每条指令可解锁更多数据处理。在 .NET 9 中,Vector 在面向 SVE 时为 128 位宽,未来的工作将允许缩放其宽度以匹配目标计算机的矢量寄存器大小。您可以使用新的 System.Runtime.Intrinsics.Arm.Sve API 在支持 SVE 的硬件上加速 .NET 应用程序。
.NET 9 中的 SVE 支持是实验性的。下面的 System.Runtime.Intrinsics.Arm.Sve API 标有 ExperimentalAttribute,这意味着它们在未来版本中可能会发生变化。此外,通过 SVE 生成的代码的调试器单步执行和断点可能无法正常工作,从而导致应用程序崩溃或数据损坏。
盒子的对象堆栈分配
值类型(如 int 和 struct)通常在堆栈而不是堆上分配。但是,为了启用各种 Code Pattern,它们经常被 “装箱” 到对象中。
请考虑以下代码段:
static bool Compare(object? x, object? y)
{
if ((x == null) || (y == null))
{
return x == y;
}
return x.Equals(y);
}
public static int RunIt()
{
bool result = Compare(3, 4);
return result ? 0 : 100;
}
Compare 编写方便,因此如果您想比较其他类型,例如字符串或双精度值,您可以重用相同的实现。但在此示例中,它也具有性能缺点,即要求对传递给它的任何值类型进行装箱。
为 RunIt 生成的 x64 汇编代码如下所示:
push rbx
sub rsp, 32
mov rcx, 0x7FFB9F8074D0 ; System.Int32
call CORINFO_HELP_NEWSFAST
mov rbx, rax
mov dword ptr [rbx+0x08], 3
mov rcx, 0x7FFB9F8074D0 ; System.Int32
call CORINFO_HELP_NEWSFAST
mov dword ptr [rax+0x08], 4
add rbx, 8
mov ecx, dword ptr [rbx]
cmp ecx, dword ptr [rax+0x08]
sete al
movzx rax, al
xor ecx, ecx
mov edx, 100
test eax, eax
mov eax, edx
cmovne eax, ecx
add rsp, 32
pop rbx
ret
对 CORINFO_HELP_NEWSFAST 的调用是装箱整数参数的堆分配。另请注意,没有对 Compare 的任何调用;编译器决定将其内联到 RunIt 中。这种内联意味着 box 永远不会 “escape”。换句话说,在 Compare 的整个执行过程中,它知道 x 和 y 实际上是整数,并且可以在不影响比较逻辑的情况下安全地将它们拆箱。
从 .NET 9 开始,64 位编译器在堆栈上分配未转义的框,从而解锁其他几个优化。在此示例中,编译器现在省略了堆分配,但是因为它知道 x 和 y 是 3 和 4,所以它也可以省略 Compare 的主体;编译器可以在编译时确定 x.Equals(y) 为 false,因此 RunIt 应始终返回 100。以下是更新后的程序集:
mov eax, 100
ret