利用 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,同時分別提供對應的一個實現?MyGeneratorFactory?和?MyGenerator。注意實現類并沒有標注?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 rbppush r14push rbxlea rbp,[rsp+10h] ; factory.CreateGenerator()mov rax,[rdi]mov rax,[rax+40h]call qword ptr [rax+20h]mov rbx,rax ; var result = 0xor r14d,r14d ; if (generator.MoveNext())mov rdi,rbxmov r11,7F3357AE0008hmov rax,7F3357AE0008hcall qword ptr [rax]test eax,eaxje short LBL_1LBL_0: ; result += generator.Current;mov rdi,rbxmov r11,7F3357AE0010hmov rax,7F3357AE0010hcall qword ptr [rax]add r14d,eax ; if (generator.MoveNext())mov rdi,rbxmov r11,7F3357AE0008hmov rax,7F3357AE0008hcall qword ptr [rax]test eax,eaxjne short LBL_0LBL_1: ; return result;mov eax,r14dpop rbxpop r14pop rbpret我用注釋標注出了生成的代碼中關鍵地方對應的 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.CreateGenerator、generator.MoveNext?以及?generator.Current?完全沒有去虛擬化
因為沒有去虛擬化因此也無法做內聯優化
這已經是 tier 1 代碼了,也就是目前階段 RyuJIT(.NET 6 的 JIT 編譯器)在不借助任何指示編譯器的?Attribute?以及 PGO 所能生成的最大優化等級的代碼。
使用 PGO
這一次我們先看看啟用動態 PGO 能得到怎樣的結果。
為了使用動態 PGO,現階段需要設置一些環境變量。
export DOTNET_ReadyToRun=0 # 禁用 AOT export DOTNET_TieredPGO=1 # 開啟分層 PGO export 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=1 export DOTNET_EventPipeConfig=Microsoft-Windows-DotNETRuntime:0x1F000080018:5 export DOTNET_EventPipeOutputPath=trace.nettrace # 追蹤文件輸出路徑 export DOTNET_ReadyToRun=0 # 禁用 AOT export DOTNET_TieredPGO=1 # 啟用分層 PGO export DOTNET_TC_CallCounting=0 # 永遠不產生 tier 1 代碼 export DOTNET_TC_QuickJitForLoops=1 export 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 rbppush r15push r14push r12push rbxlea rbp,[rsp+20h] ; if (factory.GetType() == typeof(MyGeneratorFactory))mov rax,offset methodtable(MyGeneratorFactory)cmp [rdi],raxjne near ptr LBL_11 ; IGenerator generator = new MyGenerator()mov rdi,offset methodtable(MyGenerator)call CORINFO_HELP_NEWSFASTmov rbx,raxLBL_0: ; var result = 0xor r14d,r14djmp short LBL_4LBL_1: ; if (generator.GetType() == typeof(MyGenerator))mov rdi,offset methodtable(MyGenerator)cmp r15,rdijne short LBL_6 ; result += generator.Current LBL_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,raxjne short LBL_8 ; if (generator.ReachEnd)mov rax,rbxcmp byte ptr [rax+10h],0jne short LBL_7 ; generator._current++mov eax,[rbx+8]inc eaxmov [rbx+8],eax ; if (generator._current > 1000)cmp eax,3E8hjg short LBL_5mov [rbx+0Ch],eaxjmp short LBL_2LBL_5: ; ReachEnd = truemov byte ptr [rbx+10h],1jmp short LBL_10LBL_6: ; result += generator.Currentmov rdi,rbxmov r11,7F5C42A70010hmov rax,7F5C42A70010hcall qword ptr [rax]mov r12d,eaxjmp short LBL_3LBL_7:xor r12d,r12djmp short LBL_9LBL_8: ; if (generator.MoveNext())mov rdi,rbxmov r11,7F5C42A70008hmov rax,7F5C42A70008hcall qword ptr [rax]mov r12d,eaxLBL_9:test r12d,r12djne near ptr LBL_1LBL_10: ; return true/falsemov eax,r14dpop rbxpop r12pop r14pop r15pop rbpretLBL_11: ; factory.CreateGenerator()mov rax,[rdi]mov rax,[rax+40h]call qword ptr [rax+20h]mov rbx,raxjmp near ptr LBL_0同樣,我用注釋標注出來了關鍵地方對應的 C# 代碼,這里由于稍微有些麻煩因此就不在這里還原回大概的 C# 邏輯了。
同樣,我們發現了不少有趣的地方:
通過類型測試判斷?factory?是否是?MyGeneratorFactory、generator?是否是?MyGenerator
如果是,則跳轉到一個代碼塊,這里面將?IGeneratorFactory.CreateFactory、IGenerator.MoveNext?以及?IGenerator.Current?全部去虛擬化,這也叫做 guarded devirtualization,并且全部進行了內聯
否則跳轉到一個代碼塊,這里面的代碼等同于不開啟 PGO 的 tier 1 代碼
這里做了一次 loop cloning
while?循環同樣被優化成了?do-while,做了一次 loop inversion
相比不開啟 PGO 而言,顯然優化幅度就大了很多。
用一張圖來對比首次運行、總體用時(毫秒)和比例(均為越低越好),從上至下分別是默認、關閉分層編譯、動態 PGO、靜態 PGO:
總結
有了 PGO 之后,之前的很多性能經驗就不再有效。最典型的例如在用?List<T>?或者?Array?的時候?IEnumerable<T>.Where(pred).FirstOrDefault()?比?IEnumerable<T>.FirstOrDefault(pred)?快,這是因為?IEnumerable<T>.Where?在代碼層面手動做了針對性的去虛擬化,而?FirstOrDefault<T>?沒有。但是在 PGO 的輔助下,即使不需要手動編寫針對性去虛擬化的代碼也能成功去虛擬化,而且不僅僅局限于?List<T>?和?Array,對所有實現?IEnumerable<T>?的類型都適用。
借助 PGO 我們可以預見大幅度的執行效率提升。例如在 TE-benchmark 非官方測試的 plaintext mvc 中,對比第一次請求時間(毫秒,從運行程序開始計算,越低越好)、RPS(越高越好)和比例(越高越好)結果如下:
另外,PGO 在 .NET 6 中尚處于初步階段,后續版本(.NET 7+)中將會帶來更多基于 PGO 的優化。
至于其他的 JIT 優化方面,.NET 6 同樣做了大量的改進,例如更多的 morphing pass、jump threading、loop inversion、loop alignment、loop cloning 等等,并且優化了 LSRA 和 register heuristic,以及解決了不少導致 struct 出現 stack spilling 的情況,以使其一直保持在寄存器中。但是盡管如此,RyuJIT 在優化方面仍有很長的路要走,例如 loop unrolling、forward subsitituion 以及包含關系條件的 jump threading 之類的優化 .NET 6 目前并不具備,這些優化將會在 .NET 7 或者之后到來。
總結
以上是生活随笔為你收集整理的利用 PGO 提升 .NET 程序性能的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 记一次 .NET 某云采购平台API 挂
- 下一篇: [007] 详解 .NET 程序集