添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

本文介绍 .NET 10 .NET 运行时中的新功能和性能改进。 它已针对 RC 1 进行了更新。

JIT 编译器改进

.NET 10 中的 JIT 编译器包括通过更好的代码生成和优化策略提高性能的重要增强功能。

改进了结构参数的代码生成

.NET 的 JIT 编译器能够进行称为物理提升的优化,其中结构的成员放置在寄存器而不是堆栈中,从而消除了内存访问。 将结构传递给方法时,此优化特别有用,调用约定要求在寄存器中传递结构成员。

.NET 10 改进了 JIT 编译器的内部表示形式,以处理共享寄存器的值。 以前,当需要将结构成员打包到单个寄存器中时,JIT 将首先将值存储在内存中,然后将其加载到寄存器中。 现在,JIT 编译器可以将结构参数的优化成员直接存储到共享寄存器中,从而消除不必要的内存操作。

请看下面的示例:

struct Point
    public long X;
    public long Y;
    public Point(long x, long y)
        X = x;
        Y = y;
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Consume(Point p)
    Console.WriteLine(p.X + p.Y);
private static void Main()
    Point p = new Point(10, 20);
    Consume(p);

在 x64 上,Point 的成员会分配到单独的寄存器,并传递给 Consume。由于本地 p 的物理提升已启动,因此最初不会在堆栈上分配任何内容。

Program:Main() (FullOpts):
       mov      edi, 10
       mov      esi, 20
       tail.jmp [Program:Consume(Program+Point)]

现在,假设成员 Point 的类型已更改为 int 而不是 long。 由于 a int 为四个字节宽,寄存器在 x64 上宽 8 个字节,因此调用约定要求在一个寄存器中传递成员 Point 。 以前,JIT 编译器首先将值存储到内存中,然后将八字节区块加载到寄存器中。 通过 .NET 10 改进,JIT 编译器现在可以直接将结构参数的提升成员放入共享寄存器中:

Program:Main() (FullOpts):
       mov      rdi, 0x140000000A
       tail.jmp [Program:Consume(Program+Point)]

这样就不需要中间内存存储,从而生成更高效的程序集代码。

改进了循环转化

JIT 编译器可以提升循环的条件 while ,并将循环体转换为 do-while 循环,从而生成最终形状:

if (loopCondition)
        // loop body
    } while (loopCondition);

此转换称为循环反转。 通过将条件移动到循环底部,JIT 无需将分支到循环顶部来测试条件,从而改进代码布局。 许多优化(如循环克隆、循环展开和感应变量优化)也依赖于循环反转来生成此形状来帮助分析。

.NET 10 通过从词法分析实现切换到基于图形的循环识别实现来增强循环反转。 此更改会考虑所有自然循环(即只有单个入口点的循环)并忽略以前被考虑的误报,从而提高了精度。 这使得包含 forwhile 语句的 .NET 程序具有了更高的优化潜力。

数组接口方法反虚拟化

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

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

.NET 10 中进行了各种内联改进。

JIT 现在可以内联因以前的内联而适合实现非虚拟化的方法。 这种改进使 JIT 可以发现更多的优化机会,例如进一步内联和去虚拟化。

某些具有异常处理语义的方法(尤其是具有 try-finally 块的方法)也可以内联。

为了更好地利用 JIT 的堆栈分配某些数组的能力,内联器的启发式算法已进行调整,以提高可能返回小型固定大小数组的候选项的收益。

在内联过程中,JIT 编译器现在会更新临时变量的类型,这些变量用于存储返回值。 如果被调用方中的所有返回站点都生成相同的类型,则使用此精确的类型信息来取消后续调用的虚拟化。 此增强功能补充了后期非虚拟化和数组枚举去抽象的改进。

配置文件数据

