.NET 9 运行时中的新增功能

本文介绍 .NET 9 .NET 运行时中的新功能和性能改进。

具有剪裁支持的功能开关的属性模型

通过两个新属性,可以定义 .NET 库(以及可用于切换功能区域) 的功能开关 。 如果不支持某个功能,则在使用本机 AOT 进行剪裁或编译时,将删除不受支持的(因此未使用)功能,从而使应用大小更小。

  • FeatureSwitchDefinitionAttribute 用于在剪裁时将功能开关属性视为常量,可以删除由开关保护的死代码:

    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 void Implementation() => ...;
    }
    

    使用项目文件中的以下功能设置剪裁应用时, Feature.IsSupported 将被视为 falseFeature.Implementation 删除代码。

    <ItemGroup>
      <RuntimeHostConfigurationOption Include="Feature.IsSupported" Value="false" Trim="true" />
    </ItemGroup>
    
  • FeatureGuardAttribute 用于将功能开关属性视为带注释 RequiresUnreferencedCodeAttribute的代码的防护, RequiresAssemblyFilesAttributeRequiresDynamicCodeAttribute。 例如:

    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 void Implementation() => ...; // Uses dynamic code
    }
    

    生成 <PublishAot>true</PublishAot>时,调用 Feature.Implementation() 不会生成分析器警告 IL3050Feature.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 中进行了显著更新和改进。

有关详细信息,请参阅动态适应应用程序大小(DATAS)。

控制流强制技术

默认情况下,Windows 上的应用启用了控制流强制技术(CET)。 它通过添加硬件强制的堆栈保护来针对面向返回的编程(ROP)攻击显著改善安全性。 这是最新的 .NET 运行时安全缓解措施

CET 对已启用 CET 的进程施加了一些限制,并可能导致性能下降。 有各种控件可以选择退出 CET。

.NET 安装搜索行为

现在,可以配置 .NET 应用以了解如何 搜索 .NET 运行时。 此功能可用于专用运行时安装,或更强地控制执行环境。

性能改进

对 .NET 9 进行了以下性能改进:

循环优化

改进循环的代码生成是 .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 上的索引后寻址

索引变量通常用于读取内存的顺序区域。 请考虑惯用 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] 。 然后,add x1, x1, #4将地址x1增加四个字节(整数的大小),移动到下一个整数。nums 此指令对应于 i++ 每次迭代结束时执行的作。

Arm64 支持索引后寻址,其中“索引”寄存器在使用地址后自动递增。 这意味着可以将两个指令组合成一个指令,使循环更高效。 CPU 只需要解码一个指令而不是两个指令,循环的代码现在更易缓存。

下面是更新的程序集的外观:

ldr w0, [x1], #0x04

末尾 #0x04 表示地址 x1 在用于将整数加载到 w0后递增四个字节。 生成 Arm64 代码时,64 位编译器现在使用索引后寻址。

强度减少

强度减少是编译器优化,其中作被替换为更快的逻辑等效作。 此方法特别适用于优化循环。 请考虑惯用 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++rcxecx 保存此寄存器的低 32 位)包含的值 sumrax 包含基址 nums,并 rdx 包含值 i。 若要计算地址 nums[i],索引中的 rdx 索引 乘以 四(整数的大小)。 然后,此偏移 量将添加到 基址中 rax,外加一些填充。 (读取时为整数后,将添加到该整数nums[i]并递增索引rcxrdx换句话说,每个数组访问都需要乘法加法运算。

乘法比加法贵,用后者取代前者是减少力量的经典动机。 为了避免在每个内存访问上计算元素的地址,可以重写示例以使用指针而不是索引变量访问整数 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

rcxecx 保存此寄存器的低 32 位)仍保留其值 sum,但现在 rdx 保留指向的 p地址,因此访问元素 nums 只需要取消引用 rdx。 第一个示例中的所有乘法和加法都替换为单个 add 指令来向前移动指针。

在 .NET 9 中,JIT 编译器 自动 将第一个索引模式转换为第二个索引模式,而无需重写任何代码。

循环计数器变量方向

64 位编译器现在可识别循环的计数器变量仅用于控制迭代数,并转换循环以倒计时而不是向上计数。

在惯用 for (int i = ...) 模式中,计数器变量通常会增加。 请看下面的示例:

for (int i = 0; i < 100; i++)
{
    DoSomething();
}

但是,在许多体系结构中,递减循环计数器的性能更高,如下所示:

for (int i = 100; i > 0; i--)
{
    DoSomething();
}

对于第一个示例,编译器需要发出一个指令来递增 i,然后发出一个指令来执行比较,然后是条件跳转以 i < 100 继续循环(如果条件仍然存在 true),这是总共三个指令。 但是,如果翻转计数器的方向,则需要少一条指令。 例如,在 x64 上,编译器可以使用 dec 指令递减 i;当达到零时 i ,指令 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 编译器无法内联到 Callee--即使两种方法都在同一类型上实例化,内联Test代码Callee也无法访问正确的字典。

    .NET 9 通过在调用方中自动启用运行时类型查找来取消了这一限制,这意味着 JIT 编译器现在可以将方法(如 Callee)内联到 Test 中。

    假设我们在另一种方法中调用 Test<string> 。 在伪代码中,内联如下所示:

    static bool Test<string>() => typeof(string) == typeof(int);
    

    可以在编译期间计算该类型检查,因此最终代码如下所示:

    static bool Test<string>() => false;
    

    对 JIT 编译器内联的改进可能会对其他内联决策产生复合影响,从而导致显著的性能胜利。 例如,对内联的决定也可以使调用Callee内联Test<string>,等等。 这产生了 数百 项基准改进,至少有 80 个基准提高了 10% 或更高版本。

  • 访问 Windows x64、Linux x64 和 Linux Arm64 上的 线程本地静态

    对于 static 类成员,该成员的一个实例存在于该类的所有实例中,该实例“共享”该成员。 如果成员的值对每个线程是唯一 static 的,则使该值的线程本地可以提高性能,因为它不需要并发基元从其包含的线程安全地访问 static 该成员。

    以前,对本机 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 编译器已经能够转换其连续加载的表示形式,以使用 ldp Arm64 上的指令(用于加载值)。 .NET 9 扩展了 存储 作的功能。

str 指令将数据从单个寄存器存储到内存,而 stp 指令存储一 寄存器中的数据。 使用 stp 而不是 str 意味着可以使用更少的存储作来完成相同的任务,从而提高执行时间。 将一个指令除掉似乎是一个小改进,但如果代码在一个循环中运行一个非常数迭代,则性能提升可能会很快增加。

例如,请考虑以下代码片段:

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.xb.yb.z更新值。 在程序集级别,每个成员都可以使用指令进行存储;或使用两个str存储区(stp以及b.x,或b.yb.y因为这些对在内存中是连续的)可以使用一个指令进行处理。b.z 若要使用stp指令存储和同时存储b.xb.y,编译器还需要确定计算b.x + (dt * b.vx)b.y + (dt * b.vy)彼此独立,并且可以在存储到b.xb.y之前执行。

更快的异常

CoreCLR 运行时采用了新的异常处理方法,可提高异常处理的性能。 新实现基于 NativeAOT 运行时的异常处理模型。 此更改消除了对 Windows 结构化异常处理(SEH)及其在 Unix 上的仿真的支持。 除 Windows x86(32 位)外,所有环境中都支持新方法。

新的异常处理实现速度比某些异常处理微基准快 2-4 倍。 性能实验室中测量了以下性能改进:

默认情况下,新实现处于启用状态。 但是,如果需要切换回旧式异常处理行为,可以通过以下任一方式执行此作:

  • 设置为 System.Runtime.LegacyExceptionHandlingtrueruntimeconfig.json 文件中
  • DOTNET_LegacyExceptionHandling 环境变量设置为 1.

代码布局

编译器通常使用基本 来解释程序控制流的原因,其中每个块都是一个代码块,只能在第一个指令中输入并通过最后一个指令退出。 基本块的顺序非常重要。 如果块以分支指令结尾,则控制流将传输到另一个块。 块重新排序的一个目标是通过最大化 倒退 行为来减少生成的代码中的分支指令数。 如果每个基本块后跟其最有可能的继任者,它可以“陷入”其继任者,而无需跳跃。

直到最近,JIT 编译器中的块重新排序受流程图实现的限制。 在 .NET 9 中,JIT 编译器的块重新排序算法已替换为更简单、更全局的方法。 流程图数据结构已重构为:

  • 删除有关块排序的一些限制。
  • 在块之间的每个控制流更改中,根深蒂固的执行可能性。

此外,配置文件数据在转换方法的流程图时传播和维护。

减少地址公开

在 .NET 9 中,JIT 编译器可以更好地跟踪本地变量地址的使用情况,并避免不必要的 地址泄露

