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

本文介绍 .NET 10 .NET 运行时中的新功能和性能改进。 它已针对预览版 4 进行更新。

数组接口方法去虚拟化

.NET 10 的重点 领域 之一是减少常用语言功能的抽象开销。 为了追求此目标,JIT 去虚拟化方法调用的能力已经扩展为涵盖数组接口方法。

请考虑遍历一个数组的典型方法:

static int Sum(int[] array)
{
    int sum = 0;
    for (int i = 0; i < array.Length; i++)
    {
        sum += array[i];
    }
    return sum;
}

此代码形式易于 JIT 优化,主要是因为不存在虚拟调用。 相反,JIT 可以专注于删除对数组访问的边界检查,并应用 .NET 9 中添加的循环优化。 以下示例添加一些虚拟调用:

static int Sum(int[] array)
{
    int sum = 0;
    IEnumerable<int> temp = array;

    foreach (var num in temp)
    {
        sum += num;
    }
    return sum;
}

基础集合的类型是明确的,JIT 应该能够将此代码片段转换为第一个代码片段。 但是,数组接口的实现方式与“普通”接口不同,因此 JIT 不知道如何对它们进行反虚拟化。 这意味着循环中的 foreach 枚举器调用仍然是虚拟的,从而阻止了多个优化,例如内联和堆栈分配。

从 .NET 10 开始,JIT 可以取消虚拟化和内联数组接口方法。 这是实现两者性能相等的诸多步骤中的第一步,如.NET 10 去抽象计划中所述。

数组枚举去抽象化

通过枚举器减少数组迭代的抽象开销的努力改进了 JIT 的内联、堆栈分配和循环克隆功能。 例如,通过 IEnumerable 枚举数组的开销减少,并且条件转义分析现在在某些情况下支持枚举器进行堆栈分配。

小型值类型数组的堆栈分配

在 .NET 9 中,JIT 获得了在对象保证不会超过其父方法的生命周期时在堆栈上分配对象的能力。 堆栈分配不仅减少了 GC 必须跟踪的对象数,而且还会解锁其他优化。 例如,在对象被堆栈分配后,JIT 可以考虑将其完全替换为其标量值。 因此,堆栈分配是减少引用类型的抽象惩罚的关键。

在 .NET 10 中,JIT 现在会在栈上分配小型固定大小的值类型数组,这些数组在能够实现前面所述的相同生存期保证时,不包含 GC 指针。 请看下面的示例:

static void Sum()
{
    int[] numbers = {1, 2, 3};
    int sum = 0;

    for (int i = 0; i < numbers.Length; i++)
    {
        sum += numbers[i];
    }

    Console.WriteLine(sum);
}

由于 JIT 知道 numbers 在编译时是一个仅包含三个整数的数组,并且它不会在调用 Sum 后存活,因此将其分配在堆栈上。

引用类型的小型数组的堆栈分配

.NET 10 将 .NET 9 堆栈分配改进 扩展到引用类型的小型数组。 以前,即使引用类型的生存期限定为单个方法,也始终在堆上分配引用类型的数组。 现在,JIT 在确定这些数组不会超过其创建上下文的生命周期后,可以将其堆栈分配。 例如:

static void Print()
{
    string[] words = {"Hello", "World!"};
    foreach (var str in words)
    {
        Console.WriteLine(str);
    }
}

在此示例中,数组 words 现在在堆栈上分配。 消除堆分配可降低 GC 压力并提高性能。

改进了代码布局

.NET 10 中的 JIT 编译器引入了一种将方法代码组织为基本块的新方法,以提高运行时性能。 以前,JIT 使用程序流图的反向后序(RPO)遍历作为初始布局,然后进行迭代转换。 虽然此方法有效,但在模拟减少分支与增加热代码密度之间权衡时存在限制。

在 .NET 10 中,JIT 将块重新排序问题建模为减少非对称旅行销售人员问题,并实现 3 选择启发式来查找近乎最佳的遍历。 此优化可提高热路径密度并减少分支距离,从而提高运行时性能。

AVX10.2 支持

.NET 10 为基于 x64 的处理器引入了对高级矢量扩展 (AVX) 10.2 的支持。 一旦提供支持的硬件,就可以测试类中 System.Runtime.Intrinsics.X86.Avx10v2 可用的新内部函数。

