引子
.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 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_1
LBL_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_0
LBL_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.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=1
dotnet 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,rax
LBL_0:
; var result = 0
xor r14d,r14d
jmp short LBL_4
LBL_1:
; if (generator.GetType() == typeof(MyGenerator))
mov rdi,offset methodtable(MyGenerator)
cmp r15,rdi
jne short LBL_6
; result += generator.Current
LBL_2:
mov r12d,[rbx+0Ch]
LBL_3:
add r14d,r12d
LBL_4:
; if (generator.GetType() == typeof(MyGenerator))
mov r15,[rbx]
mov rax,offset methodtable(MyGenerator)
cmp r15,rax
jne short LBL_8
; if (generator.ReachEnd)
mov rax,rbx
cmp byte ptr [rax+10h],0
jne short LBL_7
; generator._current++
mov eax,[rbx+8]
inc eax
mov [rbx+8],eax
; if (generator._current > 10)
cmp eax,3E8h
jg short LBL_5
mov [rbx+0Ch],eax
jmp short LBL_2
LBL_5:
; ReachEnd = true
mov byte ptr [rbx+10h],1
jmp short LBL_10
LBL_6:
; result += generator.Current
mov rdi,rbx
mov r11,7F5C42A70010h
mov rax,7F5C42A70010h
call qword ptr [rax]
mov r12d,eax
jmp short LBL_3
LBL_7:
xor r12d,r12d
jmp short LBL_9
LBL_8:
; if (generator.MoveNext())
mov rdi,rbx
mov r11,7F5C42A70008h
mov rax,7F5C42A70008h
call qword ptr [rax]
mov r12d,eax
LBL_9:
test r12d,r12d
jne near ptr LBL_1
LBL_10:
; return true/false
mov eax,r14d
pop rbx
pop r12
pop r14
pop r15
pop rbp
ret
LBL_11:
; factory.CreateGenerator()
mov rax,[rdi]
mov rax,[rax+40h]
call qword ptr [rax+20h]
mov rbx,rax
jmp near ptr LBL_0
同樣,我用註釋標註出來了關鍵地方對應的 C# 程式碼,這裡由於稍微有些麻煩因此就不在這裡還原回大概的 C# 邏輯了。
同樣,我們發現了不少有趣的地方:
- 通過型別測試判斷
factory
是否是MyGeneratorFactory
、generator
是否是MyGenerator
- 如果是,則跳轉到一個程式碼塊,這裡面將
IGeneratorFactory.CreateFactory
、IGenerator.MoveNext
以及IGenerator.Current
全部去虛擬化,並且全部進行了內聯 - 否則跳轉到一個程式碼塊,這裡面的程式碼等同於不開啟 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 或者之後到來,屆時 RyuJIT 的優化能力將會更強大。