.NET 7的性能改进有哪些具体细节?

摘要:原文 | Stephen Toub 翻译 | 郑子铭 边界检查消除 (Bounds Check Elimination) 让.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也不例外。 例如,来自@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上,这些索引操作中的每一个都会导致边界检查,其汇编如下。
阅读全文