2021-07-28

利用 PGO 提升 .NET 程序性能

引子

.NET 6 开始初步引入 PGO。PGO 即 Profile Guided Optimization,通过收集运行时信息来指导 JIT 如何优化代码,相比以前没有 PGO 时可以做更多以前难以完成的优化。

下面我们用 .NET 6 的 nightly build 版本 6.0.100-rc.1.21377.6 来试试新的 PGO。

PGO 工具

.NET 6 提供了静态 PGO 和动态 PGO。前者通过工具收集 profile 数据,然后应用到下一次编译当中指导编译器如何进行代码优化;后者则直接在运行时一边收集 profile 数据一边进行优化。

另外由于从 .NET 5 开始引入了 OSR(On Stack Replacement),因此可以在运行时替换正在运行的函数,允许将正在运行的低优化代码迁移到高优化代码,例如替换一个热循环中的代码。

分层编译和 PGO

.NET 从 Core 3.1 开始正式引入了分层编译(Tiered Compilation),程序启动时 JIT 首先快速生成低优化的 tier 0 代码,由于优化代价小,因此 JIT 吞吐量很高,可以改善整体的延时。

然后随着程序运行,对多次调用的方法进行再次 JIT 产生高优化的 tier 1 代码,以提升程序的执行效率。

但是这么做对于程序的性能几乎没有提升,只是改善了延时,降低首次 JIT 的时间,却反而可能由于低优化代码导致性能倒退。因此我个人通常在开发客户端类程序的时候会关闭分层编译,而在开发服务器程序时开启分层编译。

然而 .NET 6 引入 PGO 后,分层编译的机制将变得非常重要。

由于 tier 0 的代码是低优化代码,因此更能够收集到完整的运行时 profile 数据,指导 JIT 做更全面的优化。

为什么这么说?

例如在 tier 1 代码中,某方法 B 被某方法 A 内联(inline),运行期间多次调用方法 A 后收集到了 profile 将只包含 A 的信息,而没有 B 的信息;又例如在 tier 1 代码中,某循环被 JIT 做了 loop cloning,那此时收集到的 profile 则是不准确的。

因此为了发挥 PGO 的最大效果,我们不仅需要开启分层编译,还需要给循环启用 Quick Jit 在一开始生成低优化代码。

进行优化

前面说了这么多,那 .NET 6 的 PGO 到底应该如何使用,又会如何对代码优化产生影响呢?这里举个例子。

测试代码

新建一个 .NET 6 控制台项目 PgoExperiment,考虑有如下代码:

interface IGenerator{ bool ReachEnd { get; } int Current { get; } bool MoveNext();}abstract class IGeneratorFactory{ public abstract IGenerator CreateGenerator();}class MyGenerator : IGenerator{ private int _current; public bool ReachEnd { get; private set; } public int Current { get; private set; } public bool MoveNext() {  if (ReachEnd)   {   return false;  }  _current++;  if (_current > 1000)  {   ReachEnd = true;   return false;  }  Current = _current;  return true; }}class MyGeneratorFactory : IGeneratorFactory{ public override IGenerator CreateGenerator()  {  return new MyGenerator(); }}

我们利用 IGeneratorFactory 产生 IGenerator,同时分别提供对应的一个实现 MyGeneratorFactoryMyGenerator。注意实现类并没有标注 sealed 因此 JIT 并不知道是否能做去虚拟化(devirtualization),于是生成的代码会老老实实查虚表。

然后我们编写测试代码:

[MethodImpl(MethodImplOptions.NoInlining)]int Test(IGeneratorFactory factory){ var generator = factory.CreateGenerator(); var result = 0; while (generator.MoveNext()) {  result += generator.Current; } return result;}var sw = Stopwatch.StartNew();var factory = new MyGeneratorFactory();for (var i = 0; i < 10; i++){ sw.Restart(); for (int j = 0; j < 1000000; j++) {  Test(factory); } sw.Stop(); Console.WriteLine($"Iteration {i}: {sw.ElapsedMilliseconds} ms.");}

你可能会问为什么不用 BenchmarkDotNet,因为这里要测试出 分层编译和 PGO 前后的区别,因此不能进行所谓的"预热"。

进行测试

测试环境:

  • CPU:2vCPU Intel(R) Xeon(R) Platinum 8171M CPU @ 2.60GHz
  • 内存:4G
  • 系统:Ubuntu 20.04.2 LTS
  • 程序运行配置:Release

不使用 PGO

首先采用默认参数运行:

dotnet run -c Release

得到结果:

Iteration 0: 740 ms.Iteration 1: 648 ms.Iteration 2: 687 ms.Iteration 3: 639 ms.Iteration 4: 643 ms.Iteration 5: 641 ms.Iteration 6: 641 ms.Iteration 7: 639 ms.Iteration 8: 644 ms.Iteration 9: 643 ms.

