【譯】.NET 7 中的性能改進(五)

原文 | Stephen Toub

翻譯 | 鄭子銘

循環提升和克隆 (Loop Hoisting and Cloning)

我們之前看到PGO是如何與循環提升和克隆互動的,這些優化也有其他改進。

從歷史上看,JIT對提升的支持僅限於將一個不變量提升到一個層級。

考慮一下這個例子:

[Benchmark]
public void Compute()
{
    for (int thousands = 0; thousands < 10; thousands++)
    {
        for (int hundreds = 0; hundreds < 10; hundreds++)
        {
            for (int tens = 0; tens < 10; tens++)
            {
                for (int ones = 0; ones < 10; ones++)
                {
                    int n = ComputeNumber(thousands, hundreds, tens, ones);
                    Process(n);
                }
            }
        }
    }
}

static int ComputeNumber(int thousands, int hundreds, int tens, int ones) =>
    (thousands * 1000) +
    (hundreds * 100) +
    (tens * 10) +
    ones;

[MethodImpl(MethodImplOptions.NoInlining)]
static void Process(int n) { }

乍一看,你可能會說:"有什麼可提升的,n的計算需要所有的循環輸入,而所有的計算都在ComputeNumber中。" 但從編譯器的角度來看,ComputeNumber函數是可內聯的,因此在邏輯上可以成爲其調用者的一部分,n的計算實際上被分成了多塊,每塊都可以被提升到不同的層級,例如,十的計算可以提升出一層,百的提升出兩層,千的提升出三層。下面是[DisassemblyDiagnoser]對.NET 6的輸出。

; Program.Compute()
       push      r14
       push      rdi
       push      rsi
       push      rbp
       push      rbx
       sub       rsp,20
       xor       esi,esi
M00_L00:
       xor       edi,edi
M00_L01:
       xor       ebx,ebx
M00_L02:
       xor       ebp,ebp
       imul      ecx,esi,3E8
       imul      eax,edi,64
       add       ecx,eax
       lea       eax,[rbx+rbx*4]
       lea       r14d,[rcx+rax*2]
M00_L03:
       lea       ecx,[r14+rbp]
       call      Program.Process(Int32)
       inc       ebp
       cmp       ebp,0A
       jl        short M00_L03
       inc       ebx
       cmp       ebx,0A
       jl        short M00_L02
       inc       edi
       cmp       edi,0A
       jl        short M00_L01
       inc       esi
       cmp       esi,0A
       jl        short M00_L00
       add       rsp,20
       pop       rbx
       pop       rbp
       pop       rsi
       pop       rdi
       pop       r14
       ret
; Total bytes of code 84

我們可以看到,這裏發生了一些提升。畢竟,最裏面的循環(標記爲M00_L03)只有五條指令:增加ebp(這時是1的計數器值),如果它仍然小於0xA(10),就跳回到M00_L03,把r14中的任何數字加到1上。很好,所以我們已經把所有不必要的計算從內循環中取出來了,只剩下把1的位置加到其餘的數字中。讓我們再往外走一級。M00_L02是十位數循環的標籤。我們在這裏看到了什麼?有問題。兩條指令imul ecx,esi,3E8和imul eax,edi,64正在進行千位數1000和百位數100的操作,突出表明這些本來可以進一步提升的操作被卡在了最下層的循環中。現在,這是我們在.NET 7中得到的結果,在dotnet/runtime#68061中,這種情況得到了改善:

; Program.Compute()
       push      r15
       push      r14
       push      r12
       push      rdi
       push      rsi
       push      rbp
       push      rbx
       sub       rsp,20
       xor       esi,esi
M00_L00:
       xor       edi,edi
       imul      ebx,esi,3E8
M00_L01:
       xor       ebp,ebp
       imul      r14d,edi,64
       add       r14d,ebx
M00_L02:
       xor       r15d,r15d
       lea       ecx,[rbp+rbp*4]
       lea       r12d,[r14+rcx*2]
M00_L03:
       lea       ecx,[r12+r15]
       call      qword ptr [Program.Process(Int32)]
       inc       r15d
       cmp       r15d,0A
       jl        short M00_L03
       inc       ebp
       cmp       ebp,0A
       jl        short M00_L02
       inc       edi
       cmp       edi,0A
       jl        short M00_L01
       inc       esi
       cmp       esi,0A
       jl        short M00_L00
       add       rsp,20
       pop       rbx
       pop       rbp
       pop       rsi
       pop       rdi
       pop       r12
       pop       r14
       pop       r15
       ret
