【译】.NET 7 中的性能改进(三)

2023-02-22 00:00:00 调用 检查 方法 循环 类型

我在我的 .NET 6 性能改进一文中写了关于配置文件引导优化 (profile-guided optimization) (PGO) 的文章,但我将在此处再次介绍它,因为它已经看到了 .NET 7 的大量改进。

PGO 已经存在了很长时间,有多种语言和编译器。基本思想是你编译你的应用程序,要求编译器将检测注入应用程序以跟踪各种有趣的信息。然后你让你的应用程序通过它的步伐,运行各种常见的场景,使该仪器“描述”应用程序执行时发生的事情,然后保存结果。然后重新编译应用程序,将这些检测结果反馈给编译器,并允许它根据预期的使用方式优化应用程序。这种 PGO 方法被称为“静态 PGO”,因为所有信息都是在实际部署之前收集的,这是 .NET 多年来一直以各种形式进行的事情。不过,从我的角度来看,.NET 中真正有趣的开发是“动态 PGO”,它是在 .NET 6 中引入的,但默认情况下是关闭的。

动态 PGO 利用分层编译。我注意到 JIT 检测第 0 层代码以跟踪方法被调用的次数,或者在循环的情况下,循环执行了多少次。它也可以将它用于其他事情。例如,它可以准确跟踪哪些具体类型被用作接口分派的目标,然后在第 1 层专门化代码以期望常见的类型(这称为“保护去虚拟化 (guarded devirtualization)”或 GDV)。你可以在这个小例子中看到这一点。将 DOTNET_TieredPGO 环境变量设置为 1,然后在 .NET 7 上运行:

class Program
{
    static void Main()
    {
        IPrinter printer = new Printer();
        for (int i = ; ; i++)
        {
            DoWork(printer, i);
        }
    }

    static void DoWork(IPrinter printer, int i)
    {
        printer.PrintIfTrue(i == int.MaxValue);
    }

    interface IPrinter
    {
        void PrintIfTrue(bool condition);
    }

    class Printer : IPrinter
    {
        public void PrintIfTrue(bool condition)
        {
            if (condition) Console.WriteLine("Print!");
        }
    }
}

DoWork 的第 0 层代码终看起来像这样:

G_M000_IG01:                ;; offset=0000H
       55                   push     rbp
       4883EC30             sub      rsp, 48
       488D6C2430           lea      rbp, [rsp+30H]
       33C0                 xor      eax, eax
       488945F8             mov      qword ptr [rbp-08H], rax
       488945F0             mov      qword ptr [rbp-10H], rax
       48894D10             mov      gword ptr [rbp+10H], rcx
       895518               mov      dword ptr [rbp+18H], edx

G_M000_IG02:                ;; offset=001BH
       FF059F220F00         inc      dword ptr [(reloc 0x7ffc3f1b2ea0)]
       488B4D10             mov      rcx, gword ptr [rbp+10H]
       48894DF8             mov      gword ptr [rbp-08H], rcx
       488B4DF8             mov      rcx, gword ptr [rbp-08H]
       48BAA82E1B3FFC7F0000 mov      rdx, 0x7FFC3F1B2EA8
       E8B47EC55F           call     CORINFO_HELP_CLASSPROFILE32
       488B4DF8             mov      rcx, gword ptr [rbp-08H]
       48894DF0             mov      gword ptr [rbp-10H], rcx
       488B4DF0             mov      rcx, gword ptr [rbp-10H]
       33D2                 xor      edx, edx
       817D18FFFFFF7F       cmp      dword ptr [rbp+18H], 0x7FFFFFFF
       0F94C2               sete     dl
       49BB0800F13EFC7F0000 mov      r11, 0x7FFC3EF10008
       41FF13               call     [r11]IPrinter:PrintIfTrue(bool):this
       90                   nop

G_M000_IG03:                ;; offset=0062H
       4883C430             add      rsp, 48
       5D                   pop      rbp
       C3                   ret

而值得注意的是,你可以看到调用[r11]IPrinter:PrintIfTrue(bool):这个做接口调度。但是,再看一下为层生成的代码。我们仍然看到调用[r11]IPrinter:PrintIfTrue(bool):this,但我们也看到了这个。

