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

2023-02-24 06:00:27

原文 | 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])