.Net 7性能改进-JIT-循环提升与克隆

前言

本文是 Performance Improvements in .NET 7 Loop Hoisting and Cloning(循环提升与克隆)部分的翻译.下面开始正文:



我们之前看到了PGO如何与环提升和克隆相互作用,这些优化也看到了其他改进.

从历史上看,JIT对提升的支持仅限于不变量提升到一个级别.考虑一下这个例子:
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*1000hundreds*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,r9djae 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与执行更广泛操作的主体克隆循环.

秋风 2022-09-15