.NET 10 改进了 JIT 的内联策略,以更好地利用配置文件数据。 在众多启发式算法中,JIT 内联器不会对超过一定大小的方法进行内联,以避免使调用方法变得臃肿。 当调用方拥有表明某个内联候选函数被频繁执行的配置文件数据时,内联器会增加对该候选函数的大小容忍度。

假设 JIT 将没有配置文件数据的被调用方 Callee 内联到具有配置文件数据的调用方 Caller 中。 这种不一致可能发生在被调用方过小而不值得进行检测,或者被调用方被内联调用过于频繁,从而导致调用次数不足。 如果 Callee 拥有自己的内联候选项,JIT 此前并未将其纳入考虑范围,因为 Callee 缺乏性能分析数据。 现在,JIT 将识别到 Caller 拥有配置文件数据,并因此放宽其大小限制(但为了弥补精度损失,这种放宽程度不及 Callee 拥有配置文件数据时)。

同样,当 JIT 决定某个调用点不适合进行内联优化时,它会用 NoInlining 标记该方法,以避免未来再次尝试对该方法进行内联优化。 但是,许多内联启发法对配置文件数据很敏感。 例如,在缺少配置文件数据的情况下,JIT 可能会认为某个方法太大而不值得进行内联。 但是,当调用方足够热时,JIT 可能愿意放宽其大小限制并内联调用。 在 .NET 10 中,JIT 不再用 NoInlining 标记那些无利可图的内联函数,以避免配置文件数据导致调用点的性能恶化。

AVX10.2 支持

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

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

堆栈分配可减少 GC 必须跟踪的对象数,并且还会解锁其他优化。 例如,在对象被堆栈分配后,JIT 可以考虑将其完全替换为其标量值。 因此,堆栈分配是减少引用类型的抽象惩罚的关键。 .NET 10 为值类型和引用类型的小型数组添加堆栈分配。 它还包括对本地结构字段和委托的转义分析。 (无法转义的对象可以在堆栈上分配。)

值类型的小型数组

当可以保证它们的生命周期不会超过其父方法时,JIT 现在会对不包含 GC 指针的小型固定大小的值类型数组进行堆栈分配。 在下面的示例中,JIT 知道在编译时 numbers 是一个仅包含三个整数的数组,并且该数组不会在调用 Sum 后继续存在,因此在堆栈上分配它。

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);

引用类型的小数组

.NET 10 将 .NET 9 堆栈分配改进 扩展到引用类型的小型数组。 以前,引用类型的数组总是被分配在堆上,即使其生命周期仅限于单个方法内也是如此。 现在,JIT 在确定这些数组不会超过其创建上下文的生命周期后,可以将其堆栈分配。 在以下示例中,数组 words 现在在堆栈上分配。

static void Print()
    string[] words = {"Hello", "World!"};
    foreach (var str in words)
        Console.WriteLine(str);
              转义分析会确定对象是否会比其父方法的生存期更长。 当对象分配给非局部变量或传递给未由 JIT 内联的函数时,它们会“转义”。 如果对象无法转义,则可以在堆栈上分配它。 .NET 10 包含以下的转义分析:

本地结构字段

本地结构体字段

从 .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 程序集包括调用 CORINFO_HELP_NEWARR_1_VC 来在堆上分配 x ,这表明它被标记为逃逸:

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

在 .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

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

当源代码被编译成 IL 时,每个委托都会被转换为闭包类,闭包类中的方法与委托的定义相对应,字段则匹配任何被捕获的变量。 在运行时,将创建一个闭包对象来实例化捕获的变量,以及一个 Func 对象用于调用委托。 如果转义分析确定 Func 对象不会超过其当前范围,则 JIT 会将其分配给堆栈。

请考虑以下 Main 方法:

 public static int Main()
    int local = 1;
    int[] arr = new int[100];
    var func = (int x) => x + local;
    int sum = 0;
    foreach (int num in arr)
        sum += func(num);
    return sum;

