【译】.NET 7 中的性能改进(四)
原文 | 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 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#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

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
欢迎转载、使用、重新发布,但务必保留文章署名 郑子铭 (包含链接: http://www.cnblogs.com/MingsonZheng/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。
如有任何疑问,请与我联系 (MingsonZheng@outlook.com)
【译】.NET 7 中的性能改进(四)的更多相关文章
- 【译】ASP.NET Core 6 中的性能改进
原文 | Brennan Conroy 翻译 | 郑子铭 受到 Stephen Toub 关于 .NET 性能的博文的启发,我们正在写一篇类似的文章来强调 6.0 中对 ASP.NET Core 所做 ...
- 【翻译】.NET 5中的性能改进
[翻译].NET 5中的性能改进 在.NET Core之前的版本中,其实已经在博客中介绍了在该版本中发现的重大性能改进. 从.NET Core 2.0到.NET Core 2.1到.NET Core ...
- .NET 4.6中的性能改进
.NET 4.6中带来了一些与性能改进相关的CLR特性,这些特性中有一部分将会自动生效,而另外一些特性,例如SIMD与异步本地存储(Async Local Storage)则需要对编写应用的方式进行某 ...
- .NET性能系列文章一:.NET7的性能改进
这些方法在.NET7中变得更快 照片来自 CHUTTERSNAP 的 Unsplash 欢迎阅读.NET性能系列的第一章.这一系列的特点是对.NET世界中许多不同的主题进行研究.比较性能.正如标题所说 ...
- .NET 5 中的正则引擎性能改进(翻译)
前言 System.Text.RegularExpressions 命名空间已经在 .NET 中使用了多年,一直追溯到 .NET Framework 1.1.它在 .NET 实施本身的数百个位置中使用 ...
- 译<容器网络中OVS-DPDK的性能>
译<容器网络中OVS-DPDK的性能> 本文来自对Performance of OVS-DPDK in Container Networks的翻译. 概要--网络功能虚拟化(Network ...
- [译]async/await中使用阻塞式代码导致死锁 百万数据排序:优化的选择排序(堆排序)
[译]async/await中使用阻塞式代码导致死锁 这篇博文主要是讲解在async/await中使用阻塞式代码导致死锁的问题,以及如何避免出现这种死锁.内容主要是从作者Stephen Cleary的 ...
- Lazy<T>在Entity Framework中的性能优化实践
Lazy<T>在Entity Framework中的性能优化实践(附源码) 2013-10-27 18:12 by JustRun, 328 阅读, 4 评论, 收藏, 编辑 在使用EF的 ...
- [译]async/await中使用阻塞式代码导致死锁
原文:[译]async/await中使用阻塞式代码导致死锁 这篇博文主要是讲解在async/await中使用阻塞式代码导致死锁的问题,以及如何避免出现这种死锁.内容主要是从作者Stephen Clea ...
- [译]async/await中阻塞死锁
这篇博文主要是讲解在async/await中使用阻塞式代码导致死锁的问题,以及如何避免出现这种死锁.内容主要是从作者Stephen Cleary的两篇博文中翻译过来. 原文1:Don'tBlock o ...
随机推荐
- c# Winfrom桌面软件自动升级系统
对于开发桌面应用升级应该是我们第一个要考虑的.一般而言一个项目只有一个客户端,有的时候一个项目可能分好几个客户端,前台客户端,后台客户端.而我在网上找了很久也没有找到可以同时管理多个客户端升级的.所以 ...
- 腾讯会议如何在Linux下的安装与使用
腾讯会议官网https://meeting.tencent.com/download?mfrom=OfficialIndex_TopBanner1_Download下载 腾讯会议官网只提供了deb版的 ...
- windows使用pyinstaller 打包sklearn模块出现死循环报错
前言 解决这个让我花费了很长时间, 我这里说的死循环,不是正常通过 --hidden-import能解决的问题. 因为我也查询了很多资料 但是无一例外都失败了(能通过 –hidden-import 解 ...
- 爬虫笔记之xpath
目录 xpath如何取包含多个class属性 xpath获取当前标签下的所有文本(包括子标签) xpath如何取包含多个class属性 如果HTML结构是这样 <div class=" ...
- K8s 超详细总结
一个目标:容器操作:两地三中心:四层服务发现:五种Pod共享资源:六个CNI常用插件:七层负载均衡:八种隔离维度:九个网络模型原则:十类IP地址:百级产品线:千级物理机:万级容器:相如无亿,K8s有亿 ...
- JavaScript Promises, async/await
new Promise() 的时候,传一个 executor 给 Promise. let promise = new Promise(function(resolve, reject) { // t ...
- 论文解读(PCL)《Probabilistic Contrastive Learning for Domain Adaptation》
论文信息 论文标题:Probabilistic Contrastive Learning for Domain Adaptation论文作者:Junjie Li, Yixin Zhang, Zilei ...
- Spring IOC源码(一):IOC容器启动流程核心方法概览
Spring有两种方式加载配置,分别为xml文件.注解的方式,对于xml配置的方式相信大家都不陌生,往往通过new ClassPathXmlApplicationContext("*.xml ...
- salesforce零基础学习(一百二十三)Transaction Security 浅入浅出
本篇参考: https://help.salesforce.com/s/articleView?id=sf.enhanced_transaction_security_policy_types.htm ...
- vue 强制刷新数据 this.$forceUpdate()
vue项目中,修改了数据可能已经渲染的地方不会发生变化,所以加上 this.$forceUpdate()可以强制刷新数据