; Total bytes of code 99

現在注意一下這些imul指令的位置。有四個標籤,每個標籤對應一個循環,我們可以看到最外層的循環有imul ebx,esi,3E8(用於千位計算),下一個循環有imul r14d,edi,64(用於百位計算),突出表明這些計算被提升到了適當的層級(十位和一位計算仍然在正確的位置)。

在克隆方面有了更多的改進。以前,循環克隆只適用於從低值到高值的1次迭代循環。有了dotnet/runtime#60148,與上值的比較可以是<=,而不僅僅是<。有了dotnet/runtime#67930,向下迭代的循環也可以被克隆,增量和減量大於1的循環也是如此。

private int[] _values = Enumerable.Range(0, 1000).ToArray();

[Benchmark]
[Arguments(0, 0, 1000)]
public int LastIndexOf(int arg, int offset, int count)
{
    int[] values = _values;
    for (int i = offset + count - 1; i >= offset; i--)
        if (values[i] == arg)
            return i;
    return 0;
}

如果沒有循環克隆,JIT不能假設offset到offset+count都在範圍內,因此對數組的每個訪問都需要進行邊界檢查。有了循環克隆,JIT可以生成一個沒有邊界檢查的循環版本,並且只在它知道所有的訪問都是有效的時候使用。這正是現在.NET 7中發生的事情。下面是我們在.NET 6中得到的情況。

; Program.LastIndexOf(Int32, Int32, Int32)
       sub       rsp,28
       mov       rcx,[rcx+8]
       lea       eax,[r8+r9+0FFFF]
       cmp       eax,r8d
       jl        short M00_L01
       mov       r9d,[rcx+8]
       nop       word ptr [rax+rax]
M00_L00:
       cmp       eax,r9d
       jae       short M00_L03
       movsxd    r10,eax
       cmp       [rcx+r10*4+10],edx
       je        short M00_L02
       dec       eax
       cmp       eax,r8d
       jge       short M00_L00
M00_L01:
       xor       eax,eax
       add       rsp,28
       ret
M00_L02:
       add       rsp,28
       ret
M00_L03:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 72

注意在覈心循環中,在標籤M00_L00處,有一個邊界檢查(cmp eax,r9d and jae short M00_L03,它跳到一個調用CORINFO_HELP_RNGCHKFAIL)。而這裏是我們在.NET 7中得到的結果。

; Program.LastIndexOf(Int32, Int32, Int32)
       sub       rsp,28
       mov       rax,[rcx+8]
       lea       ecx,[r8+r9+0FFFF]
       cmp       ecx,r8d
       jl        short M00_L02
       test      rax,rax
       je        short M00_L01
       test      ecx,ecx
       jl        short M00_L01
       test      r8d,r8d
       jl        short M00_L01
       cmp       [rax+8],ecx
       jle       short M00_L01
M00_L00:
       mov       r9d,ecx
       cmp       [rax+r9*4+10],edx
       je        short M00_L03
       dec       ecx
       cmp       ecx,r8d
       jge       short M00_L00
       jmp       short M00_L02
M00_L01:
       cmp       ecx,[rax+8]
       jae       short M00_L04
       mov       r9d,ecx
       cmp       [rax+r9*4+10],edx
       je        short M00_L03
       dec       ecx
       cmp       ecx,r8d
       jge       short M00_L01
M00_L02:
       xor       eax,eax
       add       rsp,28
       ret
M00_L03:
       mov       eax,ecx
       add       rsp,28
       ret
M00_L04:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 98

注意到代碼大小是如何變大的,以及現在有兩個循環的變化:一個在 M00_L00,一個在 M00_L01。第二個,M00_L01,有一個分支到那個相同的調用 CORINFO_HELP_RNGCHKFAIL,但第一個沒有,因爲那個循環最終只會在證明偏移量、計數和 _values.Length 是這樣的,即索引將總是在界內之後被使用。

dotnet/runtime#59886使JIT能夠選擇不同的形式來發出選擇快速或慢速循環路徑的條件,例如,是否發出所有的條件,與它們一起,然後分支(if (! (cond1 & cond2)) goto slowPath),或者是否單獨發出每個條件(if (!cond1) goto slowPath; if (!cond2) goto slowPath)。 dotnet/runtime#66257使循環變量被初始化爲更多種類的表達式時,循環克隆得以啓動(例如,for (int fromindex = lastIndex - lengthToClear; ...) )。dotnet/runtime#70232增加了JIT克隆具有更廣泛操作的主體的循環的意願。