Mean = 656.5ms

你会发现 Iteration 0 用时比其他都要长一点,这符合预期,因为一开始执行的是 tier 0 的低优化代码,然后随着调用次数增加,JIT 重新生成 tier 1 的高优化代码。

然后我们关闭分层编译看看会怎么样:

dotnet run -c Release /p:TieredCompilation=false

得到结果:

Iteration 0: 677 ms.Iteration 1: 669 ms.Iteration 2: 677 ms.Iteration 3: 680 ms.Iteration 4: 683 ms.Iteration 5: 689 ms.Iteration 6: 677 ms.Iteration 7: 685 ms.Iteration 8: 676 ms.Iteration 9: 673 ms.

Mean = 678.6ms

这下就没有区别了,因为一开始生成的就是 tier 1 的高优化代码。

我们看看 JIT dump:

  push rbp  push r14  push rbx  lea  rbp,[rsp+10h]; factory.CreateGenerator()  mov  rax,[rdi]  mov  rax,[rax+40h]  call qword ptr [rax+20h]  mov  rbx,rax; var result = 0  xor  r14d,r14d; if (generator.MoveNext())  mov  rdi,rbx  mov  r11,7F3357AE0008h  mov  rax,7F3357AE0008h  call qword ptr [rax]  test eax,eax  je  short LBL_1LBL_0:; result += generator.Current;  mov  rdi,rbx  mov  r11,7F3357AE0010h  mov  rax,7F3357AE0010h  call qword ptr [rax]  add  r14d,eax; if (generator.MoveNext())  mov  rdi,rbx  mov  r11,7F3357AE0008h  mov  rax,7F3357AE0008h  call qword ptr [rax]  test eax,eax  jne  short LBL_0LBL_1:; return result;  mov  eax,r14d  pop  rbx  pop  r14  pop  rbp  ret

我用注释标注出了生成的代码中关键地方对应的 C# 写法,还原成 C# 代码大概是这个样子:

var generator = factory.CreateGenerator();var result = 0;do{ if (generator.MoveNext()) {  result += generator.Current; } else {  return result; }} while(true);

这里有不少有趣的地方:

  • while 循环被优化成了 do-while 循环,做了一次 loop inversion,以此来节省一次循环
  • generator.CreateGeneratorgenerator.MoveNext 以及 generator.Current 完全没有去虚拟化
  • 因为没有去虚拟化因此也无法做内联优化

这已经是 tier 1 代码了,也就是目前阶段 RyuJIT(.NET 6 的 JIT 编译器)在不借助任何指示编译器的 Attribute 以及 PGO 所能生成的最大优化等级的代码。

使用 PGO

这一次我们先看看启用动态 PGO 能得到怎样的结果。

为了使用动态 PGO,现阶段需要设置一些环境变量。

export DOTNET_ReadyToRun=0 # 禁用 AOTexport DOTNET_TieredPGO=1 # 开启分层 PGOexport DOTNET_TC_QuickJitForLoops=1 # 为循环启用 Quick Jit

然后运行即可:

dotnet run -c Release

得到如下结果:

Iteration 0: 349 ms.Iteration 1: 190 ms.Iteration 2: 188 ms.Iteration 3: 189 ms.Iteration 4: 190 ms.Iteration 5: 190 ms.Iteration 6: 189 ms.Iteration 7: 188 ms.Iteration 8: 191 ms.Iteration 9: 189 ms.

Mean = 205.3ms

得到了惊人的性能提升,只用了先前的 31% 的时间,相当于性能提升 322%。

然后我们试试静态 PGO + AOT 编译,AOT 负责在编译时预先生成优化后的代码。

为了使用静态 PGO,我们需要安装 dotnet-pgo 工具生成静态 PGO 数据,由于正式版尚未发布,因此需要添加如下 nuget 源:

<configuration> <packageSources> <add key="dotnet-public" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/index.json" /> <add key="dotnet-tools" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json" /> <add key="dotnet-eng" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json" /> <add key="dotnet6" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet6/nuget/v3/index.json" /> <add key="dotnet6-transport" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet6-transport/nuget/v3/index.json" /> </packageSources></configuration>

安装 dotnet-pgo 工具:

dotnet tool install dotnet-pgo --version 6.0.0-* -g

先运行程序采集 profile:

export DOTNET_EnableEventPipe=1export DOTNET_EventPipeConfig=Microsoft-Windows-DotNETRuntime:0x1F000080018:5export DOTNET_EventPipeOutputPath=trace.nettrace # 追踪文件输出路径export DOTNET_ReadyToRun=0 # 禁用 AOTexport DOTNET_TieredPGO=1 # 启用分层 PGOexport DOTNET_TC_CallCounting=0 # 永远不产生 tier 1 代码export DOTNET_TC_QuickJitForLoops=1export DOTNET_JitCollect64BitCounts=1dotnet run -c Release

