原文 | 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也不例外。
例如,來自@anthonycanino的dotnet/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#61569和dotnet/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。而這正是@pentp的dotnet/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)