隐式空值检查是一种技术,用于在高级语言本机表示中移除对空指针/引用的显式检查,而是依靠处理器发出访问冲突,然后对其进行处理(即 SEH),并将其转换为托管异常。它主要用于异常处理开销次要的情况,例如,如果我们知道空值异常很少见。
在我发现的所有例子中,这些检查都是针对访问相关 ptr 的语句进行的:
int m(Foo foo) {
return foo.x;
}
这里,我们可以简单地发出 asm 代码:
mov rax,[rcx]
并让本机异常处理机制处理生成 NullReferenceException,而不是崩溃。
但是,函数调用又如何呢?
int m(Foo foo) {
return foo.MemberFunction();
}
是否也可以在那里使用隐式空检查?我对 x64-asm 特别感兴趣。那里似乎更难。让我们看一个 asm 中的非虚拟函数调用示例(代码与函数 1:1 不匹配,它包含一个“mov”,只是为了显示一个对象被设置到用于 Windows 上的成员函数调用的寄存器中):
mov rcx,[rsp+20h] // load target-object from stack-local (Foo*)
call Foo::MemberFunction // call Foo::MemberFunction, can be represented with an address w/o fixups of the ptr
在这里,我们无法访问“rcx”指向的内存。因此,如果根据语言的定义,这样的调用必须在调用点抛出 NullReferenceException,我们需要使用显式检查:
mov rcx,[rsp+32h] // load target-object from stack-local (Foo*)
test rcx,rcx
je .L0 // exception-handler already moved out of hot-path
call Foo::MemberFunction // call Foo::MemberFunction, can be represented with an address w/o fixups of the ptr
...
.L0:
call throwNullReferenceException();
或者有没有更有效的方法用一条产生访问冲突的指令替换 test+je 对?我想我可以这样做
mov rcx,[rsp+32h] // load target-object from stack-local (Foo*)
mov rax,[rcx] // mov into unused reg, to trigger access-violation
call Foo::MemberFunction // call Foo::MemberFunction, can be represented with an address w/o fixups of the ptr
这不会使用分支,也不需要额外调用异常调用。但是,它可能需要读取 [rcx] 的内存,而另一种方法不需要。与分支相比,它的性能如何?如果更差,有没有更好的方法?请参阅下文以进一步解释完整的用例。
背景
我有一种自定义的高级语言,它被编译为字节码,然后被编译为本机 ASM。该语言使用 NullReference 异常优雅地处理空检查。异常仍然始终是需要解决的错误,而不是正常发生的事件。因此,处理异常的代码可能效率低下。重要的是,在通常没有异常(因此没有空引用)的情况下,代码运行得尽可能快。这就是隐式空检查看起来很有吸引力的原因。删除处理调用异常所需的所有分支和额外代码可能会有益。不过,即使是现有的检查也应该已经很快了。分支应该可以很好地预测为始终为假,并且我已经这样做了,所以这种情况根本不需要 jmp,而是让代码线性执行(我读过这是更优化的)。
那么考虑到这一点,我在上述情况下试图摆脱这些检查是否愚蠢,或者是否有某种方法可以最佳地实现它?
除非缓存未命中,否则成本很低。而且被调用者无论如何都会在稍后支付该成本,除非它从不触碰其
*this
。如果确实如此,那么之前的加载基本上就是预取……除非第一个“实际”访问是写入,在这种情况下,如果多个线程访问此对象,我们可以直接进入 MESI 独占/已修改状态,而无需先从只读访问中获取仅处于共享状态的副本。如果之前没有其他核心拥有缓存行,则简单的加载通常会使缓存行进入独占状态,无需另一个核外事务(读取所有权 = RFO)即可转换为已修改状态。
如果被调用者在第一次访问时也将进行读取,那么这里的读取没有任何缺点。
对于大对象,如果成员函数仅触及位于后续缓存行中的成员,则触及第一个缓存行将会污染缓存。
让无序执行处理负载是件好事,可能比假设它实际上从未出错的测试/分支更便宜。测试/分支可能会出现分支错误预测。就像每条指令一样,管道强烈假设负载不会出错,只有当出错指令达到退出状态(变为非推测性)时才会真正执行任何操作。
但是分支总是以这种或那种方式进行预测,并且占用分支预测资源,并且可以与其他分支混淆,因此即使它们总是被强烈拒绝,也会被错误预测。
现代 x86 CPU 具有非常好的加载端口吞吐量,例如每个时钟周期 2 或 3 个(对于自然对齐的加载,L1d 缓存命中),并且有相当多的加载缓冲区来跟踪未完成的加载。例如,10 多年前的 Haswell(https://www.realworldtech.com/haswell-cpu/5/)有 72 个加载缓冲区条目,而 ROB(重新排序缓冲区)有 192 个条目。
对于小于 4 个字节的结构,使用
mov eax, [rcx]
(2 个字节的机器代码) 或(3 个字节)加载到 32 位寄存器可能是最好的选择,甚至比 @user555045 建议的立即数更便宜。 从 8 字节存储到前 8 个字节的 4 字节加载的存储转发在 x86 CPU 上非常有效,甚至在很多年前也是如此,并且 32 位操作数大小避免了 REX 前缀。movzx eax, byte ptr [rcx]
test
0
test [rcx], cl
可以节省代码大小,并且不会产生错误依赖,mov
还可以避免后端执行单元的 ALU uop。对于执行任何类型的微融合的任何 CPU,前端和发布/重命名阶段应该只有 1 个微操作 (uop)。(或者 AMD 首先简单地将其解码为 1 个 uop)。两个主要的 x86-64 调用约定都至少有一个纯粹的调用破坏寄存器,在调用之前加载它总是安全的(例如,Win x64 的 EAX、AMD64 SysV 的 R11D:可变参数函数使用 AL 传递 XMM 参数的数量。尽管您可以在 EAX 之后将其设置为常量
mov
,除非这是一个将可变参数传递给另一个函数的垫片/蹦床。)就寄存器文件限制而言,写入 GPR 和/或 FLAGS 与无序执行可以预见的范围是等效的:物理寄存器文件条目有空间容纳一个 64 位整数加上一个 FLAGS 结果,因此类似这样的指令
add rax, rcx
可以被无序执行机制视为仅写入一个结果。test cl, [rcx]
或test eax, [rcx]
仅比稍差一些mov eax, [rcx]
,因此如果由于某种原因您不能轻松地选择一个寄存器来写入,那么不要太担心使用它们。