asp.net使用三层架构的意义(在.Net7中性能改进-PGO)

本文是 Performance Improvements in .Net 7 PGO部分的翻译.下面开始正文:,我来为大家科普一下关于asp.net使用三层架构的意义?以下内容希望对你有帮助!

asp.net使用三层架构的意义(在.Net7中性能改进-PGO)

asp.net使用三层架构的意义

前言

本文是 Performance Improvements in .Net 7 PGO部分的翻译.下面开始正文:

//原文地址: https://devblogs.microsoft.com/DOTNET/performance_improvements_in_net_7/

我在我的 Performance Improvements in .NET 6 文章中写过关于配置文件引导优化(PGO)的内容,但在这里我将再次介绍它,因为它为.net 7带来了大量的改进.

// Performance Improvements in .NET 6 //地址: https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-6/

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

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

class Program { static void Main() { IPrinter printer = new Printer(); for (int i = 0; ; 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的tier-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

值得注意的是,你可以看到调用call [r11]IPrinter:PrintIfTrue(bool):this做接口分发.但是,接下来看看为第1层生成的代码.我们仍然看到call [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的调用)内联版本.因此,通常的情况(本例中唯一的情况)是以单个比较和分支为代价的超级高效.

在.NET6中已经有了,那么为什么我们现在要讨论它呢?有几个方面有所改善.首先,由于dotnet/runtime#61453等改进,PGO现在与OSR一起工作.这是一件大事,因为这意味着执行这种接口分发(这是相当常见的)的长时间运行的热方法可以获得这种类型的去虚拟化/内联优化.第二,虽然PGO目前没有默认启用,但我们已经让它更容易启用.在dotnet/runtime#71438和dotnet/sdk#26350之间,现在可以简单地把 <TieredPGO>true</TieredPGO> 在你的项目工程文件(*.csproj)中,它会有相同的效果,如果你设置DOTNET_TieredPGO=1之前的应用程序调用,启用动态PGO(注意,它不禁用R2R镜像,所以如果你想要整个核心库也采用动态PGO,你还需要设置DOTNET_ReadyToRun=0).第三,动态PGO已经学会了如何测量和优化附加的东西.

using System.Runtime.CompilerServices; class Program { static int[] s_values = Enumerable.Range(0, 1_000).ToArray(); static void Main() { for (int i = 0; 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 = 0; 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

注意 call [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(0, 1_000).ToArray(); [Benchmark] public int DelegatePGO() => Sum(s_values, i => i * 42); static int Sum(int[] values, Func<int, int>? func) { int sum = 0; foreach (int value in values) { sum = func(value); } return sum; }

禁用PGO后,我们在.Net 6和.Net 7上获得了相同的性能吞吐量:

Method

Runtime

Mean

Ratio

DelegatePGO

.NET 6.0

1.665 us

1.00

DelegatePGO

.NET 7.0

1.659 us

1.00

但是当我们启用动态PGO(DOTNET_TieredPGO=1)后,情况就不同了, .Net 6会快14%,而.Net 7会快3倍!

Method

Runtime

Mean

Ratio

DelegatePGO

.NET 6.0

1,427.7 ns

1.00

DelegatePGO

.NET 7.0

539.0 ns

0.38

dotnet/runtime#70377是动态PGO的另一个有价值的改进,它使PGO能够很好地进行循环克隆和不变量提升.为了更好地理解这一点,我们稍微离题一下这些是什么.循环克隆是JIT用于避免循环快速路径中的各种开销的一种机制.考虑下面这个例子中的Test方法:

using System.Runtime.CompilerServices; class Program { static void Main() { int[] array = new int[10_000_000]; for (int i = 0; i < 1_000_000; i ) { Test(array); } } [MethodImpl(MethodImplOptions.NoInlining)] private static bool Test(int[] array) { for (int i = 0; i < 0x12345; i ) { if (array[i] == 42) { return true; } } return false; } }

JIT不知道传入的数组是否足够长,使得循环中所有对array[i]的访问都在边界内,因此它需要为每次访问注入边界检查.虽然在前面简单地进行长度检查并在长度不够时提前抛出异常是一件好事,但这样做也会改变行为(想象一下这个方法正在写入数组,或者改变一些共享状态).相反,JIT使用“循环克隆”.它本质上重写了Test方法,使其更像这样:

if (array is not null && array.Length >= 0x12345) { for (int i = 0; i < 0x12345; i ) { if (array[i] == 42) // no bounds checks emitted for this access :-) { return true; } } } else { for (int i = 0; i < 0x12345; i ) { if (array[i] == 42) // bounds checks emitted for this access :-( { return true; } } } return false;

这样,以牺牲一些代码重复为代价,我们得到了没有边界检查的快速循环,并且只支付了慢路径上的边界检查.你可以在生成的程序集中看到这个(如果你还不知道DOTNET_JitDisasm是我在.Net 7中最喜欢的特性之一):

// Assembly listing for method Program:Test(ref):bool // Emitting BLENDED_CODE for X64 CPU with AVX - Windows // Tier-1 compilation // optimized code // rsp based frame // fully interruptible // No PGO data G_M000_IG01: // offset=0000H 4883EC28 sub rsp, 40 G_M000_IG02: // offset=0004H 33C0 xor eax, eax 4885C9 test rcx, rcx 7429 je SHORT G_M000_IG05 81790845230100 cmp dword ptr [rcx 08H], 0x12345 7C20 jl SHORT G_M000_IG05 0F1F40000F1F840000000000 align [12 bytes for IG03] G_M000_IG03: // offset=0020H 8BD0 mov edx, eax 837C91102A cmp dword ptr [rcx 4*rdx 10H], 42 7429 je SHORT G_M000_IG08 FFC0 inc eax 3D45230100 cmp eax, 0x12345 7CEE jl SHORT G_M000_IG03 G_M000_IG04: // offset=0032H EB17 jmp SHORT G_M000_IG06 G_M000_IG05: // offset=0034H 3B4108 cmp eax, dword ptr [rcx 08H] 7323 jae SHORT G_M000_IG10 8BD0 mov edx, eax 837C91102A cmp dword ptr [rcx 4*rdx 10H], 42 7410 je SHORT G_M000_IG08 FFC0 inc eax 3D45230100 cmp eax, 0x12345 7CE9 jl SHORT G_M000_IG05 G_M000_IG06: // offset=004BH 33C0 xor eax, eax G_M000_IG07: // offset=004DH 4883C428 add rsp, 40 C3 ret G_M000_IG08: // offset=0052H B801000000 mov eax, 1 G_M000_IG09: // offset=0057H 4883C428 add rsp, 40 C3 ret G_M000_IG10: // offset=005CH E81FA0C15F call CORINFO_HELP_RNGCHKFAIL CC int3 // Total bytes of code 98

G_M000_IG02块执行null检查和长度检查,如果失败,则跳转到G_M000_IG05块.如果两者都成功了,它就会执行循环(block G_M000_IG03),不进行边界检查:

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

边界检查只出现在慢路径块中:

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

这是“循环克隆”.那什么是"不变提升”呢? 提升是把某个东西从循环中拉出来放到循环之前,不变量是不变的东西.因此,不变提升是在循环之前从循环中拉出一些东西,以避免在每次循环迭代中重新计算一个不变的答案.实际上,前面的例子已经展示了不变量提升,因为边界检查被移动到循环之前,而不是在循环中,但一个更具体的例子应该是这样的:

[MethodImpl(MethodImplOptions.NoInlining)] private static bool Test(int[] array) { for (int i = 0; i < 0x12345; i ) { if (array[i] == array.Length - 42) { return true; } } return false; }

注意数组的值为array.Length - 42在每次循环迭代中不会改变,所以它对循环迭代是“不变的”,可以被取出,生成的代码会这样做:

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

在这里,我们再次检查数组是否为null( test rcx, rcx )和数组的长度被检查( mov r8d, dword ptr [rcx 08H] 然后cmp r8d, 0x12345),但然后与数组的长度在 r8d ,然后我们看到这个数组长度减去42( add r8d, -42 ),这是在我们继续进入快速路径循环在G_M000_IG03块之前.这样就可以将那组额外的操作排除在循环之外,从而避免了每次迭代重新计算值的开销.

那么这如何应用于动态PGO呢?请记住,对于PGO能够做到避免接口/虚拟分发,它通过进行类型检查来查看所使用的类型是否为最常见的类型;如果是,则使用直接调用该类型的方法的快速路径(在这样做时,该调用可能会内联),如果不是,则返回到正常的接口/虚拟分发.该检查对循环是不变的.因此,当一个方法被分层并启用PGO时,类型检查现在可以从循环中升起,这使得处理常见情况的成本更低.考虑一下我们原来例子的变化:

using System.Runtime.CompilerServices; class Program { static void Main() { IPrinter printer = new BlankPrinter(); while (true) { DoWork(printer); } } [MethodImpl(MethodImplOptions.NoInlining)] static void DoWork(IPrinter printer) { for (int j = 0; j < 123; j ) { printer.Print(j); } } interface IPrinter { void Print(int i); } class BlankPrinter : IPrinter { public void Print(int i) { Console.Write(""); } } }

当我们查看在启用动态PGO的情况下为其生成优化的汇编代码,我们看到:

// Assembly listing for method Program:DoWork(IPrinter) // Emitting BLENDED_CODE for X64 CPU with AVX - Windows // Tier-1 compilation // optimized code // optimized using profile data // rsp based frame // partially interruptible // with Dynamic PGO: edge weights are invalid, and fgCalledCount is 12187 // 0 inlinees with PGO data; 1 single block inlinees; 0 inlinees without PGO data G_M000_IG01: // offset=0000H 57 push rdi 56 push rsi 4883EC28 sub rsp, 40 488BF1 mov rsi, rcx G_M000_IG02: // offset=0009H 33FF xor edi, edi 4885F6 test rsi, rsi 742B je SHORT G_M000_IG05 48B9982DD43CFC7F0000 mov rcx, 0x7FFC3CD42D98 48390E cmp qword ptr [rsi], rcx 751C jne SHORT G_M000_IG05 G_M000_IG03: // offset=001FH 48B9282040F948020000 mov rcx, 0x248F9402028 488B09 mov rcx, gword ptr [rcx] FF1526A80D00 call [Console:Write(String)] FFC7 inc edi 83FF7B cmp edi, 123 7CE6 jl SHORT G_M000_IG03 G_M000_IG04: // offset=0039H EB29 jmp SHORT G_M000_IG07 G_M000_IG05: // offset=003BH 48B9982DD43CFC7F0000 mov rcx, 0x7FFC3CD42D98 48390E cmp qword ptr [rsi], rcx 7521 jne SHORT G_M000_IG08 48B9282040F948020000 mov rcx, 0x248F9402028 488B09 mov rcx, gword ptr [rcx] FF15FBA70D00 call [Console:Write(String)] G_M000_IG06: // offset=005DH FFC7 inc edi 83FF7B cmp edi, 123 7CD7 jl SHORT G_M000_IG05 G_M000_IG07: // offset=0064H 4883C428 add rsp, 40 5E pop rsi 5F pop rdi C3 ret G_M000_IG08: // offset=006BH 488BCE mov rcx, rsi 8BD7 mov edx, edi 49BB1000AA3CFC7F0000 mov r11, 0x7FFC3CAA0010 41FF13 call [r11]IPrinter:Print(int):this EBDE jmp SHORT G_M000_IG06 // Total bytes of code 127

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

有趣的是,这样的改进也会带来挑战.PGO导致类型检查数量的显著增加,因为专门化给定类型的调用站点需要与该类型进行比较.然而,常见的子表达式消除(CSE)在历史上并不适用于这种类型句柄(CSE是一种编译器优化,通过一次计算结果然后存储它以供后续使用,而不是每次重新计算它,从而消除重复表达式).dotnet/runtime#70580通过为这些常量句柄启用CSE修复了这个问题.例如,考虑以下方法:

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

在.Net 6中JIT生成了以下汇编代码:

// Program.AllAreStrings(System.Object, System.Object, System.Object, System.Object) test rdx,rdx je short M00_L01 mov rax,offset MT_System.String ;;第1次加载 cmp [rdx],rax jne short M00_L01 test r8,r8 je short M00_L01 mov rax,offset MT_System.String ;;第2次加载 cmp [r8],rax jne short M00_L01 test r9,r9 je short M00_L01 mov rax,offset MT_System.String ;;第3次加载 cmp [r9],rax jne short M00_L01 mov rax,[rsp 28] test rax,rax je short M00_L00 mov rdx,offset MT_System.String ;;第4次加载 cmp [rax],rdx je short M00_L00 xor eax,eax M00_L00: test rax,rax setne al movzx eax,al ret M00_L01: xor eax,eax ret // Total bytes of code 100

注意,C#有四个string(字符串)test(逻辑与运算,汇编代码有四个加载mov rax,offset MT_System.String.现在在.Net 7中进行一次加载:

Program.AllAreStrings(System.Object, System.Object, System.Object, System.Object) test rdx,rdx je short M00_L01 mov rax,offset MT_System.String ;;只有1次加载 cmp [rdx],rax jne short M00_L01 test r8,r8 je short M00_L01 cmp [r8],rax jne short M00_L01 test r9,r9 je short M00_L01 cmp [r9],rax jne short M00_L01 mov rdx,[rsp 28] test rdx,rdx je short M00_L00 cmp [rdx],rax je short M00_L00 xor edx,edx M00_L00: xor eax,eax test rdx,rdx setne al ret M00_L01: xor eax,eax ret // Total bytes of code 69

因JIT部分内容太多,这里进行拆分,PGO就到了,Bounds Check Elimination(消除边界检查)拆分为一篇博文.

个人能力有限,如果您发现有什么不对,请私信我

如果您觉得对您有用的话,可以点个赞或者加个关注,欢迎大家一起进行技术交流

,

免责声明:本文仅代表文章作者的个人观点,与本站无关。其原创性、真实性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容文字的真实性、完整性和原创性本站不作任何保证或承诺,请读者仅作参考,并自行核实相关内容。文章投诉邮箱:anhduc.ph@yahoo.com

    分享
    投诉
    首页