天天看點

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

作者:opendotnet

回複“1”擷取開發者路線圖

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

學習分享 丨作者 / 鄭 子 銘

這是DotNet NB 公衆号的第209篇原創文章

原文 | 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 ;
 }

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

推薦閱讀:【譯】.NET 7 中的性能改進(三)
【譯】.NET 7 中的性能改進(二)
【譯】.NET 7 中的性能改進(一)
一款針對EF Core輕量級分表分庫、讀寫分離的開源項目
跟我一起 掌握AspNetCore底層技術和建構原理
C#泛型最全面剖析,前世今生
點選下方卡片關注DotNet NB

一起交流學習


        
【譯】.NET 7 中的性能改進(四)
▲ 點選上方卡片關注DotNet NB,一起交流學習 請在公衆号背景 回複 【路線圖】擷取.NET 2021開發者路線圖回複 【原創内容】擷取公衆号原創内容回複 【峰會視訊】擷取.NET Conf開發者大會視訊回複 【個人簡介】擷取作者個人簡介回複 【年終總結】擷取作者年終總結回複 【加群】加入DotNet NB 交流學習群 和我一起,交流學習,分享心得。
【譯】.NET 7 中的性能改進(四)

繼續閱讀