【翻译】.NET 5中的性能改进 (6)


为了保证这段代码的安全,运行时需要生成一个检查,检查i是否在字符串s的范围内,这是JIT通过如下程序集完成的:

; Program.Get(System.String, Int32) sub rsp,28 cmp edx,[rcx+8] jae short M01_L00 movsxd rax,edx movzx eax,word ptr [rcx+rax*2+0C] add rsp,28 ret M01_L00: call CORINFO_HELP_RNGCHKFAIL int 3 ; Total bytes of code 28


这个程序集是通过Benchmark的一个方便特性生成的。将[DisassemblyDiagnoser]添加到包含基准测试的类中,它就会吐出被分解的汇编代码。我们可以看到,大会将字符串(通过rcx寄存器)和加载字符串的长度(8个字节存储到对象,因此,[rcx + 8]),与我经过比较,edx登记,如果与一个无符号的比较(无符号,这样任何负环绕大于长度)我是长度大于或等于,跳到一个辅助COREINFO_HELP_RNGCHKFAIL抛出一个异常。只有几条指令,但是某些类型的代码可能会花费大量的循环索引,因此,当JIT可以消除尽可能多的不必要的边界检查时,这是很有帮助的。
JIT已经能够在各种情况下删除边界检查。例如,当你写循环:

int[] arr = ...; for (int i = 0; i < arr.Length; i++) Use(arr[i]);


JIT可以证明我永远不会超出数组的边界,因此它可以省略它将生成的边界检查。在.NET5 中,它可以在更多的地方删除边界检查。例如,考虑这个函数,它将一个整数的字节作为字符写入一个span:

private static bool TryToHex(int value, Span<char> span) { if ((uint)span.Length <= 7) return false; ReadOnlySpan<byte> map = new byte[] { (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F' }; ; span[0] = (char)map[(value >> 28) & 0xF]; span[1] = (char)map[(value >> 24) & 0xF]; span[2] = (char)map[(value >> 20) & 0xF]; span[3] = (char)map[(value >> 16) & 0xF]; span[4] = (char)map[(value >> 12) & 0xF]; span[5] = (char)map[(value >> 8) & 0xF]; span[6] = (char)map[(value >> 4) & 0xF]; span[7] = (char)map[value & 0xF]; return true; } private char[] _buffer = new char[100]; [Benchmark] public bool BoundsChecking() => TryToHex(int.MaxValue, _buffer);


首先,在这个例子中,值得注意的是我们依赖于c#编译器的优化。注意:

ReadOnlySpan<byte> map = new byte[] { (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F' };


这看起来非常昂贵,就像我们在每次调用TryToHex时都要分配一个字节数组。事实上,它并不是这样的,它实际上比我们做的更好:

private static readonly byte[] s_map = new byte[] { (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F' }; ... ReadOnlySpan<byte> map = s_map;


C#编译器可以识别直接分配给ReadOnlySpan的新字节数组的模式(它也可以识别sbyte和bool,但由于字节关系,没有比字节大的)。因为数组的性质被span完全隐藏了,C#编译器通过将字节实际存储到程序集的数据部分而发出这些字节,而span只是通过将静态数据和长度的指针包装起来而创建的:

IL_000c: ldsflda valuetype '<PrivateImplementationDetails>'http://www.likecs.com/'__StaticArrayInitTypeSize=16' '<PrivateImplementationDetails>'::'2125B2C332B1113AAE9BFC5E9F7E3B4C91D828CB942C2DF1EEB02502ECCAE9E9' IL_0011: ldc.i4.s 16 IL_0013: newobj instance void valuetype [System.Runtime]System.ReadOnlySpan'1<uint8>::.ctor(void*, int32)


由于ldc.i4,这对于本次JIT讨论很重要。s16在上面。这就是IL加载16的长度来创建跨度,JIT可以看到这一点。它知道跨度的长度是16,这意味着如果它可以证明访问总是大于或等于0且小于16的值,它就不需要对访问进行边界检查。dotnet/runtime#1644 就是这样做的,它可以识别像array[index % const]这样的模式,并在const小于或等于长度时省略边界检查。在前面的TryToHex示例中,JIT可以看到地图跨长度16,和它可以看到所有的索引到完成& 0 xf,意义最终将所有值在范围内,因此它可以消除所有的边界检查地图。结合的事实可能已经看到,没有边界检查需要写进跨度(因为它可以看到前面长度检查的方法保护所有索引到跨度),和整个方法是在.NET bounds-check-free 5。在我的机器上,这个基准测试的结果如下:

Method Runtime Mean Ratio Code Size
BoundsChecking   .NET FW 4.8   14.466 ns   1.00   830 B  
BoundsChecking   .NET Core 3.1   4.264 ns   0.29   320 B  
BoundsChecking   .NET 5.0   3.641 ns   0.25   249 B  

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:https://www.heiqu.com/wsssyf.html