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

鄭子銘發表於2023-02-22

原文 | Stephen Toub

翻譯 | 鄭子銘

邊界檢查消除 (Bounds Check Elimination)

讓.NET吸引人的地方之一是它的安全性。執行時保護對陣列、字串和跨度的訪問,這樣你就不會因為走到任何一端而意外地破壞記憶體;如果你這樣做,而不是讀/寫任意的記憶體,你會得到異常。當然,這不是魔術;它是由JIT在每次對這些資料結構進行索引時插入邊界檢查完成的。例如,這個:

[MethodImpl(MethodImplOptions.NoInlining)]
static int Read0thElement(int[] array) => array[0];

結果是:

G_M000_IG01:                ;; offset=0000H
       4883EC28             sub      rsp, 40

G_M000_IG02:                ;; offset=0004H
       83790800             cmp      dword ptr [rcx+08H], 0
       7608                 jbe      SHORT G_M000_IG04
       8B4110               mov      eax, dword ptr [rcx+10H]

G_M000_IG03:                ;; offset=000DH
       4883C428             add      rsp, 40
       C3                   ret

G_M000_IG04:                ;; offset=0012H
       E8E9A0C25F           call     CORINFO_HELP_RNGCHKFAIL
       CC                   int3

陣列在rcx暫存器中被傳入這個方法,指向物件中的方法表指標,而陣列的長度就儲存在物件中的方法表指標之後(在64位程式中是8位元組)。因此,cmp dword ptr [rcx+08H], 0指令是在讀取陣列的長度,並將長度與0進行比較;這是有道理的,因為長度不能是負數,而且我們試圖訪問第0個元素,所以只要長度不是0,陣列就有足夠的元素讓我們訪問其第0個元素。如果長度為0,程式碼會跳到函式的末尾,其中包含呼叫 CORINFO_HELP_RNGCHKFAIL;那是一個JIT輔助函式,丟擲一個 IndexOutOfRangeException。然而,如果長度足夠,它就會讀取儲存在陣列資料開始處的int,在64位上,它比指標(mov eax, dword ptr [rcx+10H])多16位元組(0x10)。

雖然這些邊界檢查本身並不昂貴,但做了很多,其成本就會增加。因此,雖然JIT需要確保 "安全 "的訪問不會出界,但它也試圖證明某些訪問不會出界,在這種情況下,它不需要發出邊界檢查,因為它知道這將是多餘的。在每一個.NET版本中,越來越多的案例被加入,以找到可以消除這些邊界檢查的地方,.NET 7也不例外。

例如,來自@anthonycaninodotnet/runtime#61662使JIT能夠理解各種形式的二進位制操作作為範圍檢查的一部分。考慮一下這個方法。

[MethodImpl(MethodImplOptions.NoInlining)]
private static ushort[]? Convert(ReadOnlySpan<byte> bytes)
{
    if (bytes.Length != 16)
    {
        return null;
    }

    var result = new ushort[8];
    for (int i = 0; i < result.Length; i++)
    {
        result[i] = (ushort)(bytes[i * 2] * 256 + bytes[i * 2 + 1]);
    }

    return result;
}

它正在驗證輸入跨度是16個位元組,然後建立一個新的ushort[8],陣列中的每個ushort結合了兩個輸入位元組。為了做到這一點,它在輸出陣列上迴圈,並使用i * 2和i * 2 + 1作為索引進入位元組陣列。在.NET 6上,這些索引操作中的每一個都會導致邊界檢查,其彙編如下。

cmp       r8d,10    
jae       short G_M000_IG04 
movsxd    r8,r8d

其中 G_M000_IG04 是我們現在熟悉的 CORINFO_HELP_RNGCHKFAIL 的呼叫。但在.NET 7上,我們得到這個方法的彙編。

G_M000_IG01:                ;; offset=0000H
       56                   push     rsi
       4883EC20             sub      rsp, 32

G_M000_IG02:                ;; offset=0005H
       488B31               mov      rsi, bword ptr [rcx]
       8B4908               mov      ecx, dword ptr [rcx+08H]
       83F910               cmp      ecx, 16
       754C                 jne      SHORT G_M000_IG05
       48B9302F542FFC7F0000 mov      rcx, 0x7FFC2F542F30
       BA08000000           mov      edx, 8
       E80C1EB05F           call     CORINFO_HELP_NEWARR_1_VC
       33D2                 xor      edx, edx
                            align    [0 bytes for IG03]