摺疊、傳播和替換 (Folding, propagation, and substitution)

常量摺疊是一種優化,編譯器在編譯時計算只涉及常量的表達式的值,而不是在運行時生成代碼來計算該值。在.NET中有多個級別的常量摺疊,有些常量摺疊由C#編譯器執行,有些常量摺疊由JIT編譯器執行。例如,給定C#代碼。

[Benchmark]
public int A() => 3 + (4 * 5);

[Benchmark]
public int B() => A() * 2;

C#編譯器將爲這些方法生成IL,如下所示。

.method public hidebysig instance int32 A () cil managed 
{
    .maxstack 8
    IL_0000: ldc.i4.s 23
    IL_0002: ret
}

.method public hidebysig instance int32 B () cil managed 
{
    .maxstack 8
    IL_0000: ldarg.0
    IL_0001: call instance int32 Program::A()
    IL_0006: ldc.i4.2
    IL_0007: mul
    IL_0008: ret
}

你可以看到,C#編譯器已經計算出了3+(4*5)的值,因爲方法A的IL只是包含了相當於返回23;的內容。然而,方法B包含了相當於return A() * 2;的內容,突出表明C#編譯器所進行的常量摺疊只是在方法內部進行的。現在是JIT生成的內容。

; Program.A()
       mov       eax,17
       ret
; Total bytes of code 6

; Program.B()
       mov       eax,2E
       ret
; Total bytes of code 6

方法A的彙編並不特別有趣;它只是返回相同的值23(十六進制0x17)。但方法B更有趣。JIT已經內聯了從B到A的調用,將A的內容暴露給B,這樣JIT就有效地將B的主體視爲等同於返回23*2;。在這一點上,JIT可以做自己的常量摺疊,它將B的主體轉化爲簡單的返回46(十六進制0x2e)。常量傳播與常量摺疊有着錯綜複雜的聯繫,本質上就是你可以將一個常量值(通常是通過常量摺疊計算出來的)替換到進一步的表達式中,這時它們也可以被摺疊。

JIT長期以來一直在進行恆定摺疊,但它在.NET 7中得到了進一步改善。常量摺疊的改進方式之一是暴露出更多需要摺疊的值,這往往意味着更多的內聯。dotnet/runtime#55745幫助inliner理解像M(constant + constant)這樣的方法調用(注意到這些常量可能是其他方法調用的結果)本身就是在向M傳遞常量,而常量被傳遞到方法調用中是在提示inliner應該考慮更積極地進行內聯,因爲將該常量暴露給被調用者的主體有可能大大減少實現被調用者所需的代碼量。JIT之前可能已經內聯了這樣的方法,但是當涉及到內聯時,JIT是關於啓發式方法和產生足夠的證據來證明值得內聯的東西;這有助於這些證據。例如,這種模式出現在TimeSpan的各種FromXx方法中。例如,TimeSpan.FromSeconds被實現爲。

public static TimeSpan FromSeconds(double value) => Interval(value, TicksPerSecond); // TicksPerSecond is a constant

並且,爲了這個例子的目的,避開了參數驗證,Interval是。

private static TimeSpan Interval(double value, double scale) => IntervalFromDoubleTicks(value * scale);
private static TimeSpan IntervalFromDoubleTicks(double ticks) => ticks == long.MaxValue ? TimeSpan.MaxValue : new TimeSpan((long)ticks);

如果所有的東西都被內聯,意味着FromSeconds本質上是。

public static TimeSpan FromSeconds(double value)
{
    double ticks = value * 10_000_000;
    return ticks == long.MaxValue ? TimeSpan.MaxValue : new TimeSpan((long)ticks);
}

如果值是一個常數,比方說5,整個事情可以被常數摺疊(在ticks == long.MaxValue分支上消除了死代碼),簡單地說。

return new TimeSpan(50_000_000);

我就不說.NET 6的程序集了,但在.NET 7上,用這樣的基準來衡量。

[Benchmark]
public TimeSpan FromSeconds() => TimeSpan.FromSeconds(5);

我們現在得到的是簡單和乾淨。

; Program.FromSeconds()
       mov       eax,2FAF080
       ret
; Total bytes of code 6

另一個改進常量摺疊的變化包括來自@SingleAccretiondotnet/runtime#57726,它在一種特殊的情況下解除了常量摺疊,這種情況有時表現爲對從方法調用返回的結構進行逐字段賦值。作爲一個小例子,考慮這個微不足道的屬性,它訪問了Color.DarkOrange屬性,而後者又做了new Color(KnownColor.DarkOrange)。