由于已启用 AVX10.2 的硬件尚不可用,因此 JIT 对 AVX10.2 的支持目前默认处于禁用状态。

在 NativeAOT 的类型预初始化器中支持类型转换和逻辑否定

NativeAOT 的类型预初始化器现在支持所有 conv.*neg 操作码的变体。 此增强功能允许对包含类型转换或取反操作的方法进行预初始化,从而进一步优化运行时性能。

局部结构体字段的逃逸分析

转义分析 确定对象是否可以超过其父方法的生命周期继续存在。 当对象分配给非局部变量或传递给 JIT 没有内联的函数时,它们会“转义”。 如果对象无法转义,则可以在堆栈上分配它。 从 .NET 10 开始,JIT 也会考虑结构体 字段引用的对象,这能够实现更多的栈分配并减少堆内存开销。

请看下面的示例:

public class Program
{
    struct GCStruct
    {
        public int[] arr;
    }

    public static void Main()
    {
        int[] x = new int[10];
        GCStruct y = new GCStruct() { arr = x };
        return y.arr[0];
    }
}

通常,JIT 在堆栈上分配既小且固定大小并且不会逃逸的数组,例如 x。 将 y.arr 进行赋值不会导致 x 转义,因为 y 也不会转义。 但是,JIT 的转义分析实现以前没有对结构字段引用进行建模。 在 .NET 9 中,生成的 Main x64 程序集如下所示:

Program:Main():int (FullOpts):
       push     rax
       mov      rdi, 0x719E28028A98      ; int[]
       mov      esi, 10
       call     CORINFO_HELP_NEWARR_1_VC
       mov      eax, dword ptr [rax+0x10]
       add      rsp, 8
       ret

请注意调用 CORINFO_HELP_NEWARR_1_VC 在堆上分配 x,这表明它被标记为线程逃逸。 在 .NET 10 中,只要结构体本身不转义,JIT 将不再将局部结构字段引用的对象标记为转义。 程序集现在如下所示:

Program:Main():int (FullOpts):
       sub      rsp, 56
       vxorps   xmm8, xmm8, xmm8
       vmovdqu  ymmword ptr [rsp], ymm8
       vmovdqa  xmmword ptr [rsp+0x20], xmm8
       xor      eax, eax
       mov      qword ptr [rsp+0x30], rax
       mov      rax, 0x7F9FC16F8CC8      ; int[]
       mov      qword ptr [rsp], rax
       lea      rax, [rsp]
       mov      dword ptr [rax+0x08], 10
       lea      rax, [rsp]
       mov      eax, dword ptr [rax+0x10]
       add      rsp, 56
       ret

请注意,堆分配帮助程序调用已经不见了。

有关 .NET 10 中取消抽象改进的详细信息,请参阅 dotnet/runtime#108913

内联改进

在 .NET 10 中,JIT 可以内联:

  • 由于之前的内联,方法获得了进行非虚拟化的资格。 这种改进使 JIT 可以发现更多的优化机会,例如进一步内联和去虚拟化。
  • 某些方法具有异常处理语义,尤其是那些包含 try-finally 块的方法。

.NET 10 还进行了以下改进:

  • 为了更好地利用 JIT 对某些数组进行堆栈分配的能力,内联器的启发式策略进行了调整,以提高那些可能返回小型固定大小数组的候选项的效益。
  • 在内联期间,JIT 现在更新保存返回值的临时变量的类型。 如果被调用函数中的所有返回位置都生成相同的类型,则使用此精确的类型信息来使后续调用去虚拟化。 此增强功能补充了后期非虚拟化和数组枚举去抽象的改进。
  • 当 JIT 决定某个调用点不适合内联时,为了减少编译时间,它会将该方法标记为 NoInlining,从而阻止将来的内联尝试考虑此方法。 但是,许多内联启发法对性能分析数据很敏感。 例如,在缺乏配置文件数据的情况下,JIT 可能会认为某个方法太大而不值得进行内联。 但是,当调用方足够热时,JIT 可能愿意放宽其大小限制并内联呼叫。 在 .NET 10 中,JIT 不再标记无利可得的内联方法,用 NoInlining 以避免使用性能分析数据降低调用站点效率。