.Net 7性能改进-消除边界检查
前言
本文是 Performance Improvements in .NET 7 Bounds Check Elimination(消除边界检查)部分的翻译.下面开始正文:[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
[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;
}
它验证输入span为16字节长,然后创建一个new 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
没有边界检查,这是很容易看到的,因为在方法的最后没有显示call CORINFO_HELP_RNGCHKFAIL.通过这个提交, JIT能够理解某些乘法和移位操作的影响,以及它们对数据结构边界的关系.因为它可以看到结果数组的长度是8,并且循环从0迭代到那个排他的上界,它知道i将始终在[0,7]范围内,这意味着i *2将始终在 [0,14]范围内,i* 2 + 1将始终在[0,15]范围内.因此,它能够证明不需要边界检查.
dotnet/runtime#61569和dotnet/runtime#62864也有助于消除边界检查,当处理常量字符串和Span初始化从静态RVA(“相对虚拟地址”静态字段,基本上是模块数据段中的静态字段).例如,考虑以下基准测试:
[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,所以它实现了 (uint)i < Text.长度检查通过做 cmp rax,5,如果 i作为一个无符号值大于或等于5,它然后零'返回值(返回 '\0')和退出.如果长度小于5(在这种情况下,由于无符号比较,它至少是0),然后跳转到M00_L00从字符串中读取值…但我们随后看到另一个 cmp针对5,这一次是作为范围检查的一部分.因此,即使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
前面提到的提交负责长度检查.这个提交负责位偏移.所以在.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位值的上32位的值,以便数字保留其有符号的值).有趣的是,我们知道数组和张成空间的长度是非负的,因为我们只是对长度进行了i的边界检查,我们也知道i是非负的.这使得符号扩展毫无用处,因为上边的位保证是0.由于mov指令的零扩展比movsxd稍微便宜一点,我们可以简单地使用它来代替.这正是dotnet/runtime#57970 from @pentp为数组和span所做的(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;
}
Method | Runtime | Mean | Ratio |
Sum | .NET 6.0 | 964.1 us | 1.00 |
Sum | .NET 7.0 | 674.7 us | 0.70 |
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 intrinsic.“intrinsic”由编译器可以替换它认为更好的代码,而不是仅仅依赖于方法定义的实现(如果它甚至有一个).在.Net中有数以千计的方法以这种方式为JIT所知,其中GetLowerBound和GetUpperBound就是最新的两个方法.现在,作为intrinsic,当传递给它们一个常量值(例如0表示第0个rank)时,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
重要的是,注意没有更多的call(调用) (除了末尾的边界检查异常).例如,不是第一次调用GetUpperBound:
call System.Array.GetUpperBound(Int32)
我们得到:
mov r9d,[rdx+1C]
mov r10d,[rdx+14]
lea r10d,[r9+r10+0FFFF]
最终使代码运行的更快:
Method | Runtime | Mean | Ratio |
Sum | .NET 6.0 | 2,657.5 us | 1.00 |
Sum | .NET 7.0 | 676.3 us | 0.25 |