G_M000_IG03:                ;; offset=0026H
       8D0C12               lea      ecx, [rdx+rdx]
       448BC1               mov      r8d, ecx
       FFC1                 inc      ecx
       458BC0               mov      r8d, r8d
       460FB60406           movzx    r8, byte  ptr [rsi+r8]
       41C1E008             shl      r8d, 8
       8BC9                 mov      ecx, ecx
       0FB60C0E             movzx    rcx, byte  ptr [rsi+rcx]
       4103C8               add      ecx, r8d
       0FB7C9               movzx    rcx, cx
       448BC2               mov      r8d, edx
       6642894C4010         mov      word  ptr [rax+2*r8+10H], cx
       FFC2                 inc      edx
       83FA08               cmp      edx, 8
       7CD0                 jl       SHORT G_M000_IG03

G_M000_IG04:                ;; offset=0056H
       4883C420             add      rsp, 32
       5E                   pop      rsi
       C3                   ret

G_M000_IG05:                ;; offset=005CH
       33C0                 xor      rax, rax

G_M000_IG06:                ;; offset=005EH
       4883C420             add      rsp, 32
       5E                   pop      rsi
       C3                   ret

; Total bytes of code 100

沒有邊界檢查,這一點最容易從方法結尾處沒有提示性的呼叫 CORINFO_HELP_RNGCHKFAIL 看出來。有了這個PR,JIT能夠理解某些乘法和移位操作的影響以及它們與資料結構的邊界的關係。因為它可以看到結果陣列的長度是8,並且迴圈從0到那個獨佔的上界進行迭代,它知道i總是在[0, 7]範圍內,這意味著i * 2總是在[0, 14]範圍內,i * 2 + 1總是在[0, 15]範圍內。因此,它能夠證明邊界檢查是不需要的。

dotnet/runtime#61569dotnet/runtime#62864也有助於在處理從RVA靜態欄位("相對虛擬地址 (Relative Virtual Address)"靜態欄位,基本上是住在模組資料部分的靜態欄位)初始化的常量字串和跨度時消除邊界檢查。例如,考慮這個基準。

[Benchmark]
[Arguments(1)]
public char GetChar(int i)
{
    const string Text = "hello";
    return (uint)i < Text.Length ? Text[i] : '\0';
}

在.NET 6上,我們得到這個程式集:

; Program.GetChar(Int32)
       sub       rsp,28
       mov       eax,edx
       cmp       rax,5
       jl        short M00_L00
       xor       eax,eax
       add       rsp,28
       ret
M00_L00:
       cmp       edx,5
       jae       short M00_L01
       mov       rax,2278B331450
       mov       rax,[rax]
       movsxd    rdx,edx
       movzx     eax,word ptr [rax+rdx*2+0C]
       add       rsp,28
       ret
M00_L01:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 56

這開始是有意義的:JIT顯然能夠看到Text的長度是5,所以它透過做cmp rax,5來實現(uint)i < Text.Length的檢查,如果i作為一個無符號值大於或等於5,它就把返回值清零(返回'\0')並退出。如果長度小於5(在這種情況下,由於無符號比較,它也至少是0),它就會跳到M00_L00,從字串中讀取值......但是我們又看到了另一個與5的cmp,這次是作為範圍檢查的一部分。因此,即使JIT知道索引在邊界內,它也無法移除邊界檢查。現在是這樣;在.NET 7中,我們得到這樣的結果。

; Program.GetChar(Int32)
       cmp       edx,5
       jb        short M00_L00
       xor       eax,eax
       ret
M00_L00:
       mov       rax,2B0AF002530
       mov       rax,[rax]
       mov       edx,edx
       movzx     eax,word ptr [rax+rdx*2+0C]
       ret
; Total bytes of code 29

好多了。