[Benchmark]
public Color DarkOrange() => Color.DarkOrange;

在.NET 6中,JIT生成了這個。

; Program.DarkOrange()
       mov       eax,1
       mov       ecx,39
       xor       r8d,r8d
       mov       [rdx],r8
       mov       [rdx+8],r8
       mov       [rdx+10],cx
       mov       [rdx+12],ax
       mov       rax,rdx
       ret
; Total bytes of code 32

有趣的是,一些常量(39,是KnownColor.DarkOrange的值,和1,是一個私有的StateKnownColorValid常量)被加載到寄存器中(mov eax, 1 then mov ecx, 39),然後又被存儲到被返回的顏色結構的相關位置(mov [rdx+12],ax and mov [rdx+10],cx)。在.NET 7中,它現在產生了。

; Program.DarkOrange()
       xor       eax,eax
       mov       [rdx],rax
       mov       [rdx+8],rax
       mov       word ptr [rdx+10],39
       mov       word ptr [rdx+12],1
       mov       rax,rdx
       ret
; Total bytes of code 25

直接將這些常量值分配到它們的目標位置(mov word ptr [rdx+12],1 和 mov word ptr [rdx+10],39)。其他有助於常量摺疊的變化包括來自@SingleAccretiondotnet/runtime#58171和來自@SingleAccretiondotnet/runtime#57605

然而,一大類改進來自與傳播有關的優化,即正向替換。考慮一下這個愚蠢的基準。

[Benchmark]
public int Compute1() => Value + Value + Value + Value + Value;

[Benchmark]
public int Compute2() => SomethingElse() + Value + Value + Value + Value + Value;

private static int Value => 16;

[MethodImpl(MethodImplOptions.NoInlining)]
private static int SomethingElse() => 42;

如果我們看一下在.NET 6上爲Compute1生成的彙編代碼,它看起來和我們希望的一樣。我們將Value加了5次,Value被簡單地內聯,並返回一個常量值16,因此我們希望爲Compute1生成的彙編代碼實際上只是返回值80(十六進制0x50),這正是發生的情況。

; Program.Compute1()
       mov       eax,50
       ret
; Total bytes of code 6

但Compute2有點不同。代碼的結構是這樣的:對SomethingElse的額外調用最終會稍微擾亂JIT的分析,而.NET 6最終會得到這樣的彙編代碼。

; Program.Compute2()
       sub       rsp,28
       call      Program.SomethingElse()
       add       eax,10
       add       eax,10
       add       eax,10
       add       eax,10
       add       eax,10
       add       rsp,28
       ret
; Total bytes of code 29

我們不是用一個mov eax, 50來把數值0x50放到返回寄存器中,而是用5個獨立的add eax, 10來建立同樣的0x50(80)的數值。這......並不理想。

事實證明,許多JIT的優化都是在解析IL的過程中創建的樹狀數據結構上進行的。在某些情況下,當它們接觸到更多的程序時,優化可以做得更好,換句話說,當它們所操作的樹更大,包含更多需要分析的內容時。然而,各種操作可以將這些樹分解成更小的、單獨的樹,比如作爲內聯的一部分而創建的臨時變量,這樣做可以抑制這些操作。爲了有效地將這些樹縫合在一起,我們需要一些東西,這就是前置置換 (forward substitution)。你可以把前置置換看成是CSE的逆向操作;與其通過計算一次數值並將其存儲到一個臨時變量中來尋找重複的表達式並消除它們,不如前置置換來消除這個臨時變量並有效地將表達式樹移到它的使用位置。顯然,如果這樣做會否定CSE並導致重複工作的話,你是不想這樣做的,但是對於那些只定義一次並使用一次的表達式來說,這種前置傳播是很有價值的。 dotnet/runtime#61023添加了一個最初的有限的前置置換版本,然後dotnet/runtime#63720添加了一個更強大的通用實現。隨後,dotnet/runtime#70587將其擴展到了一些SIMD向量,然後dotnet/runtime#71161進一步改進了它,使其能夠替換到更多的地方(在這種情況下是替換到調用參數)。有了這些,我們愚蠢的基準現在在.NET 7上產生如下結果。

; Program.Compute2()
       sub       rsp,28
       call      qword ptr [7FFCB8DAF9A8]
       add       eax,50
       add       rsp,28
       ret
; Total bytes of code 18

原文鏈接

Performance Improvements in .NET 7

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含鏈接: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可發佈。

如有任何疑問,請與我聯繫 ([email protected])

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章