.Net 7性能改进-JIT-循环提升与克隆
前言
本文是 Performance Improvements in .NET 7 Loop Hoisting and Cloning(循环提升与克隆)部分的翻译.下面开始正文: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的计算实际上被分成多个部分,每个部分可以被提升到不同的层次,例如,数十的计算可以提升一层,数百的计算可以提升两层,数千的计算可以提升三层.下面是.NET 6中使用[DisassemblyDiagnoser]输出:
; 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(此时为“ ones”计数器值),如果它仍然小于0xA(10),则跳回到M00_ L03,将r14中的所有添加到“ones”.太好了,所以我们已经将所有不必要的计算从内部循环中移除,只剩下将1的位置添加到数字的其余部分.让我们去一个水平.M00_L02是tens循环的标签.我们在那里看到了什么?麻烦这两条指令 imul ecx,esi 3E8和 imul eax,edi, 64正在执行thousands*1000和 hundreds*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和jae short M00_L03,它跳转到一个call 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有一个分支指向同一个call CORINFO_HELP_RNGCHKFAIL,但第一个没有,因为只有在证明offset、count和_values.Length,循环才会被使用.长度是这样的,索引将始终在边界内.
其他提交也改进了循环克隆.dotnet/runtime#59886使JIT能够选择不同的形式来发出选择快速或缓慢循环路径的条件,例如是否发出所有的条件,然后分支(if (!)(cond1 & cond2)) goto slowPath),或者是否单独发出每个条件(if (!cond1) goto slowPath ;如果(!cond2) goto slowPath).dotnet/runtime#66257启用循环克隆踢在循环变量初始化为更多种类的表达式(例如,for (int frommindex = lastIndex - lengthToClear;…)).而dotnet/runtime#70232增加了JIT与执行更广泛操作的主体克隆循环.