dotnet/runtime#67141是一個很好的例子,說明不斷髮展的生態系統需求是如何促使特定的最佳化進入JIT的。Regex編譯器和原始碼生成器透過使用儲存在字串中的點陣圖查詢來處理正規表示式字元類的一些情況。例如,為了確定一個char c是否屬於字元類"[A-Za-z0-9_]"(這將匹配下劃線或任何ASCII字母或數字),該實現最終會生成一個類似以下方法主體的表示式。

[Benchmark]
[Arguments('a')]
public bool IsInSet(char c) =>
    c < 128 && ("\0\0\0\u03FF\uFFFE\u87FF\uFFFE\u07FF"[c >> 4] & (1 << (c & 0xF))) != 0;

這個實現是把一個8個字元的字串當作一個128位的查詢表。如果已知該字元在範圍內(比如它實際上是一個7位的值),那麼它就用該值的前3位來索引字串的8個元素,用後4位來選擇該元素中的16位之一,給我們一個答案,即這個輸入字元是否在該集合中。在.NET 6中,即使我們知道這個字元在字串的範圍內,JIT也無法看穿長度比較或位移。

; Program.IsInSet(Char)
       sub       rsp,28
       movzx     eax,dx
       cmp       eax,80
       jge       short M00_L00
       mov       edx,eax
       sar       edx,4
       cmp       edx,8
       jae       short M00_L01
       mov       rcx,299835A1518
       mov       rcx,[rcx]
       movsxd    rdx,edx
       movzx     edx,word ptr [rcx+rdx*2+0C]
       and       eax,0F
       bt        edx,eax
       setb      al
       movzx     eax,al
       add       rsp,28
       ret
M00_L00:
       xor       eax,eax
       add       rsp,28
       ret
M00_L01:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 75

前面提到的PR處理了長度檢查的問題。而這個PR則負責處理位的移動。所以在.NET 7中,我們得到了這個可愛的東西。

; Program.IsInSet(Char)
       movzx     eax,dx
       cmp       eax,80
       jge       short M00_L00
       mov       edx,eax
       sar       edx,4
       mov       rcx,197D4800608
       mov       rcx,[rcx]
       mov       edx,edx
       movzx     edx,word ptr [rcx+rdx*2+0C]
       and       eax,0F
       bt        edx,eax
       setb      al
       movzx     eax,al
       ret
M00_L00:
       xor       eax,eax
       ret
; Total bytes of code 51

請注意,明顯缺乏對 CORINFO_HELP_RNGCHKFAIL 的呼叫。正如你可能猜到的那樣,這種檢查在 Regex 中可能會發生很多,這使得它成為一個非常有用的補充。

當談及陣列訪問時,邊界檢查是一個明顯的開銷來源,但它們不是唯一的。還有就是要儘可能地使用最便宜的指令。在.NET 6中,有一個方法,比如:

[MethodImpl(MethodImplOptions.NoInlining)]
private static int Get(int[] values, int i) => values[i];

將會生成如下的彙編程式碼:

; Program.Get(Int32[], Int32)
       sub       rsp,28
       cmp       edx,[rcx+8]
       jae       short M01_L00
       movsxd    rax,edx
       mov       eax,[rcx+rax*4+10]
       add       rsp,28
       ret
M01_L00:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 27

這在我們之前的討論中應該很熟悉;JIT正在載入陣列的長度([rcx+8])並與i的值(在edx中)進行比較,然後跳轉到最後,如果i出界就丟擲異常。在跳轉之後,我們看到一條movsxd rax, edx指令,它從edx中獲取i的32位值並將其移動到64位暫存器rax中。作為移動的一部分,它對其進行了符號擴充套件;這就是指令名稱中的 "sxd "部分(符號擴充套件意味著新的64位值的前32位將被設定為32位值的前一位的值,這樣數字就保留了其符號值)。但有趣的是,我們知道陣列和跨度的長度是非負的,而且由於我們剛剛用長度對i進行了邊界檢查,我們也知道i是非負的。這使得這種符號擴充套件毫無用處,因為上面的位被保證為0。而這正是@pentpdotnet/runtime#57970對陣列和跨度的作用(dotnet/runtime#70884也同樣避免了其他情況下的一些有符號轉換)。現在在.NET 7上,我們得到了這個。