G_M000_IG02:                ;; offset=0020H
       48B9982D1B3FFC7F0000 mov      rcx, 0x7FFC3F1B2D98
       48390F               cmp      qword ptr [rdi], rcx
       7521                 jne      SHORT G_M000_IG05
       81FEFFFFFF7F         cmp      esi, 0x7FFFFFFF
       7404                 je       SHORT G_M000_IG04

G_M000_IG03:                ;; offset=0037H
       FFC6                 inc      esi
       EBE5                 jmp      SHORT G_M000_IG02

G_M000_IG04:                ;; offset=003BH
       48B9D820801A24020000 mov      rcx, 0x2241A8020D8
       488B09               mov      rcx, gword ptr [rcx]
       FF1572CD0D00         call     [Console:WriteLine(String)]
       EBE7                 jmp      SHORT G_M000_IG03

块是检查IPrinter的具体类型(存储在rdi中)并与Printer的已知类型(0x7FFC3F1B2D98)进行比较。如果它们不一样,它就跳到它在未优化版本中做的同样的接口调度。但如果它们相同,它就会直接跳到Printer.PrintIfTrue的内联版本(你可以看到这个方法中对Console:WriteLine的调用)。因此,普通情况(本例中的情况)是超级有效的,代价是一个单一的比较和分支。

这一切都存在于.NET 6中,那么为什么我们现在要谈论它?有几件事得到了改善。首先,由于dotnet/runtime#61453这样的改进,PGO现在可以与OSR一起工作。这是一个大问题,因为这意味着做这种接口调度的热的长期运行的方法(这相当普遍)可以得到这些类型的去虚拟化/精简优化。第二,虽然PGO目前不是默认启用的,但我们已经让它更容易打开了。在dotnet/runtime#71438和dotnet/sdk#26350之间,现在可以简单地将true放入你的.csproj中。 csproj,它的效果和你在每次调用应用程序之前设置DOTNET_TieredPGO=1一样,启用动态PGO(注意,它不会禁止使用R2R图像,所以如果你希望整个核心库也采用动态PGO,你还需要设置DOTNET_ReadyToRun=0)。然而,第三,是动态PGO已经学会了如何检测和优化额外的东西。

PGO已经知道如何对虚拟调度进行检测。现在在.NET 7中,在很大程度上要感谢dotnet/runtime#68703,它也可以为委托做这件事(至少是对实例方法的委托)。考虑一下这个简单的控制台应用程序。

using System.Runtime.CompilerServices;

class Program
{
    static int[] s_values = Enumerable.Range(, 1_000).ToArray();