等待程序运行完成,我们会得到一个 trace.nettrace 文件,里面包含了追踪数据,然后利用 dotnet-pgo 工具产生 PGO 数据。

dotnet-pgo create-mibc -t trace.nettrace -o pgo.mibc

至此我们就得到了一个 pgo.mibc,里面包含了 PGO 数据。

然后我们使用 crossgen2,在 PGO 数据的指导下对代码进行 AOT 编译:

dotnet publish -c Release -r linux-x64 /p:PublishReadyToRun=true /p:PublishReadyToRunComposite=true /p:PublishReadyToRunCrossgen2ExtraArgs=--embed-pgo-data%3b--mibc%3apgo.mibc

你可能会觉得这一系列步骤里面不少参数和环境变量都非常诡异,自然也是因为目前正式版还没有发布,因此名称和参数什么的都还没有规范化。

编译后我们运行编译后代码:

cd bin/Release/net6.0/linux-x64/publish./PgoExperiment

得到如下结果:

Iteration 0: 278 ms.Iteration 1: 185 ms.Iteration 2: 186 ms.Iteration 3: 187 ms.Iteration 4: 184 ms.Iteration 5: 187 ms.Iteration 6: 185 ms.Iteration 7: 183 ms.Iteration 8: 180 ms.Iteration 9: 186 ms.

Mean = 194.1ms

相比动态 PGO 而言,可以看出第一次用时更小,因为不需要经过 profile 收集后重新 JIT 的过程。

我们看看 PGO 数据指导下产生了怎样的代码:

  push rbp  push r15  push r14  push r12  push rbx  lea  rbp,[rsp+20h]; if (factory.GetType() == typeof(MyGeneratorFactory))  mov  rax,offset methodtable(MyGeneratorFactory)  cmp  [rdi],rax  jne  near ptr LBL_11; IGenerator generator = new MyGenerator()  mov  rdi,offset methodtable(MyGenerator)  call CORINFO_HELP_NEWSFAST  mov  rbx,raxLBL_0:; var result = 0  xor  r14d,r14d  jmp  short LBL_4LBL_1:; if (generator.GetType() == typeof(MyGenerator))  mov  rdi,offset methodtable(MyGenerator)  cmp  r15,rdi  jne  short LBL_6; result += generator.CurrentLBL_2:  mov  r12d,[rbx+0Ch]LBL_3:  add  r14d,r12dLBL_4:; if (generator.GetType() == typeof(MyGenerator))  mov  r15,[rbx]  mov  rax,offset methodtable(MyGenerator)  cmp  r15,rax  jne  short LBL_8; if (gen......

原文转载:http://www.shaoqun.com/a/901556.html

跨境电商:https://www.ikjzd.com/

gtc:https://www.ikjzd.com/w/974

eprice:https://www.ikjzd.com/w/1325

国际标准书号:https://www.ikjzd.com/w/174


引子.NET6开始初步引入PGO。PGO即ProfileGuidedOptimization,通过收集运行时信息来指导JIT如何优化代码,相比以前没有PGO时可以做更多以前难以完成的优化。下面我们用.NET6的nightlybuild版本6.0.100-rc.1.21377.6来试试新的PGO。PGO工具.NET6提供了静态PGO和动态PGO。前者通过工具收集profile数据,然后应用到下一次编
naver:https://www.ikjzd.com/w/1727
photobucket:https://www.ikjzd.com/w/132
云游四方|打卡荷兰"皇家"之城,乘坐海滩摩天轮看夕阳西下:http://www.30bags.com/a/246341.html
云游四方|古丝绸之路上的薰衣草田,比普罗旺斯更美:http://www.30bags.com/a/273353.html
云游四方|三月桃花与雪山、麦田、峡谷交织,只能在这里看到:http://www.30bags.com/a/245604.html
云游四方|探索非洲秘境,有沙滩、洞穴和能看到神奇动物的沼泽林:http://www.30bags.com/a/270357.html
趴在男朋友身上打光屁股 不吃饭被男朋友教训了:http://lady.shaoqun.com/m/a/248330.html
老师单独补课让我看她洗澡 老师叫我帮他解乳罩:http://www.30bags.com/m/a/249684.html
崩了!「解压玩具」侵权已"压垮"133家店铺,卖家快点撤离下架!:https://www.ikjzd.com/articles/146971
女人一旦出轨,通常会有这些变化。你遇到过他们吗?:http://lady.shaoqun.com/a/438500.html
男人晚上反应正常吗?一晚上几次比较好?看文章怎么说:http://lady.shaoqun.com/a/438501.html
你知道已婚女性出轨后有什么特点吗?:http://lady.shaoqun.com/a/438502.html

No comments:

Post a Comment