; Program.Get(Int32[], Int32)
       sub       rsp,28
       cmp       edx,[rcx+8]
       jae       short M01_L00
       mov       eax,edx
       mov       eax,[rcx+rax*4+10]
       add       rsp,28
       ret
M01_L00:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 26

不過,這並不是陣列訪問的唯一開銷來源。事實上,有一類非常大的陣列訪問開銷一直存在,但這是眾所周知的,甚至有老的FxCop規則和新的Roslyn分析器都警告它:多維陣列訪問。多維陣列的開銷不僅僅是在每個索引操作上的額外分支,或者計算元素位置所需的額外數學運算,而是它們目前透過JIT的最佳化階段時基本沒有修改。dotnet/runtime#70271改善了世界上的現狀,在JIT的管道早期對多維陣列訪問進行擴充套件,這樣以後的最佳化階段可以像改善其他程式碼一樣改善多維訪問,包括CSE和迴圈不變數提升。這方面的影響在一個簡單的基準中可以看到,這個基準是對一個多維陣列的所有元素進行求和。

private int[,] _square;

[Params(1000)]
public int Size { get; set; }

[GlobalSetup]
public void Setup()
{
    int count = 0;
    _square = new int[Size, Size];
    for (int i = 0; i < Size; i++)
    {
        for (int j = 0; j < Size; j++)
        {
            _square[i, j] = count++;
        }
    }
}

[Benchmark]
public int Sum()
{
    int[,] square = _square;
    int sum = 0;
    for (int i = 0; i < Size; i++)
    {
        for (int j = 0; j < Size; j++)
        {
            sum += square[i, j];
        }
    }
    return sum;
}
方法 執行時 平均值 比率
Sum .NET 6.0 964.1 us 1.00
Sum .NET 7.0 674.7 us 0.70

前面的例子假設你知道多維陣列中每個維度的大小(它在迴圈中直接引用了Size)。顯然,這並不總是(甚至可能很少)的情況。在這種情況下,你更可能使用Array.GetUpperBound方法,而且因為多維陣列可以有一個非零的下限,所以使用Array.GetLowerBound。這將導致這樣的程式碼。

private int[,] _square;

[Params(1000)]
public int Size { get; set; }

[GlobalSetup]
public void Setup()
{
    int count = 0;
    _square = new int[Size, Size];
    for (int i = 0; i < Size; i++)
    {
        for (int j = 0; j < Size; j++)
        {
            _square[i, j] = count++;
        }
    }
}

[Benchmark]
public int Sum()
{
    int[,] square = _square;
    int sum = 0;
    for (int i = square.GetLowerBound(0); i < square.GetUpperBound(0); i++)
    {
        for (int j = square.GetLowerBound(1); j < square.GetUpperBound(1); j++)
        {
            sum += square[i, j];
        }
    }
    return sum;
}

在.NET 7中,由於dotnet/runtime#60816,那些GetLowerBound和GetUpperBound的呼叫成為JIT的內在因素。對於編譯器來說,"內在的 "是指編譯器擁有內在的知識,這樣就不會僅僅依賴一個方法的定義實現(如果它有的話),編譯器可以用它認為更好的東西來替代。在.NET中,有數以千計的方法以這種方式為JIT所知,其中GetLowerBound和GetUpperBound是最近的兩個。現在,作為本徵,當它們被傳遞一個常量值時(例如,0代表第0級),JIT可以替代必要的彙編指令,直接從存放邊界的記憶體位置讀取。下面是這個基準的彙編程式碼在.NET 6中的樣子;這裡主要看到的是對GetLowerBound和GetUpperBound的所有呼叫。

; Program.Sum()
       push      rdi
       push      rsi
       push      rbp
       push      rbx
       sub       rsp,28
       mov       rsi,[rcx+8]
       xor       edi,edi
       mov       rcx,rsi
       xor       edx,edx
       cmp       [rcx],ecx
       call      System.Array.GetLowerBound(Int32)
       mov       ebx,eax
       mov       rcx,rsi
       xor       edx,edx
       call      System.Array.GetUpperBound(Int32)
       cmp       eax,ebx
       jle       short M00_L03
