为了保证这段代码的安全,运行时需要生成一个检查,检查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