    static void Main()
    {
        for (int i = ; i < 1_000_000; i++)
            Sum(s_values, i => i * 42);
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    static int Sum(int[] values, Func<int, int> func)
    {
        int sum = ;
        foreach (int value in values)
            sum += func(value);
        return sum;
    }
}

在没有启用PGO的情况下,我得到的优化汇编是这样的。

; Assembly listing for method Program:Sum(ref,Func`2):int
; Emitting BLENDED_CODE for X64 CPU with AVX - Windows
; Tier-1 compilation
; optimized code
; rsp based frame
; partially interruptible
; No PGO data

G_M000_IG01:                ;; offset=0000H
       4156                 push     r14
       57                   push     rdi
       56                   push     rsi
       55                   push     rbp
       53                   push     rbx
       4883EC20             sub      rsp, 32
       488BF2               mov      rsi, rdx

G_M000_IG02:                ;; offset=000DH
       33FF                 xor      edi, edi
       488BD9               mov      rbx, rcx
       33ED                 xor      ebp, ebp
       448B7308             mov      r14d, dword ptr [rbx+08H]
       4585F6               test     r14d, r14d
       7E16                 jle      SHORT G_M000_IG04

G_M000_IG03:                ;; offset=001DH
       8BD5                 mov      edx, ebp
       8B549310             mov      edx, dword ptr [rbx+4*rdx+10H]
       488B4E08             mov      rcx, gword ptr [rsi+08H]
       FF5618               call     [rsi+18H]Func`2:Invoke(int):int:this
       03F8                 add      edi, eax
       FFC5                 inc      ebp
       443BF5               cmp      r14d, ebp
       7FEA                 jg       SHORT G_M000_IG03

G_M000_IG04:                ;; offset=0033H
       8BC7                 mov      eax, edi

G_M000_IG05:                ;; offset=0035H
       4883C420             add      rsp, 32
       5B                   pop      rbx
       5D                   pop      rbp
       5E                   pop      rsi
       5F                   pop      rdi
       415E                 pop      r14
       C3                   ret

; Total bytes of code 64

注意其中调用[rsi+18H]Func`2:Invoke(int):int:this来调用委托。现在启用了PGO。

; Assembly listing for method Program:Sum(ref,Func`2):int
; Emitting BLENDED_CODE for X64 CPU with AVX - Windows
; Tier-1 compilation
; optimized code
; optimized using profile data
; rsp based frame
; fully interruptible
; with Dynamic PGO: edge weights are valid, and fgCalledCount is 5628
; 0 inlinees with PGO data; 1 single block inlinees; 0 inlinees without PGO data

G_M000_IG01:                ;; offset=0000H
       4157                 push     r15
       4156                 push     r14
       57                   push     rdi
       56                   push     rsi
       55                   push     rbp
       53                   push     rbx
       4883EC28             sub      rsp, 40
       488BF2               mov      rsi, rdx

G_M000_IG02:                ;; offset=000FH
       33FF                 xor      edi, edi
       488BD9               mov      rbx, rcx
       33ED                 xor      ebp, ebp
       448B7308             mov      r14d, dword ptr [rbx+08H]
       4585F6               test     r14d, r14d
       7E27                 jle      SHORT G_M000_IG05

G_M000_IG03:                ;; offset=001FH
       8BC5                 mov      eax, ebp
       8B548310             mov      edx, dword ptr [rbx+4*rax+10H]
       4C8B4618             mov      r8, qword ptr [rsi+18H]
       48B8A0C2CF3CFC7F0000 mov      rax, 0x7FFC3CCFC2A0
       4C3BC0               cmp      r8, rax
       751D                 jne      SHORT G_M000_IG07
       446BFA2A             imul     r15d, edx, 42

G_M000_IG04:                ;; offset=003CH
       4103FF               add      edi, r15d
       FFC5                 inc      ebp
       443BF5               cmp      r14d, ebp
       7FD9                 jg       SHORT G_M000_IG03

G_M000_IG05:                ;; offset=0046H
       8BC7                 mov      eax, edi

G_M000_IG06:                ;; offset=0048H
       4883C428             add      rsp, 40
       5B                   pop      rbx
       5D                   pop      rbp
       5E                   pop      rsi
       5F                   pop      rdi
       415E                 pop      r14
       415F                 pop      r15
       C3                   ret

G_M000_IG07:                ;; offset=0055H
       488B4E08             mov      rcx, gword ptr [rsi+08H]
       41FFD0               call     r8
       448BF8               mov      r15d, eax
       EBDB                 jmp      SHORT G_M000_IG04

我选择了i => i * 42中的42常数,以使其在汇编中容易看到,果然,它就在那里。

G_M000_IG03:                ;; offset=001FH
       8BC5                 mov      eax, ebp
       8B548310             mov      edx, dword ptr [rbx+4*rax+10H]
       4C8B4618             mov      r8, qword ptr [rsi+18H]
       48B8A0C2CF3CFC7F0000 mov      rax, 0x7FFC3CCFC2A0
       4C3BC0               cmp      r8, rax
       751D                 jne      SHORT G_M000_IG07
       446BFA2A             imul     r15d, edx, 42

这是从委托中加载目标地址到r8,并加载预期目标的地址到rax。如果它们相同,它就简单地执行内联操作(imul r15d, edx, 42),否则就跳转到G_M000_IG07,调用r8的函数。如果我们把它作为一个基准运行,其效果是显而易见的。

static int[] s_values = Enumerable.Range(, 1_000).ToArray();

[Benchmark]
public int DelegatePGO() => Sum(s_values, i => i * 42);

static int Sum(int[] values, Func<int, int>? func)
{
    int sum = ;
    foreach (int value in values)
    {
        sum += func(value);
    }
    return sum;
}

在禁用PGO的情况下,我们在.NET 6和.NET 7中得到了相同的性能吞吐量。

方法运行时间平均值比率
DelegatePGO.NET 6.01.665 us1.00
DelegatePGO.NET 7.01.659 us1.00

相关文章