使用局部变量的地址时,JIT 编译器在优化方法时必须采取额外的预防措施。 例如,假设编译器正在优化一个方法,该方法在调用另一个方法中传递局部变量的地址。 由于被调用方可以使用地址访问本地变量,以保持正确性,编译器将避免转换变量。 已解决的局部变量可能会显著抑制编译器的优化潜力。

AVX10v1 支持

AVX10 添加了新的 API,这是 Intel 中新的 SIMD 指令集。 可以使用新 Avx10v1 API 在启用了 AVX10 的硬件上加速 .NET 应用程序,并使用矢量化作。

硬件内部代码生成

许多硬件内部 API 要求用户为某些参数传递常量值。 这些常量直接编码为内部函数的基础指令,而不是加载到寄存器中或从内存中访问。 如果未提供常量,则内部函数将替换为对功能等效但速度较慢的回退实现的调用。

请看下面的示例:

static byte Test1()
{
    Vector128<byte> v = Vector128<byte>.Zero;
    const byte size = 1;
    v = Sse2.ShiftRightLogical128BitLane(v, size);
    return Sse41.Extract(v, 0);
}

调用size中的用法Sse2.ShiftRightLogical128BitLane可以替换为常量 1,在正常情况下,JIT 编译器已经能够进行这种替换优化。 但是,当确定是要为其 Sse2.ShiftRightLogical128BitLane生成加速代码还是回退代码时,编译器会检测变量正在传递而不是常量,并过早地决定不要“内化”调用。 从 .NET 9 开始,编译器会识别更多这样的情况,并将变量参数替换为其常量值,从而生成加速代码。

浮点和 SIMD作的常量折叠

常量折叠 是 JIT 编译器中的现有优化。 常量折叠 是指将可以在编译时计算的表达式替换为它们所求值的常量,从而消除在运行时的计算。 .NET 9 添加了新的常量折叠功能:

  • 对于浮点二进制作,其中一个作数是常量:
    • x + NaN 现在折叠到 NaN
    • x * 1.0 现在折叠到 x
    • x + -0 现在折叠到 x
  • 对于硬件内部函数。 例如,假设x为:Vector<T>
    • x + Vector<T>.Zero 现在折叠到 x
    • x & Vector<T>.Zero 现在折叠到 Vector<T>.Zero
    • x & Vector<T>.AllBitsSet 现在折叠到 x

Arm64 SVE 支持

.NET 9 引入了 对可缩放矢量扩展 (SVE)的实验性支持,这是 ARM64 CPU 的 SIMD 指令集。 .NET 已支持 NEON 指令集,因此,在支持 NEON 的硬件上,应用程序可以利用 128 位矢量寄存器。 SVE 支持灵活的矢量长度,最多 2048 位,从而解锁每个指令的更多数据处理。 在 .NET 9 中, Vector<T> 面向 SVE 时宽为 128 位,未来工作将使其宽度的缩放与目标计算机的矢量寄存器大小匹配。 可以使用新的 System.Runtime.Intrinsics.Arm.Sve API 在支持 SVE 的硬件上加速 .NET 应用程序。

注释

.NET 9 中的 SVE 支持是实验性的。 下面的 System.Runtime.Intrinsics.Arm.Sve API 标有 ExperimentalAttribute,这意味着它们在未来版本中可能会更改。 此外,通过 SVE 生成的代码执行调试器和断点可能无法正常工作,从而导致应用程序崩溃或数据损坏。

框的对象堆栈分配

值类型(例如 intstruct)通常在堆栈上分配,而不是堆。 但是,若要启用各种代码模式,它们经常被“装箱”到对象中。

请考虑以下代码片段:

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 可以方便地编写,以便如果要比较其他类型的类型(如字符串或 double 值),则可以重复使用相同的实现。 但在此示例中,它还具有要求传递给它的任何值类型的装 的性能缺点。

生成的 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其中。 这种内联意味着框永远不会“转义”。换句话说,在整个执行 Compare过程中,它知道 x 并且 y 实际上是整数,并且它们可以安全地解箱,而不会影响比较逻辑。

从 .NET 9 开始,64 位编译器在堆栈上分配未转义框,这将解锁其他几个优化。 在此示例中,编译器现在省略堆分配,但由于它知道 x 且为 3 和 y 4,因此也可以省略正文 Compare;编译器可以在编译时确定 x.Equals(y) 为 false,因此 RunIt 应始终返回 100。 下面是更新的程序集:

mov      eax, 100
ret