.NET8動態PGO簡析

2024年2月6日 19点热度 0人点赞

前言

.NET8在性能方面的驚人飛躍,遠超過去所取得成就,這在很大程度上歸功於動態PGO。【 I dare say the improvements in .NET 8 in the JIT are an incredible leap beyond what was achieved in the past, in large part due to dynamic PGO…官方原話】

詳細

在早期的.NET方法隻編譯一次,在第一次調用該方法的時候,JIT啟動以生成該方法的代碼。後續的調用以及當前的調用都會使用JIT生成的代碼來運行程序,這是一個簡單的無沖突的時代,但是也是一個原始的時代。

簡單和原始在於,方法隻編譯一次,再無其它可能性。無沖突在於,尤其是.NET開源時代,分層,動態PGO,OSR等等都會破壞原有的代碼邏輯,造成一定的復雜度,而早期的.NET不存在這種狀況。

優化是編譯器裡面最耗時的操作,編譯器可以花費超長的時間來優化一段代碼或者一個指令集。但是程序使用者,或者軟件使用者,或者軟件開發者沒有超長的時間去等待編譯器慢慢的完成一個方法的編譯。這是很致命的。

編譯時間和代碼質量上需要權衡利益得失。好的代碼質量,需要更長的時間去編譯,差的代碼質量,則編譯更快。這取決於JIT本身是如何工作的。大量的事實證明,程序中大部分方法都隻是調用一次或者幾次,耗費很多的時間去優化這些方法。優化的時間反而超過了這些方法本身運行的時間,這完全是得不償失的。

為了解決這種情況,.NET Core3.0中引入了新的JIT功能,稱之為分層編譯。通過分層一個方法可能會被編譯多次,在第一次編譯的時候被編譯為0層(tier0),在0層當中JIT優先考慮的是代碼編譯的速度,而不是質量。在0層的編譯特點是,最小優化(min opts,但是依然會保持一些優化),之所以這樣,是因為它需要更快速的編譯而不是更好質量的代碼。

0層之後,會有1層(tier1)。這一層是基於0層的代碼運行情況而來,比如JIT收集到0層的某個方法,運行的時間超過了60ms,運行的次數超過了2次(.NET8 R2R函數進入分層編譯隊列的閾值,比如Console.ReadLine方法),那麼JIT就會把0層的這個方法放入到分層編譯隊列,進行編譯之後該方法就會進一步優化形成了1層(tier1),此後調用該方法和當前調用該方法都會調用1層的代碼,而棄用了0層的代碼。

神來之筆

這裡需要註意了,隻有極少數的符合閾值的方法才能夠進入1層。這樣其實是0層和1層共生運行一個程序的過程,既保證了代碼的質量,保證了程序運行的速度,這是.NET8的一個神奇點。但是它的好處遠不止於此。

神奇點1,代碼從0層被優化到1層,JIT在0層的時候就會收集到代碼的信息,並且在編譯到1層的時候,會進行相對應的優化。如果JIT直接從一開始把代碼編譯到1層,那麼可能無法擁有這樣的優化。舉個例子,比如0層有個方法,它裡面有個靜態隻讀字段,0層編譯的時候它一定被初始化過了。JIT就可以根據它收集到的初始化的內容,在生成1層代碼的時候進行相對應的優化。

神奇點2,有一種情況,一個方法可能隻運行了寥寥幾次或者隻有一次。但是它裡面有for循環這種代碼,一直不停的運行。如果沒有分層編譯,它直接進入了Tier1,這樣很粗暴的方式明顯不行。為了解決這個問題,.NET中引入了OSR(On-Stack Replacement),當一個循環計數達到一定的閾值,JIT將編譯該方法的新優化版本,此後從最小代碼優化版本調到新優化版本中繼續執行。非常巧妙的一個方式。

神奇點3,基於配置文件的靜態優化(PGO)已經存在了幾十年。適用於多種環境和語言,比如C/C ,Python,Java等等。這種原理主要是,在一些代碼關鍵地方收集信息,下次運行根據這些信息重新構建應用程序。因為做到這些需要一些編譯器或者其它一些配置,稱之為靜態PGO。而通過分層,Tier0優化到Tier1的基礎上,所有的都是JIT自行收集,自行判斷,自行優化,無須任何額外的開發工作,或者基礎設施的配置,它就是動態PGO。