M00_L00:
       mov       rcx,[rsi]
       mov       ecx,[rcx+4]
       add       ecx,0FFFFFFE8
       shr       ecx,3
       cmp       ecx,1
       jbe       short M00_L05
       lea       rdx,[rsi+10]
       inc       ecx
       movsxd    rcx,ecx
       mov       ebp,[rdx+rcx*4]
       mov       rcx,rsi
       mov       edx,1
       call      System.Array.GetUpperBound(Int32)
       cmp       eax,ebp
       jle       short M00_L02
M00_L01:
       mov       ecx,ebx
       sub       ecx,[rsi+18]
       cmp       ecx,[rsi+10]
       jae       short M00_L04
       mov       edx,ebp
       sub       edx,[rsi+1C]
       cmp       edx,[rsi+14]
       jae       short M00_L04
       mov       eax,[rsi+14]
       imul      rax,rcx
       mov       rcx,rdx
       add       rcx,rax
       add       edi,[rsi+rcx*4+20]
       inc       ebp
       mov       rcx,rsi
       mov       edx,1
       call      System.Array.GetUpperBound(Int32)
       cmp       eax,ebp
       jg        short M00_L01
M00_L02:
       inc       ebx
       mov       rcx,rsi
       xor       edx,edx
       call      System.Array.GetUpperBound(Int32)
       cmp       eax,ebx
       jg        short M00_L00
M00_L03:
       mov       eax,edi
       add       rsp,28
       pop       rbx
       pop       rbp
       pop       rsi
       pop       rdi
       ret
M00_L04:
       call      CORINFO_HELP_RNGCHKFAIL
M00_L05:
       mov       rcx,offset MT_System.IndexOutOfRangeException
       call      CORINFO_HELP_NEWSFAST
       mov       rsi,rax
       call      System.SR.get_IndexOutOfRange_ArrayRankIndex()
       mov       rdx,rax
       mov       rcx,rsi
       call      System.IndexOutOfRangeException..ctor(System.String)
       mov       rcx,rsi
       call      CORINFO_HELP_THROW
       int       3
; Total bytes of code 219

現在,對於.NET 7來說,這裡是它的內容:

; Program.Sum()
       push      r14
       push      rdi
       push      rsi
       push      rbp
       push      rbx
       sub       rsp,20
       mov       rdx,[rcx+8]
       xor       eax,eax
       mov       ecx,[rdx+18]
       mov       r8d,ecx
       mov       r9d,[rdx+10]
       lea       ecx,[rcx+r9+0FFFF]
       cmp       ecx,r8d
       jle       short M00_L03
       mov       r9d,[rdx+1C]
       mov       r10d,[rdx+14]
       lea       r10d,[r9+r10+0FFFF]
M00_L00:
       mov       r11d,r9d
       cmp       r10d,r11d
       jle       short M00_L02
       mov       esi,r8d
       sub       esi,[rdx+18]
       mov       edi,[rdx+10]
M00_L01:
       mov       ebx,esi
       cmp       ebx,edi
       jae       short M00_L04
       mov       ebp,[rdx+14]
       imul      ebx,ebp
       mov       r14d,r11d
       sub       r14d,[rdx+1C]
       cmp       r14d,ebp
       jae       short M00_L04
       add       ebx,r14d
       add       eax,[rdx+rbx*4+20]
       inc       r11d
       cmp       r10d,r11d
       jg        short M00_L01
M00_L02:
       inc       r8d
       cmp       ecx,r8d
       jg        short M00_L00
M00_L03:
       add       rsp,20
       pop       rbx
       pop       rbp
       pop       rsi
       pop       rdi
       pop       r14
       ret
M00_L04:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 130

重要的是,注意沒有更多的呼叫(除了最後的邊界檢查異常)。例如,代替第一次的GetUpperBound呼叫。

call      System.Array.GetUpperBound(Int32)

我們得到了:

mov       r9d,[rdx+1C]
mov       r10d,[rdx+14]
lea       r10d,[r9+r10+0FFFF]

而且最後會快得多:

方法 執行時 平均值 比率
Sum .NET 6.0 2,657.5 us 1.00
Sum .NET 7.0 676.3 us 0.25

原文連結

Performance Improvements in .NET 7

知識共享許可協議

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

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

如有任何疑問,請與我聯絡 (MingsonZheng@outlook.com)

相關文章