当前位置:   article > 正文

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

test rcx,rcx

 点击上方“DotNet NB”关注公众号

回复“1”获取开发者路线图

38edd62315a306600d81f955fcce86f5.gif

学习分享 丨作者 / 郑 子 铭    

这是DotNet NB 公众号的第208篇原创文章

原文 | Stephen Toub

翻译 | 郑子铭

PGO

我在我的 .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 上运行:

  1. class Program
  2. {
  3. static void Main()
  4. {
  5. IPrinter printer = new Printer();
  6. for (int i = 0; ; i++)
  7. {
  8. DoWork(printer, i);
  9. }
  10. }
  11. static void DoWork(IPrinter printer, int i)
  12. {
  13. printer.PrintIfTrue(i == int.MaxValue);
  14. }
  15. interface IPrinter
  16. {
  17. void PrintIfTrue(bool condition);
  18. }
  19. class Printer : IPrinter
  20. {
  21. public void PrintIfTrue(bool condition)
  22. {
  23. if (condition) Console.WriteLine("Print!");
  24. }
  25. }
  26. }

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

  1. G_M000_IG01: ;; offset=0000H
  2. 55 push rbp
  3. 4883EC30 sub rsp, 48
  4. 488D6C2430 lea rbp, [rsp+30H]
  5. 33C0 xor eax, eax
  6. 488945F8 mov qword ptr [rbp-08H], rax
  7. 488945F0 mov qword ptr [rbp-10H], rax
  8. 48894D10 mov gword ptr [rbp+10H], rcx
  9. 895518 mov dword ptr [rbp+18H], edx
  10. G_M000_IG02: ;; offset=001BH
  11. FF059F220F00 inc dword ptr [(reloc 0x7ffc3f1b2ea0)]
  12. 488B4D10 mov rcx, gword ptr [rbp+10H]
  13. 48894DF8 mov gword ptr [rbp-08H], rcx
  14. 488B4DF8 mov rcx, gword ptr [rbp-08H]
  15. 48BAA82E1B3FFC7F0000 mov rdx, 0x7FFC3F1B2EA8
  16. E8B47EC55F call CORINFO_HELP_CLASSPROFILE32
  17. 488B4DF8 mov rcx, gword ptr [rbp-08H]
  18. 48894DF0 mov gword ptr [rbp-10H], rcx
  19. 488B4DF0 mov rcx, gword ptr [rbp-10H]
  20. 33D2 xor edx, edx
  21. 817D18FFFFFF7F cmp dword ptr [rbp+18H], 0x7FFFFFFF
  22. 0F94C2 sete dl
  23. 49BB0800F13EFC7F0000 mov r11, 0x7FFC3EF10008
  24. 41FF13 call [r11]IPrinter:PrintIfTrue(bool):this
  25. 90 nop
  26. G_M000_IG03: ;; offset=0062H
  27. 4883C430 add rsp, 48
  28. 5D pop rbp
  29. C3 ret

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

  1. G_M000_IG02: ;; offset=0020H
  2. 48B9982D1B3FFC7F0000 mov rcx, 0x7FFC3F1B2D98
  3. 48390F cmp qword ptr [rdi], rcx
  4. 7521 jne SHORT G_M000_IG05
  5. 81FEFFFFFF7F cmp esi, 0x7FFFFFFF
  6. 7404 je SHORT G_M000_IG04
  7. G_M000_IG03: ;; offset=0037H
  8. FFC6 inc esi
  9. EBE5 jmp SHORT G_M000_IG02
  10. G_M000_IG04: ;; offset=003BH
  11. 48B9D820801A24020000 mov rcx, 0x2241A8020D8
  12. 488B09 mov rcx, gword ptr [rcx]
  13. FF1572CD0D00 call [Console:WriteLine(String)]
  14. 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,它也可以为委托做这件事(至少是对实例方法的委托)。考虑一下这个简单的控制台应用程序。

  1. using System.Runtime.CompilerServices;
  2. class Program
  3. {
  4. static int[] s_values = Enumerable.Range(0, 1_000).ToArray();
  5. static void Main()
  6. {
  7. for (int i = 0; i < 1_000_000; i++)
  8. Sum(s_values, i => i * 42);
  9. }
  10. [MethodImpl(MethodImplOptions.NoInlining)]
  11. static int Sum(int[] values, Func<int, int> func)
  12. {
  13. int sum = 0;
  14. foreach (int value in values)
  15. sum += func(value);
  16. return sum;
  17. }
  18. }

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

  1. ; Assembly listing for method Program:Sum(ref,Func`2):int
  2. ; Emitting BLENDED_CODE for X64 CPU with AVX - Windows
  3. ; Tier-1 compilation
  4. ; optimized code
  5. ; rsp based frame
  6. ; partially interruptible
  7. ; No PGO data
  8. G_M000_IG01: ;; offset=0000H
  9. 4156 push r14
  10. 57 push rdi
  11. 56 push rsi
  12. 55 push rbp
  13. 53 push rbx
  14. 4883EC20 sub rsp, 32
  15. 488BF2 mov rsi, rdx
  16. G_M000_IG02: ;; offset=000DH
  17. 33FF xor edi, edi
  18. 488BD9 mov rbx, rcx
  19. 33ED xor ebp, ebp
  20. 448B7308 mov r14d, dword ptr [rbx+08H]
  21. 4585F6 test r14d, r14d
  22. 7E16 jle SHORT G_M000_IG04
  23. G_M000_IG03: ;; offset=001DH
  24. 8BD5 mov edx, ebp
  25. 8B549310 mov edx, dword ptr [rbx+4*rdx+10H]
  26. 488B4E08 mov rcx, gword ptr [rsi+08H]
  27. FF5618 call [rsi+18H]Func`2:Invoke(int):int:this
  28. 03F8 add edi, eax
  29. FFC5 inc ebp
  30. 443BF5 cmp r14d, ebp
  31. 7FEA jg SHORT G_M000_IG03
  32. G_M000_IG04: ;; offset=0033H
  33. 8BC7 mov eax, edi
  34. G_M000_IG05: ;; offset=0035H
  35. 4883C420 add rsp, 32
  36. 5B pop rbx
  37. 5D pop rbp
  38. 5E pop rsi
  39. 5F pop rdi
  40. 415E pop r14
  41. C3 ret
  42. ; Total bytes of code 64

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

  1. ; Assembly listing for method Program:Sum(ref,Func`2):int
  2. ; Emitting BLENDED_CODE for X64 CPU with AVX - Windows
  3. ; Tier-1 compilation
  4. ; optimized code
  5. ; optimized using profile data
  6. ; rsp based frame
  7. ; fully interruptible
  8. ; with Dynamic PGO: edge weights are valid, and fgCalledCount is 5628
  9. ; 0 inlinees with PGO data; 1 single block inlinees; 0 inlinees without PGO data
  10. G_M000_IG01: ;; offset=0000H
  11. 4157 push r15
  12. 4156 push r14
  13. 57 push rdi
  14. 56 push rsi
  15. 55 push rbp
  16. 53 push rbx
  17. 4883EC28 sub rsp, 40
  18. 488BF2 mov rsi, rdx
  19. G_M000_IG02: ;; offset=000FH
  20. 33FF xor edi, edi
  21. 488BD9 mov rbx, rcx
  22. 33ED xor ebp, ebp
  23. 448B7308 mov r14d, dword ptr [rbx+08H]
  24. 4585F6 test r14d, r14d
  25. 7E27 jle SHORT G_M000_IG05
  26. G_M000_IG03: ;; offset=001FH
  27. 8BC5 mov eax, ebp
  28. 8B548310 mov edx, dword ptr [rbx+4*rax+10H]
  29. 4C8B4618 mov r8, qword ptr [rsi+18H]
  30. 48B8A0C2CF3CFC7F0000 mov rax, 0x7FFC3CCFC2A0
  31. 4C3BC0 cmp r8, rax
  32. 751D jne SHORT G_M000_IG07
  33. 446BFA2A imul r15d, edx, 42
  34. G_M000_IG04: ;; offset=003CH
  35. 4103FF add edi, r15d
  36. FFC5 inc ebp
  37. 443BF5 cmp r14d, ebp
  38. 7FD9 jg SHORT G_M000_IG03
  39. G_M000_IG05: ;; offset=0046H
  40. 8BC7 mov eax, edi
  41. G_M000_IG06: ;; offset=0048H
  42. 4883C428 add rsp, 40
  43. 5B pop rbx
  44. 5D pop rbp
  45. 5E pop rsi
  46. 5F pop rdi
  47. 415E pop r14
  48. 415F pop r15
  49. C3 ret
  50. G_M000_IG07: ;; offset=0055H
  51. 488B4E08 mov rcx, gword ptr [rsi+08H]
  52. 41FFD0 call r8
  53. 448BF8 mov r15d, eax
  54. EBDB jmp SHORT G_M000_IG04

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

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

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

  1. static int[] s_values = Enumerable.Range(0, 1_000).ToArray();
  2. [Benchmark]
  3. public int DelegatePGO() => Sum(s_values, i => i * 42);
  4. static int Sum(int[] values, Func<int, int>? func)
  5. {
  6. int sum = 0;
  7. foreach (int value in values)
  8. {
  9. sum += func(value);
  10. }
  11. return sum;
  12. }

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

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

但当我们启用动态PGO(DOTNET_TieredPGO=1)时,情况发生了变化。.NET 6的速度提高了~14%,但.NET 7的速度提高了~3倍!

方法运行时平均值比率
DelegatePGO.NET 6.01,427.7 ns1.00
DelegatePGO.NET 7.0539.0 ns0.38

dotnet/runtime#70377是动态PGO的另一个有价值的改进,它使PGO能够很好地发挥循环克隆和不变量提升的作用。为了更好地理解这一点,简要地说说这些是什么。循环克隆 (Loop cloning) 是JIT采用的一种机制,以避免循环的快速路径中的各种开销。考虑一下本例中的Test方法。

  1. using System.Runtime.CompilerServices;
  2. class Program
  3. {
  4. static void Main()
  5. {
  6. int[] array = new int[10_000_000];
  7. for (int i = 0; i < 1_000_000; i++)
  8. {
  9. Test(array);
  10. }
  11. }
  12. [MethodImpl(MethodImplOptions.NoInlining)]
  13. private static bool Test(int[] array)
  14. {
  15. for (int i = 0; i < 0x12345; i++)
  16. {
  17. if (array[i] == 42)
  18. {
  19. return true;
  20. }
  21. }
  22. return false;
  23. }
  24. }

JIT不知道传入的数组是否有足够的长度,以至于在循环中对数组[i]的所有访问都在边界内,因此它需要为每次访问注入边界检查。虽然简单地在前面进行长度检查,并在长度不够的情况下提前抛出一个异常是很好的,但这样做也会改变行为(设想该方法在进行时向数组中写入数据,或者以其他方式改变一些共享状态)。相反,JIT采用了 "循环克隆"。它从本质上重写了这个测试方法,使之更像这样。

  1. if (array is not null && array.Length >= 0x12345)
  2. {
  3. for (int i = 0; i < 0x12345; i++)
  4. {
  5. if (array[i] == 42) // no bounds checks emitted for this access :-)
  6. {
  7. return true;
  8. }
  9. }
  10. }
  11. else
  12. {
  13. for (int i = 0; i < 0x12345; i++)
  14. {
  15. if (array[i] == 42) // bounds checks emitted for this access :-(
  16. {
  17. return true;
  18. }
  19. }
  20. }
  21. return false;

这样一来,以一些代码重复为代价,我们得到了没有边界检查的快速循环,而只需支付慢速路径中的边界检查。你可以在生成的程序集中看到这一点(如果你还不明白,DOTNET_JitDisasm是.NET 7中我最喜欢的功能之一)。

  1. ; Assembly listing for method Program:Test(ref):bool
  2. ; Emitting BLENDED_CODE for X64 CPU with AVX - Windows
  3. ; Tier-1 compilation
  4. ; optimized code
  5. ; rsp based frame
  6. ; fully interruptible
  7. ; No PGO data
  8. G_M000_IG01: ;; offset=0000H
  9. 4883EC28 sub rsp, 40
  10. G_M000_IG02: ;; offset=0004H
  11. 33C0 xor eax, eax
  12. 4885C9 test rcx, rcx
  13. 7429 je SHORT G_M000_IG05
  14. 81790845230100 cmp dword ptr [rcx+08H], 0x12345
  15. 7C20 jl SHORT G_M000_IG05
  16. 0F1F40000F1F840000000000 align [12 bytes for IG03]
  17. G_M000_IG03: ;; offset=0020H
  18. 8BD0 mov edx, eax
  19. 837C91102A cmp dword ptr [rcx+4*rdx+10H], 42
  20. 7429 je SHORT G_M000_IG08
  21. FFC0 inc eax
  22. 3D45230100 cmp eax, 0x12345
  23. 7CEE jl SHORT G_M000_IG03
  24. G_M000_IG04: ;; offset=0032H
  25. EB17 jmp SHORT G_M000_IG06
  26. G_M000_IG05: ;; offset=0034H
  27. 3B4108 cmp eax, dword ptr [rcx+08H]
  28. 7323 jae SHORT G_M000_IG10
  29. 8BD0 mov edx, eax
  30. 837C91102A cmp dword ptr [rcx+4*rdx+10H], 42
  31. 7410 je SHORT G_M000_IG08
  32. FFC0 inc eax
  33. 3D45230100 cmp eax, 0x12345
  34. 7CE9 jl SHORT G_M000_IG05
  35. G_M000_IG06: ;; offset=004BH
  36. 33C0 xor eax, eax
  37. G_M000_IG07: ;; offset=004DH
  38. 4883C428 add rsp, 40
  39. C3 ret
  40. G_M000_IG08: ;; offset=0052H
  41. B801000000 mov eax, 1
  42. G_M000_IG09: ;; offset=0057H
  43. 4883C428 add rsp, 40
  44. C3 ret
  45. G_M000_IG10: ;; offset=005CH
  46. E81FA0C15F call CORINFO_HELP_RNGCHKFAIL
  47. CC int3
  48. ; Total bytes of code 98

G_M000_IG02部分正在进行空值检查和长度检查,如果任何一项失败,则跳转到G_M000_IG05块。如果两者都成功了,它就会执行循环(G_M000_IG03块)而不进行边界检查。

  1. G_M000_IG03: ;; offset=0020H
  2. 8BD0 mov edx, eax
  3. 837C91102A cmp dword ptr [rcx+4*rdx+10H], 42
  4. 7429 je SHORT G_M000_IG08
  5. FFC0 inc eax
  6. 3D45230100 cmp eax, 0x12345
  7. 7CEE jl SHORT G_M000_IG03

边界检查只显示在慢速路径块中。

  1. G_M000_IG05: ;; offset=0034H
  2. 3B4108 cmp eax, dword ptr [rcx+08H]
  3. 7323 jae SHORT G_M000_IG10
  4. 8BD0 mov edx, eax
  5. 837C91102A cmp dword ptr [rcx+4*rdx+10H], 42
  6. 7410 je SHORT G_M000_IG08
  7. FFC0 inc eax
  8. 3D45230100 cmp eax, 0x12345
  9. 7CE9 jl SHORT G_M000_IG05

这就是 "循环克隆"。那么,"不变量提升 (invariant hoisting) "呢?提升是指把某个东西从循环中拉到循环之前,而不变量是不会改变的东西。因此,不变量提升是指把某个东西从循环中拉到循环之前,以避免在循环的每个迭代中重新计算一个不会改变的答案。实际上,前面的例子已经展示了不变量提升,即边界检查被移到了循环之前,而不是在循环中,但一个更具体的例子是这样的。

  1. [MethodImpl(MethodImplOptions.NoInlining)]
  2. private static bool Test(int[] array)
  3. {
  4. for (int i = 0; i < 0x12345; i++)
  5. {
  6. if (array[i] == array.Length - 42)
  7. {
  8. return true;
  9. }
  10. }
  11. return false;
  12. }

注意,array.Length - 42的值在循环的每次迭代中都不会改变,所以它对循环迭代是 "不变的",可以被抬出来,生成的代码就是这样做的。

  1. G_M000_IG02: ;; offset=0004H
  2. 33D2 xor edx, edx
  3. 4885C9 test rcx, rcx
  4. 742A je SHORT G_M000_IG05
  5. 448B4108 mov r8d, dword ptr [rcx+08H]
  6. 4181F845230100 cmp r8d, 0x12345
  7. 7C1D jl SHORT G_M000_IG05
  8. 4183C0D6 add r8d, -42
  9. 0F1F4000 align [4 bytes for IG03]
  10. G_M000_IG03: ;; offset=0020H
  11. 8BC2 mov eax, edx
  12. 4439448110 cmp dword ptr [rcx+4*rax+10H], r8d
  13. 7433 je SHORT G_M000_IG08
  14. FFC2 inc edx
  15. 81FA45230100 cmp edx, 0x12345
  16. 7CED jl SHORT G_M000_IG03

这里我们再次看到数组被测试为空(test rcx, rcx),数组的长度被检查(mov r8d, dword ptr [rcx+08H] then cmp r8d, 0x12345),但是在r8d中有数组的长度,然后我们看到这个前期块从长度中减去42(add r8d, -42),这是在我们继续进入G_M000_IG03块的快速路径循环前。这使得额外的操作集不在循环中,从而避免了每次迭代重新计算数值的开销。

好的,那么这如何适用于动态PGO呢?请记住,对于PGO能够做到的界面/虚拟调度的规避,它是通过进行类型检查,看使用的类型是否是最常见的类型;如果是,它就使用直接调用该类型方法的快速路径(这样做的话,该调用有可能被内联),如果不是,它就回到正常的界面/虚拟调度。这种检查可以不受循环的影响。因此,当一个方法被分层,PGO启动时,类型检查现在可以从循环中提升出来,使得处理普通情况更加便宜。考虑一下我们原来的例子的这个变化。

  1. using System.Runtime.CompilerServices;
  2. class Program
  3. {
  4. static void Main()
  5. {
  6. IPrinter printer = new BlankPrinter();
  7. while (true)
  8. {
  9. DoWork(printer);
  10. }
  11. }
  12. [MethodImpl(MethodImplOptions.NoInlining)]
  13. static void DoWork(IPrinter printer)
  14. {
  15. for (int j = 0; j < 123; j++)
  16. {
  17. printer.Print(j);
  18. }
  19. }
  20. interface IPrinter
  21. {
  22. void Print(int i);
  23. }
  24. class BlankPrinter : IPrinter
  25. {
  26. public void Print(int i)
  27. {
  28. Console.Write("");
  29. }
  30. }
  31. }

当我们看一下在启用动态PGO的情况下为其生成的优化程序集时,我们看到了这个。

  1. ; Assembly listing for method Program:DoWork(IPrinter)
  2. ; Emitting BLENDED_CODE for X64 CPU with AVX - Windows
  3. ; Tier-1 compilation
  4. ; optimized code
  5. ; optimized using profile data
  6. ; rsp based frame
  7. ; partially interruptible
  8. ; with Dynamic PGO: edge weights are invalid, and fgCalledCount is 12187
  9. ; 0 inlinees with PGO data; 1 single block inlinees; 0 inlinees without PGO data
  10. G_M000_IG01: ;; offset=0000H
  11. 57 push rdi
  12. 56 push rsi
  13. 4883EC28 sub rsp, 40
  14. 488BF1 mov rsi, rcx
  15. G_M000_IG02: ;; offset=0009H
  16. 33FF xor edi, edi
  17. 4885F6 test rsi, rsi
  18. 742B je SHORT G_M000_IG05
  19. 48B9982DD43CFC7F0000 mov rcx, 0x7FFC3CD42D98
  20. 48390E cmp qword ptr [rsi], rcx
  21. 751C jne SHORT G_M000_IG05
  22. G_M000_IG03: ;; offset=001FH
  23. 48B9282040F948020000 mov rcx, 0x248F9402028
  24. 488B09 mov rcx, gword ptr [rcx]
  25. FF1526A80D00 call [Console:Write(String)]
  26. FFC7 inc edi
  27. 83FF7B cmp edi, 123
  28. 7CE6 jl SHORT G_M000_IG03
  29. G_M000_IG04: ;; offset=0039H
  30. EB29 jmp SHORT G_M000_IG07
  31. G_M000_IG05: ;; offset=003BH
  32. 48B9982DD43CFC7F0000 mov rcx, 0x7FFC3CD42D98
  33. 48390E cmp qword ptr [rsi], rcx
  34. 7521 jne SHORT G_M000_IG08
  35. 48B9282040F948020000 mov rcx, 0x248F9402028
  36. 488B09 mov rcx, gword ptr [rcx]
  37. FF15FBA70D00 call [Console:Write(String)]
  38. G_M000_IG06: ;; offset=005DH
  39. FFC7 inc edi
  40. 83FF7B cmp edi, 123
  41. 7CD7 jl SHORT G_M000_IG05
  42. G_M000_IG07: ;; offset=0064H
  43. 4883C428 add rsp, 40
  44. 5E pop rsi
  45. 5F pop rdi
  46. C3 ret
  47. G_M000_IG08: ;; offset=006BH
  48. 488BCE mov rcx, rsi
  49. 8BD7 mov edx, edi
  50. 49BB1000AA3CFC7F0000 mov r11, 0x7FFC3CAA0010
  51. 41FF13 call [r11]IPrinter:Print(int):this
  52. EBDE jmp SHORT G_M000_IG06
  53. ; Total bytes of code 127

我们可以在G_M000_IG02块中看到,它正在对IPrinter实例进行类型检查,如果检查失败就跳到G_M000_IG05(mov rcx, 0x7FFC3CD42D98 then cmp qword ptr [rsi], rcx then jne SHORT G_M000_IG05),否则就跳到G_M000_IG03,这是一个紧密的快速路径循环,内联BlankPrinter.Print,看不到任何类型检查。

有趣的是,这样的改进也会带来自己的挑战。PGO导致了类型检查数量的大幅增加,因为专门针对某一特定类型的调用站点需要与该类型进行比较。然而,普通的子表达式消除 (common subexpression elimination)(CSE)在历史上并不适用这种类型的句柄(CSE是一种编译器优化,通过计算一次结果,然后存储起来供以后使用,而不是每次都重新计算,来消除重复的表达式)。dotnet/runtime#70580通过对这种常量句柄启用CSE来解决这个问题。例如,考虑这个方法。

  1. [Benchmark]
  2. [Arguments("", "", "", "")]
  3. public bool AllAreStrings(object o1, object o2, object o3, object o4) =>
  4. o1 is string && o2 is string && o3 is string && o4 is string;

在.NET 6上,JIT产生了这个汇编代码:

  1. ; Program.AllAreStrings(System.Object, System.Object, System.Object, System.Object)
  2. test rdx,rdx
  3. je short M00_L01
  4. mov rax,offset MT_System.String
  5. cmp [rdx],rax
  6. jne short M00_L01
  7. test r8,r8
  8. je short M00_L01
  9. mov rax,offset MT_System.String
  10. cmp [r8],rax
  11. jne short M00_L01
  12. test r9,r9
  13. je short M00_L01
  14. mov rax,offset MT_System.String
  15. cmp [r9],rax
  16. jne short M00_L01
  17. mov rax,[rsp+28]
  18. test rax,rax
  19. je short M00_L00
  20. mov rdx,offset MT_System.String
  21. cmp [rax],rdx
  22. je short M00_L00
  23. xor eax,eax
  24. M00_L00:
  25. test rax,rax
  26. setne al
  27. movzx eax,al
  28. ret
  29. M00_L01:
  30. xor eax,eax
  31. ret
  32. ; Total bytes of code 100

请注意,C#对字符串有四个测试,而汇编代码中的mov rax,offset MT_System.String有四个加载。现在在.NET 7上,加载只执行一次。

  1. ; Program.AllAreStrings(System.Object, System.Object, System.Object, System.Object)
  2. test rdx,rdx
  3. je short M00_L01
  4. mov rax,offset MT_System.String
  5. cmp [rdx],rax
  6. jne short M00_L01
  7. test r8,r8
  8. je short M00_L01
  9. cmp [r8],rax
  10. jne short M00_L01
  11. test r9,r9
  12. je short M00_L01
  13. cmp [r9],rax
  14. jne short M00_L01
  15. mov rdx,[rsp+28]
  16. test rdx,rdx
  17. je short M00_L00
  18. cmp [rdx],rax
  19. je short M00_L00
  20. xor edx,edx
  21. M00_L00:
  22. xor eax,eax
  23. test rdx,rdx
  24. setne al
  25. ret
  26. M00_L01:
  27. xor eax,eax
  28. ret
  29. ; Total bytes of code 69

原文链接

Performance Improvements in .NET 7 

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/盐析白兔/article/detail/100724
推荐阅读
相关标签
  

闽ICP备14008679号