神奇點4,把R2R納入到分層編譯。R2R是一個預編譯鏡像,它裡面存儲的Native Header是完全的二進制運行代碼,它跟AOT的不同在於它運行了一定的次數之後,會被JIT進行重新優化編譯。如果不把它納入到分層編譯,這對動態PGO的性能是一個很大的阻礙。

編譯分支和例子

一般的來說,即時編譯分為兩個分支。即源碼編譯和R2R(它大量的應用在System.Private.CoreLib.dll裡面)預編譯(AOT不屬於即時編譯,這裡需要註意)。

源碼編譯即所謂未PreJIT編譯,0層一般都是未優化(not optimized),未檢查(not instrumented),在0層進行了檢查但是未優化,此後在1層生成優化性代碼。R2R即所謂PreJIT編譯,因為R2R可能已經被優化過了。所以0層它是已優化,未檢查,會在0層進行檢查。到了1層已優化已檢查,然後會再次生成一個優化性的代碼,也稱之為1層(但其實已經是2層 了)。參考如下圖:

下面看下Tier0優化的一個例子,上面說到Tier0是確保編譯速度,而忽略代碼質量。但是不代表Tier0不進行代碼優化。下面就是0層代碼優化的例子。

// dotnet run -c Release -f net8.0
MaybePrint(42.0);
static void MaybePrint<T>(T value){ if (value is int) Console.WriteLine(value);}

0層JIT可以進行一定常量折疊(在編譯的時候評估常量而不是在運行的時候),這可以讓0層生成更少的代碼。一般的來說,0層JIT大部分時間都是與虛擬機交互。如果能夠減少一些永遠不會使用的分支,可以大幅度提高編譯的時間,也能獲得更好的代碼質量。將DOTNET_JitDisasm設置為MaybePrint,運行

dotnet run -c Release -f net7.0

0層代碼如下:

; Assembly listing for method Program:<<Main>$>g__MaybePrint|0_0[double](double); Emitting BLENDED_CODE for X64 CPU with AVX - Windows; Tier-0 compilation; MinOpts code; rbp based frame; partially interruptible
G_M000_IG01: ;; offset=0000H 55 push rbp 4883EC30 sub rsp, 48 C5F877 vzeroupper 488D6C2430 lea rbp, [rsp 30H] 33C0 xor eax, eax 488945F8 mov qword ptr [rbp-08H], rax C5FB114510 vmovsd qword ptr [rbp 10H], xmm0
G_M000_IG02: ;; offset=0018H 33C9 xor ecx, ecx 85C9 test ecx, ecx 742D je SHORT G_M000_IG03 48B9B877CB99F97F0000 mov rcx, 0x7FF999CB77B8 E813C9AE5F call CORINFO_HELP_NEWSFAST 488945F8 mov gword ptr [rbp-08H], rax 488B4DF8 mov rcx, gword ptr [rbp-08H] C5FB104510 vmovsd xmm0, qword ptr [rbp 10H] C5FB114108 vmovsd qword ptr [rcx 08H], xmm0 488B4DF8 mov rcx, gword ptr [rbp-08H] FF15BFF72000 call [System.Console:WriteLine(System.Object)]
G_M000_IG03: ;; offset=0049H 90 nop
G_M000_IG04: ;; offset=004AH 4883C430 add rsp, 48 5D pop rbp C3 ret
; Total bytes of code 80

System.Console:WriteLine裡面的代碼都是可以進行優化的,但是.NET7裡面0層它解析出了MaybePrint,並未意識到其根本不會執行。現在在.NET8裡面JIT意識到分支永遠不會執行,所以生成如下:

; Assembly listing for method Program:<<Main>$>g__MaybePrint|0_0[double](double) (Tier0); Emitting BLENDED_CODE for X64 with AVX - Windows; Tier0 code; rbp based frame; partially interruptible
G_M000_IG01: ;; offset=0x0000 push rbp mov rbp, rsp vmovsd qword ptr [rbp 0x10], xmm0
G_M000_IG02: ;; offset=0x0009
G_M000_IG03: ;; offset=0x0009 pop rbp ret
; Total bytes of code 11