首页 > 其他分享 >【译】.NET 7 中的性能改进(四)

【译】.NET 7 中的性能改进(四)

时间:2023-09-18 16:08:42浏览次数:42  
标签:rcx int 性能 mov eax 改进 M00 edx NET

原文 | 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也不例外。

例如,来自@anthonycaninodotnet/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#61569dotnet/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。而这正是@pentpdotnet/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


标签:rcx,int,性能,mov,eax,改进,M00,edx,NET
From: https://blog.51cto.com/u_12517860/7511259

相关文章

  • 【译】使用 ML.NET 进行机器学习 - 集群完整指南
    原文|NikolaM.Zivkovic翻译|郑子铭在之前的几篇文章中,我们探索了一些基本的机器学习算法。到目前为止,我们介绍了一些简单的回归算法,分类算法。我们使用ML.NET实现和应用这些算法。到目前为止,我们探索了使用监督学习的算法。这意味着我们始终拥有用于训练机器学习模型的输......
  • BMS电池管理系统的蓝牙芯片 国产高性能 低功耗蓝牙Soc芯片PHY6222
    电池管理系统是对电池进行监控与控制的系统,将采集的电池信息实时反馈给用户,同时根据采集的信息调节参数,充分发挥电池的性能。但是,前技术中,在管理多个电池时,需要人员现场调试与设置,导致其检查、维护与更新相当不方便。而且,针对电池组的工作性能、电池老化情况、使用寿命等信息,需要......
  • 快学会这个技能-.NET API拦截技法
    大家好,我是沙漠尽头的狼。本文先抛出以下问题,请在文中寻找答案,可在评论区回答:什么是API拦截?一个方法被很多地方调用,怎么在不修改这个方法源码情况下,记录这个方法调用的前后时间?同2,不修改源码的情况下,怎么对方法的参数进行校正(篡改)?同3,不修改源码的情况下,怎么对方法的返回值进行伪造?......
  • 【译】.NET 7 中的性能改进(一)
    原文|StephenToub翻译|郑子铭一年前,我发布了.NET6中的性能改进,紧接着是.NET5、.NETCore3.0、.NETCore2.1和.NETCore2.0的类似帖子。我喜欢写这些帖子,也喜欢阅读开发人员对它们的回复。去年的一条评论特别引起了我的共鸣。评论者引用了虎胆龙威的电影名言,“'当亚历山......
  • exist和left join 性能对比
    今天遇到一个性能问题,再调优过程中发现耗时最久的计划是exist部分涉及的三个表。然后计划用leftjoin来替换exist,然后查询了很多资料,大部分都说exist和leftjoin性能差不多。为了验证这一结论进行了如下实验步骤如下1、创建测试表droptableapp_family;CREATETABLEapp......
  • C#中的高级主题:内存管理、性能优化和安全性
    简介:欢迎来到C#语言入门指南的第十三篇博客!在之前的博客中,我们已经学习了C#的基础和一些高级编程概念。今天,我们将深入研究C#中的高级主题,包括内存管理、性能优化和安全性,以帮助您构建更出色的应用程序。让我们开始吧!1.内存管理:了解C#中的内存管理是非常重要的,它可以帮助您避免......
  • yarn 出现 【 info There appears to be trouble with your network connection. Retr
    第一种解决方案#调整为taobao镜像源yarnconfigsetregistryhttps://registry.npm.taobao.org我用了没用,可以试试第二种解决方案要在项目根目录下创建后缀名为.yarnrc的文件,并设置network-timeout的值为600000,你可以按照以下步骤进行操作:打开文本编辑器,例如Note......
  • ASP.Net Web项目调试运行没问题,发布后一直403
    ASP.NetWeb项目调试运行没问题,发布一直403因为换了仓库,同事移了下项目,然后就如标题了,经过几个小时排查,发现项目中只有Global.asax.cs而Global.asax没有包含进项目…特此记录下......
  • vb.net 开发 excel Addin 学习(5)---- 几个小问题
    在做 excelAddin开发的时候越到了几个小问题。总结一下。一,Addin无缘无故不加载。没有任何痕迹可查询。解决方法: 可能是Excel禁止了你的addin,也就是你的addin被列入了黑名单,如果真是这样,看一下下面的(有图示说明),或许可以解决问题。在Excel2003中,点击标题栏中的......
  • .net core 浏览器缓存设置
    1、浏览器缓存设置  [ResponseCache(Duration=20)] 将ResponseCache特性设置在接口方法上就可以了,Duration中的时间是以秒为单位2、服务端缓存设置 a、将app.UseResponseCaching()方法放在app.MapControllers()方法之前  b、如果使用了app.UseCors()方法,要确保app.......