前言
本文是Performance Improvements in .NET 7 OSR部分的翻譯.下面開始正文:
//原文位址: https://devblogs.microsoft.com/dotnet/performance_improvements_in_net_7/#loop-hoisting-and-cloning
On-Stack Replacement(棧上替換),是在.NET 7中實作JIT的最酷的功能之一.但要真正了解OSR,我們首先需要了解分層編譯,是以快速回顧一下…
使用JIT編譯器的托管環境必須處理的問題之一是啟動和吞吐量之間的權衡.從曆史上看,編譯器優化的任務是生成執行更快的代碼,以便在應用程式或服務運作時實作盡可能最佳的吞吐量.但這種優化需要分析,需要時間,執行所有這些工作會導緻啟動時間增加,因為程式的所有代碼(例如,在web伺服器可以服務第一個請求之前需要運作的所有代碼)都需要編譯.是以,JIT編譯器需要做出權衡:以更長的啟動時間為代價提高吞吐量,或以降低吞吐量為代價提高啟動時間.對于某些類型的應用程式和服務,折衷是一個簡單的調用,例如,如果您的服務啟動一次,然後運作幾天,額外幾秒的啟動時間并不重要,或者如果您是一個控制台應用程式,将要進行快速計算并退出,啟動時間才是最重要的.
但是,JIT如何知道它處于哪個場景中,我們真的希望每個開發人員都知道這些設定和權衡,并相應地配置他們的每個應用程式嗎?對此的一個答案是提前編譯,它在.NET中采用了多種形式.例如,所有的核心庫都是“crossgen”,這意味着它們已經通過一個工具運作,該工具生成了前面提到的R2R格式,生成的二進制檔案包含彙編代碼,隻需要稍加調整即可實際執行;不是每個方法都可以為其生成代碼,但足以顯著減少啟動時間.當然,這種方法也有其自身的缺點,例如,JIT編譯器的一個承諾是,它可以利用目前機器/程序的知識進行最佳優化,是以,例如,R2R映像必須假設某個基線指令集(例如,哪些矢量化指令可用),而JIT可以看到哪些實際可用并使用最佳.“分層編譯”提供了另一個答案,無論是否使用這些其他提前(AOT)編譯解決方案,都可以使用.
分層編譯使JIT能夠魚與熊掌兼得.分層編譯這個想法很簡單:允許JIT多次編譯相同的代碼.第一次,JIT可以使用盡可能少的優化(少量優化實際上可以使JIT自身的吞吐量更快,是以這些優化仍然适用),生成相當未優化的彙編代碼,但速度非常快.當它這樣做時,它可以在程式集中添加一些工具來跟蹤方法的調用頻率.事實證明,在啟動路徑上使用的許多函數被調用一次,或者可能隻調用了幾次,優化它們比不優化執行它們需要更多的時間.然後,當方法的插裝觸發某個門檻值時,例如,一個方法已執行30次,工作項将排隊重新編譯該方法,但這一次JIT可以對其進行所有優化.這被親切地稱為“分層”.一旦重新編譯完成,方法的調用站點就會用新高度優化的彙編代碼的位址進行修補,未來的調用将采用快速路徑.是以,我們獲得了更快的啟動和更快的持續吞吐量.
然而,一個問題是不适合這種模式的方法.當然,許多性能敏感的方法都相對較快,執行了很多次,但也有大量性能敏感方法隻執行了幾次,甚至可能隻執行一次,但執行需要很長時間,甚至可能是整個過程的持續時間:帶循環的方法.是以,預設情況下,分層編譯未應用于循環,但可以通過将DOTNET_TC_QuickJitForLoops環境變量設定為1來啟用.我們可以通過嘗試使用.NET 6的簡單控制台應用程式來檢視其效果.使用預設設定,運作此應用程式:
class Program
{
static void Main()
{
var sw = new System.Diagnostics.Stopwatch();
while (true)
{
sw.Restart();
for (int trial = 0; trial < 10_000; trial++)
{
int count = 0;
for (int i = 0; i < char.MaxValue; i++)
if (IsAsciiDigit((char)i))
count++;
}
sw.Stop();
Console.WriteLine(sw.Elapsed);
}
static bool IsAsciiDigit(char c) => (uint)(c - '0') <= 9;
}
}
輸出内容:
00:00:00.5734352
00:00:00.5526667
00:00:00.5675267
00:00:00.5588724
00:00:00.5616028
現在,嘗試将DOTNET_TC_QuickJitForLoops設定為1.當我再次運作它時,得到如下數字:
00:00:01.2841397
00:00:01.2693485
00:00:01.2755646
00:00:01.2656678
00:00:01.2679925
換句話說,啟用DOTNET_TC_QuickJitForLoops時,需要的時間是不啟用時的2.5倍(在.NET6中).這是因為這個主函數從未應用過優化.通過将DOTNET_TC_QuickJitForLoops設定為1,我們說“JIT,請将分層也應用于具有循環的方法”,但這種具有循環的方式隻調用一次,是以在整個過程中,它最終保持在“tier-0”,即未優化.現在,讓我們對.NET 7進行同樣的嘗試.無論是否設定了環境變量,我都會再次得到如下數字:
00:00:00.5528889
00:00:00.5562563
00:00:00.5622086
00:00:00.5668220
00:00:00.5589112
但重要的是,這種方法仍然參與了分層.事實上,我們可以通過使用前面提到的DOTNET_JitDisasmSummary=1環境變量來确認.當我設定并再次運作時,我在輸出中看到這些行:
4: JIT compiled Program:Main() [Tier0, IL size=83, code size=319]
...
6: JIT compiled Program:Main() [Tier1-OSR @0x27, IL size=83, code size=380]
棧上替換的思想是,方法不僅可以在調用之間替換,而且可以在“棧上”執行時替換.除了為調用計數檢測第0層代碼外,還為疊代計數檢測循環.當疊代超過某個限制時,JIT編譯該方法的新的高度優化版本,将所有本地/寄存器狀态從目前調用轉移到新調用,然後跳到新方法中的适當位置.通過使用前面讨論的DOTNET_JitDisasm環境變量,我們可以看到這一點.将其設定為Program:*以檢視為Program類中的所有方法生成的彙編代碼,然後再次運作應用程式.您應該看到如下輸出:
// Assembly listing for method Program:Main()
// 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
4881EC80000000 sub rsp, 128
488DAC2480000000 lea rbp, [rsp+80H]
C5D857E4 vxorps xmm4, xmm4
C5F97F65B0 vmovdqa xmmword ptr [rbp-50H], xmm4
33C0 xor eax, eax
488945C0 mov qword ptr [rbp-40H], rax
G_M000_IG02: ;; offset=001FH
48B9002F0B50FC7F0000 mov rcx, 0x7FFC500B2F00
E8721FB25F call CORINFO_HELP_NEWSFAST
488945B0 mov gword ptr [rbp-50H], rax
488B4DB0 mov rcx, gword ptr [rbp-50H]
FF1544C70D00 call [Stopwatch:.ctor():this]
488B4DB0 mov rcx, gword ptr [rbp-50H]
48894DC0 mov gword ptr [rbp-40H], rcx
C745A8E8030000 mov dword ptr [rbp-58H], 0x3E8
G_M000_IG03: ;; offset=004BH
8B4DA8 mov ecx, dword ptr [rbp-58H]
FFC9 dec ecx
894DA8 mov dword ptr [rbp-58H], ecx
837DA800 cmp dword ptr [rbp-58H], 0
7F0E jg SHORT G_M000_IG05
G_M000_IG04: ;; offset=0059H
488D4DA8 lea rcx, [rbp-58H]
BA06000000 mov edx, 6
E8B985AB5F call CORINFO_HELP_PATCHPOINT
G_M000_IG05: ;; offset=0067H
488B4DC0 mov rcx, gword ptr [rbp-40H]
3909 cmp dword ptr [rcx], ecx
FF1585C70D00 call [Stopwatch:Restart():this]
33C9 xor ecx, ecx
894DBC mov dword ptr [rbp-44H], ecx
33C9 xor ecx, ecx
894DB8 mov dword ptr [rbp-48H], ecx
EB20 jmp SHORT G_M000_IG08
G_M000_IG06: ;; offset=007FH
8B4DB8 mov ecx, dword ptr [rbp-48H]
0FB7C9 movzx rcx, cx
FF152DD40B00 call [Program:<Main>g__IsAsciiDigit|0_0(ushort):bool]
85C0 test eax, eax
7408 je SHORT G_M000_IG07
8B4DBC mov ecx, dword ptr [rbp-44H]
FFC1 inc ecx
894DBC mov dword ptr [rbp-44H], ecx
G_M000_IG07: ;; offset=0097H
8B4DB8 mov ecx, dword ptr [rbp-48H]
FFC1 inc ecx
894DB8 mov dword ptr [rbp-48H], ecx
G_M000_IG08: ;; offset=009FH
8B4DA8 mov ecx, dword ptr [rbp-58H]
FFC9 dec ecx
894DA8 mov dword ptr [rbp-58H], ecx
837DA800 cmp dword ptr [rbp-58H], 0
7F0E jg SHORT G_M000_IG10
G_M000_IG09: ;; offset=00ADH
488D4DA8 lea rcx, [rbp-58H]
BA23000000 mov edx, 35
E86585AB5F call CORINFO_HELP_PATCHPOINT
G_M000_IG10: ;; offset=00BBH
817DB800CA9A3B cmp dword ptr [rbp-48H], 0x3B9ACA00
7CBB jl SHORT G_M000_IG06
488B4DC0 mov rcx, gword ptr [rbp-40H]
3909 cmp dword ptr [rcx], ecx
FF1570C70D00 call [Stopwatch:get_ElapsedMilliseconds():long:this]
488BC8 mov rcx, rax
FF1507D00D00 call [Console:WriteLine(long)]
E96DFFFFFF jmp G_M000_IG03
// Total bytes of code 222
// Assembly listing for method Program:<Main>g__IsAsciiDigit|0_0(ushort):bool
// 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
488BEC mov rbp, rsp
894D10 mov dword ptr [rbp+10H], ecx
G_M000_IG02: ;; offset=0007H
8B4510 mov eax, dword ptr [rbp+10H]
0FB7C0 movzx rax, ax
83C0D0 add eax, -48
83F809 cmp eax, 9
0F96C0 setbe al
0FB6C0 movzx rax, al
G_M000_IG03: ;; offset=0019H
5D pop rbp
C3 ret
這裡有一些相關的事情需要注意.首先,頂部的注釋強調了這段代碼是如何編譯的:
// Tier-0 compilation
// MinOpts code
是以,我們知道這是用最小優化(“MinOpts”)編譯的方法的初始版本(“第0層”).第二,注意裝配的這一行:
FF152DD40B00 call [Program:<Main>g__IsAsciiDigit|0_0(ushort):bool]
我們的IsAsciiDigit輔助方法是簡單的可内聯的,但它沒有内聯;相反,程式集有一個對它的調用,事實上,我們可以在下面看到為IsAsciiDigit生成的代碼(也是“MinOpts”).為什麼?因為内聯是一種優化(非常重要的優化),但在tier-0中被禁用(因為為了進行良好的内聯分析也非常昂貴).第三,我們可以看到JIT輸出到檢測該方法的代碼.這有點複雜,但我會指出相關部分.首先,我們看到:
C745A8E8030000 mov dword ptr [rbp-58H], 0x3E8
0x3E8是十進制1000的十六進制值,這是在JIT生成方法的優化版本之前循環需要疊代的預設疊代次數(可通過環境變量DOTNET_TC_OnStackReplacement_InitialCounter進行配置).是以,我們看到1000存儲在這個堆棧位置.然後,在該方法的後面,我們看到:
G_M000_IG03: // offset=004BH
8B4DA8 mov ecx, dword ptr [rbp-58H]
FFC9 dec ecx
894DA8 mov dword ptr [rbp-58H], ecx
837DA800 cmp dword ptr [rbp-58H], 0
7F0E jg SHORT G_M000_IG05
G_M000_IG04: // offset=0059H
488D4DA8 lea rcx, [rbp-58H]
BA06000000 mov edx, 6
E8B985AB5F call CORINFO_HELP_PATCHPOINT
G_M000_IG05: // offset=0067H
生成的代碼将該計數器加載到ecx寄存器中,将其遞減,存儲回,然後檢視計數器是否下降到0.如果沒有下降,則代碼跳轉到G_M000_IG05,這是循環其餘部分中實際代碼的标簽.但如果計數器下降到0,JIT将繼續将相關狀态存儲到rcx和edx寄存器中,然後調用CORINFO_HELP_PATCHPOINT helper方法.該助手負責觸發優化方法的建立(如果它還不存在),修複所有适當的跟蹤狀态,并跳轉到新方法.事實上,如果您再次檢視運作程式的控制台輸出,您将看到主方法的另一個輸出:
// Assembly listing for method Program:Main()
// Emitting BLENDED_CODE for X64 CPU with AVX - Windows
// Tier-1 compilation
// OSR variant for entry point 0x23
// optimized code
// rsp based frame
// fully interruptible
// No PGO data
// 1 inlinees with PGO data; 8 single block inlinees; 0 inlinees without PGO data
G_M000_IG01: // offset=0000H
4883EC58 sub rsp, 88
4889BC24D8000000 mov qword ptr [rsp+D8H], rdi
4889B424D0000000 mov qword ptr [rsp+D0H], rsi
48899C24C8000000 mov qword ptr [rsp+C8H], rbx
C5F877 vzeroupper
33C0 xor eax, eax
4889442428 mov qword ptr [rsp+28H], rax
4889442420 mov qword ptr [rsp+20H], rax
488B9C24A0000000 mov rbx, gword ptr [rsp+A0H]
8BBC249C000000 mov edi, dword ptr [rsp+9CH]
8BB42498000000 mov esi, dword ptr [rsp+98H]
G_M000_IG02: // offset=0041H
EB45 jmp SHORT G_M000_IG05
align [0 bytes for IG06]
G_M000_IG03: // offset=0043H
33C9 xor ecx, ecx
488B9C24A0000000 mov rbx, gword ptr [rsp+A0H]
48894B08 mov qword ptr [rbx+08H], rcx
488D4C2428 lea rcx, [rsp+28H]
48B87066E68AFD7F0000 mov rax, 0x7FFD8AE66670
G_M000_IG04: // offset=0060H
FFD0 call rax ; Kernel32:QueryPerformanceCounter(long):int
488B442428 mov rax, qword ptr [rsp+28H]
488B9C24A0000000 mov rbx, gword ptr [rsp+A0H]
48894310 mov qword ptr [rbx+10H], rax
C6431801 mov byte ptr [rbx+18H], 1
33FF xor edi, edi
33F6 xor esi, esi
833D92A1E55F00 cmp dword ptr [(reloc 0x7ffcafe1ae34)], 0
0F85CA000000 jne G_M000_IG13
G_M000_IG05: // offset=0088H
81FE00CA9A3B cmp esi, 0x3B9ACA00
7D17 jge SHORT G_M000_IG09
G_M000_IG06: // offset=0090H
0FB7CE movzx rcx, si
83C1D0 add ecx, -48
83F909 cmp ecx, 9
7702 ja SHORT G_M000_IG08
G_M000_IG07: // offset=009BH
FFC7 inc edi
G_M000_IG08: // offset=009DH
FFC6 inc esi
81FE00CA9A3B cmp esi, 0x3B9ACA00
7CE9 jl SHORT G_M000_IG06
G_M000_IG09: // offset=00A7H
488B6B08 mov rbp, qword ptr [rbx+08H]
48899C24A0000000 mov gword ptr [rsp+A0H], rbx
807B1800 cmp byte ptr [rbx+18H], 0
7436 je SHORT G_M000_IG12
G_M000_IG10: // offset=00B9H
488D4C2420 lea rcx, [rsp+20H]
48B87066E68AFD7F0000 mov rax, 0x7FFD8AE66670
G_M000_IG11: // offset=00C8H
FFD0 call rax ; Kernel32:QueryPerformanceCounter(long):int
488B4C2420 mov rcx, qword ptr [rsp+20H]
488B9C24A0000000 mov rbx, gword ptr [rsp+A0H]
482B4B10 sub rcx, qword ptr [rbx+10H]
4803E9 add rbp, rcx
833D2FA1E55F00 cmp dword ptr [(reloc 0x7ffcafe1ae34)], 0
48899C24A0000000 mov gword ptr [rsp+A0H], rbx
756D jne SHORT G_M000_IG14
G_M000_IG12: // offset=00EFH
C5F857C0 vxorps xmm0, xmm0
C4E1FB2AC5 vcvtsi2sd xmm0, rbp
C5FB11442430 vmovsd qword ptr [rsp+30H], xmm0
48B9F04BF24FFC7F0000 mov rcx, 0x7FFC4FF24BF0
BAE7070000 mov edx, 0x7E7
E82E1FB25F call CORINFO_HELP_GETSHARED_NONGCSTATIC_BASE
C5FB10442430 vmovsd xmm0, qword ptr [rsp+30H]
C5FB5905E049F6FF vmulsd xmm0, xmm0, qword ptr [(reloc 0x7ffc4ff25720)]
C4E1FB2CD0 vcvttsd2si rdx, xmm0
48B94B598638D6C56D34 mov rcx, 0x346DC5D63886594B
488BC1 mov rax, rcx
48F7EA imul rdx:rax, rdx
488BCA mov rcx, rdx
48C1E93F shr rcx, 63
48C1FA0B sar rdx, 11
4803CA add rcx, rdx
FF1567CE0D00 call [Console:WriteLine(long)]
E9F5FEFFFF jmp G_M000_IG03
G_M000_IG13: // offset=014EH
E8DDCBAC5F call CORINFO_HELP_POLL_GC
E930FFFFFF jmp G_M000_IG05
G_M000_IG14: // offset=0158H
E8D3CBAC5F call CORINFO_HELP_POLL_GC
EB90 jmp SHORT G_M000_IG12
// Total bytes of code 351
這裡,我們再次注意到一些有趣的事情.首先,在頭檔案中我們看到:
// Tier-1 compilation
// OSR variant for entry point 0x23
// optimized code
是以,我們知道這既是優化的“第1層”代碼,也是該方法的“OSR變體”.其次,請注意,不再調用IsAsciiDigit方法.相反,我們看到的是,該調用的位置:
G_M000_IG06: ;; offset=0090H
0FB7CE movzx rcx, si
83C1D0 add ecx, -48
83F909 cmp ecx, 9
7702 ja SHORT G_M000_IG08
這是将一個值加載到rcx中,從中減去48(48是“0”字元的十進制ASCII值),并将結果值與9進行比較.聽起來很像我們的IsasciidGit實作( (uint)(c-“0”)<=9 ),不是嗎?這是因為它是.幫助程式成功地内聯到現在優化的代碼中.
很好,現在在.NET7中,我們可以在很大程度上避免啟動和吞吐量之間的權衡,因為OSR使分層編譯能夠應用于所有方法,甚至是那些長期運作的方法.許多送出開始啟用此功能,包括過去幾年中的許多送出,但是所有的功能在釋出時都被禁用了.由于dotnet/runtime#62831在Arm64上實作了對OSR的支援(之前僅實作了x64支援),以及dotnet/Runtime#63406和dotnet/runtime#65609修改了如何OSR導入和epilog的處理,dotnet/runtime#65675在預設情況下啟用OSR(DOTNET_TC_QuickJitForLoops=1).
但是,分層編譯和OSR不僅僅是關于啟動(盡管它們在那裡當然非常有價值).他們還将進一步提高吞吐量.盡管分層編譯最初被設想為一種在不影響吞吐量的情況下優化啟動的方法,但它已經遠遠不止于此.JIT可以在tier-0期間了解到關于方法的各種資訊,然後可以用于tier-1.例如,執行的tier-2代碼意味着該方法通路的任何靜态都将被初始化,這意味着任何隻讀靜态不僅在執行tier-3代碼時已經初始化,而且它們的值永遠不會改變.這反過來意味着,任何原始類型的隻讀靜态(如bool、int等)都可以被視為常量,而不是靜态隻讀字段,并且在第1層編譯期間,JIT可以優化它們,就像優化常量一樣.例如,在将DOTNET_JitDisasm設定為Program:Test後,嘗試運作以下簡單程式:
using System.Runtime.CompilerServices;
class Program
{
static readonly bool Is64Bit = Environment.Is64BitProcess;
static int Main()
{
int count = 0;
for (int i = 0; i < 1_000_000_000; i++)
if (Test())
count++;
return count;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static bool Test() => Is64Bit;
}
當我這樣做時,我得到以下輸出:
// Assembly listing for method Program:Test():bool
// 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
4883EC20 sub rsp, 32
488D6C2420 lea rbp, [rsp+20H]
G_M000_IG02: ;; offset=000AH
48B9B8639A3FFC7F0000 mov rcx, 0x7FFC3F9A63B8
BA01000000 mov edx, 1
E8C220B25F call CORINFO_HELP_GETSHARED_NONGCSTATIC_BASE
0FB60545580C00 movzx rax, byte ptr [(reloc 0x7ffc3f9a63ea)]
G_M000_IG03: ;; offset=0025H
4883C420 add rsp, 32
5D pop rbp
C3 ret
// Total bytes of code 43
// Assembly listing for method Program:Test():bool
// 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
G_M000_IG02: ;; offset=0000H
B801000000 mov eax, 1
G_M000_IG03: ;; offset=0005H
C3 ret
// Total bytes of code 6
注意,我們再次看到程式的兩個輸出:Test方法的彙編代碼.首先,我們看到“Tier-0”代碼,它正在通路靜态(注意調用CORINFO_HELP_GETSHARED_NONGCSTATIC_BASE指令).但是,我們看到了“Tier-1”代碼,其中所有的開銷都消失了,取而代之的是mov eax,1.由于必須執行“Tier-0”代碼才能分層,“Tier-2”代碼是在知道靜态隻讀bool IS64位字段的值為真的情況下生成的(1),是以,整個方法是将值1存儲到用于傳回值的eax寄存器中.
這非常有用,現在編寫元件時都考慮到了分層.考慮一下新的Regex源代碼生成器,這将在後面的文章中讨論(Roslyn源代碼生成器是幾年前引入的;就像Roslyn分析器能夠插入編譯器并基于編譯器從源代碼中學習到的所有資料提供額外的診斷一樣,Roslyn源代碼生成器能夠分析相同的資料,然後用額外的源代碼進一步增加編譯單元).Regex源生成器應用基于此的dotnet/runtime#67775技術.Regex支援設定流程範圍的逾時,該逾時将應用于沒有顯式設定逾時的Regex執行個體.這意味着,即使設定這樣一個程序範圍的逾時非常罕見,Regex源生成器仍然需要輸出與逾時相關的代碼,以備需要.它通過輸出一些helper來實作,像這樣:
static class Utilities
{
internal static readonly TimeSpan s_defaultTimeout = AppContext.GetData("REGEX_DEFAULT_MATCH_TIMEOUT") is TimeSpan timeout ? timeout : Timeout.InfiniteTimeSpan;
internal static readonly bool s_hasTimeout = s_defaultTimeout != Timeout.InfiniteTimeSpan;
}
然後調用的方式,如下所示:
if (Utilities.s_hasTimeout)
{
base.CheckTimeout();
}
在第0層中,這些檢查仍然會在程式集代碼中發出,但在吞吐量很重要的第1層中,如果沒有設定相關的AppContext開關,那麼s_defaultTimeout将會是Timeout.infinittimeespan,此時s_hasTimeout将為false.由于s_hasTimeout是一個靜态的隻讀bool, JIT将能夠将其視為const,并且所有的條件,如if (Utilities.s_hasTimeout)将被視為與if (false)相等,并從彙編代碼中完全清除為死代碼.
但是,這有點老生常談了.自從.NET Core 3.0引入分層編譯以來,JIT就能夠進行這樣的優化.不過,現在在.NET 7中,在OSR中,它也可以在預設情況下對帶有循環的方法進行優化(是以啟用了類似于正規表達式的情況).然而,當OSR與另一個令人興奮的特性:動态PGO相結合時,OSR的真正魔力開始發揮作用.
因JIT部分内容太多,這裡進行拆分,PGO拆分為一篇博文.
個人能力有限,如果您發現有什麼不對,請私信我
如果您覺得對您有用的話,可以點個贊或者加個關注,歡迎大家一起進行技術交流