以前,JIT 为 Main生成以下基于 x64 架构的缩写程序集。 在进入循环之前,arrfunc和名为funcProgram+<>c__DisplayClass0_0闭包类均在上进行分配,如CORINFO_HELP_NEW*调用所示。

       ; prolog omitted for brevity
       mov      rdi, 0x7DD0AE362E28      ; Program+<>c__DisplayClass0_0
       call     CORINFO_HELP_NEWSFAST
       mov      rbx, rax
       mov      dword ptr [rbx+0x08], 1
       mov      rdi, 0x7DD0AE268A98      ; int[]
       mov      esi, 100
       call     CORINFO_HELP_NEWARR_1_VC
       mov      r15, rax
       mov      rdi, 0x7DD0AE4A9C58      ; System.Func`2[int,int]
       call     CORINFO_HELP_NEWSFAST
       mov      r14, rax
       lea      rdi, bword ptr [r14+0x08]
       mov      rsi, rbx
       call     CORINFO_HELP_ASSIGN_REF
       mov      rsi, 0x7DD0AE461140      ; code for Program+<>c__DisplayClass0_0:<Main>b__0(int):int:this
       mov      qword ptr [r14+0x18], rsi
       xor      ebx, ebx
       add      r15, 16
       mov      r13d, 100
G_M24375_IG03:  ;; offset=0x0075
       mov      esi, dword ptr [r15]
       mov      rdi, gword ptr [r14+0x08]
       call     [r14+0x18]System.Func`2[int,int]:Invoke(int):int:this
       add      ebx, eax
       add      r15, 4
       dec      r13d
       jne      SHORT G_M24375_IG03
       ; epilog omitted for brevity

因为func从未在Main范围之外被引用,因此它也被分配在堆栈上。

       ; prolog omitted for brevity
       mov      rdi, 0x7B52F7837958      ; Program+<>c__DisplayClass0_0
       call     CORINFO_HELP_NEWSFAST
       mov      rbx, rax
       mov      dword ptr [rbx+0x08], 1
       mov      rsi, 0x7B52F7718CC8      ; int[]
       mov      qword ptr [rbp-0x1C0], rsi
       lea      rsi, [rbp-0x1C0]
       mov      dword ptr [rsi+0x08], 100
       lea      r15, [rbp-0x1C0]
       xor      r14d, r14d
       add      r15, 16
       mov      r13d, 100
G_M24375_IG03:  ;; offset=0x0099
       mov      esi, dword ptr [r15]
       mov      rdi, rbx
       mov      rax, 0x7B52F7901638      ; address of definition for "func"
       call     rax
       add      r14d, eax
       add      r15, 4
       dec      r13d
       jne      SHORT G_M24375_IG03
       ; epilog omitted for brevity

注意还有一个 CORINFO_HELP_NEW* 调用,这是闭包的堆分配。 运行时团队计划在未来的版本中扩展转义分析以支持闭包的堆栈分配。

NativeAOT 类型预初始化器改进

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

Arm64 写入屏障改进

.NET 的垃圾回收器(GC)是代系的,这意味着它按年龄分隔实时对象以提高回收性能。 GC 更频繁地收集年轻一代,其假设是,长生命周期对象在任何给定时间点被引用(或“死亡”)的可能性较小。 但是,假设旧对象开始引用一个年轻对象;GC 需要知道它不能收集年轻的对象。 然而,需要扫描旧对象来收集新对象会抵消代际 GC 的性能优势。

为了解决此问题,JIT 在对象引用更新之前插入写屏障,以通知 GC。 在 x64 上,运行时可以在写入屏障实现之间动态切换,以平衡写入速度和收集效率,具体取决于 GC 的配置。 在 .NET 10 中,此功能也可用于 Arm64。 具体而言,Arm64 上的新默认写屏障实现能更准确地处理 GC 区域,从而提高收集性能,但会稍微影响写屏障的吞吐量。 基准测试显示,采用新的 GC 默认设置后,GC 暂停时间的改善幅度从 8% 提升至超过 20%。