既然您说您想要尽可能快的代码,我将在整个答案中做出一些重要的简化假设。根据问题,这些是合法的。特别是,我假设浮点值的 x86 和 IEEE-754 表示。在适用的情况下,我还将提及特定于 MSVC 的怪癖,尽管一般性讨论适用于任何针对此架构的编译器。
测试浮点值是否等于 0 的方法是测试它的所有位。如果所有位都为 0,则该值为零。实际上,该值为+0.0。正如您在问题中提到的那样,符号位可以是 0 或 1,因为表示允许正负 0.0 之类的东西。但是这种差异 实际上 并不存在(实际上并不存在 +0.0 和 -0.0 之类的东西),因此您真正需要的是测试所有位 除了 符号位。
这可以通过一些位旋转来快速有效地完成。在 x86 等小端架构上,符号位是前导位,因此您只需将其移出,然后测试剩余位。
Agner Fog 在他的Optimizing Subroutines in Assembly Language 中描述了这个技巧。具体来说,示例 17.4b(在当前版本的第 156 页上)。
对于 32 位宽的单精度浮点值(即,float):
mov eax, DWORD PTR [floatingPointValue]
add eax, eax ; shift out the sign bit to ignore -0.0
sete al ; set AL if the remaining bits were 0
将其翻译成 C 代码,您可以执行以下操作:
const uint32_t bits = *(reinterpret_cast<uint32_t*>(&value));
return ((bits + bits) == 0);
当然,由于类型双关语,这在形式上是不安全的。 MSVC 让你摆脱它,没问题。事实上,如果您尝试真正符合标准并安全行事,MSVC 将倾向于生成不太高效的代码,从而降低此技巧的有效性。如果您想安全地执行此操作,则需要验证编译器的输出并确保它正在执行您想要的操作。还建议使用一些断言。
如果您对这种方法的不安全性质感到满意,您会发现它比预测不佳的条件分支快,所以当您处理随机输入值时,它可能是性能上的胜利。出于比较目的,如果您只是针对 0.0 进行简单的相等性测试,您将从 MSVC 中看到以下内容:
;; assuming /arch:IA32, which is *not* the default in modern versions of MSVC
;; but necessary if you cannot assume SSE2 support
fld DWORD PTR [floatingPointValue]
fldz
fucompp
fnstsw ax
test ah, 44h
jp IsNonZero
mov al, 1
ret
IsNonZero:
xor al, al
ret
;; assuming /arch:SSE2, which *is* the default in modern versions of MSVC
movss xmm0, DWORD PTR [floatingPointValue]
ucomiss xmm0, DWORD PTR [constantZero]
lahf
test ah, 44h
jp IsNonZero
mov al, 1
ret
IsNonZero:
xor al, al
ret
丑陋,而且可能很慢。有无分支的方法可以做到这一点,但 MSVC 不会使用它们。
上述“优化”实现的一个明显缺点是它需要从内存中加载浮点值才能访问其位。没有 x87 指令可以直接访问这些位,并且没有办法直接从 x87 寄存器到 GP 寄存器而不经过内存。由于内存访问很慢,这确实会导致性能损失,但在我的测试中,它仍然比预测错误的分支快。
如果您在 32 位 x86 上使用任何标准调用约定(__cdecl、__stdcall 等),则所有浮点值都会在 x87 寄存器中传递和返回,因此没有从 x87 寄存器迁移到 GP 寄存器与从 x87 寄存器迁移到 SSE 寄存器的区别。
如果您的目标是 x86-64,或者如果您在 x86-32 上使用 __vectorcall,情况会有所不同。然后,您实际上在 SSE 寄存器中存储和传递了浮点值,因此您可以利用无分支 SSE 指令。至少,理论上是这样。 MSVC 不会,除非你握住它的手。它通常会执行与上面所示相同的分支比较,只是没有额外的内存负载:
;; MSVC output for a __vectorcall function, targeting x86-32 with /arch:SSE2
;; and/or for x86-64 (which always uses a vector calling convention and SSE2)
;; The floating point value being compared is passed directly in XMM0
ucomiss xmm0, DWORD PTR [constantZero]
lahf
test ah, 44h
jp IsNonZero
mov al, 1
ret
IsNonZero:
xor al, al
ret
我已经演示了一个非常简单的bool IsZero(float val) 函数的编译器输出,但在我的观察中,MSVC 总是为这种类型的比较发出一个UCOMISS+JP 序列,无论比较如何被合并到输入代码。同样,如果输入的零性是可预测的,那很好,但如果分支预测失败,则相对糟糕。
如果您想确保获得无分支代码,避免分支错误预测停顿的可能性,那么您需要使用内部函数进行比较。这些内在函数将迫使 MSVC 发出更接近您期望的代码:
return (_mm_ucomieq_ss(_mm_set_ss(floatingPointValue), _mm_setzero_ps()) != 0);
很遗憾,输出仍然不完美。您会遇到围绕使用内在函数的一般优化缺陷——即,不同 SSE 寄存器之间的输入值的一些冗余混洗——但这是 (A) 不可避免的,并且 (B) 不是可测量的性能问题。
我会在这里指出,其他编译器,例如 Clang 和 GCC,不需要他们的双手。你可以做value == 0.0。它们发出的代码的确切顺序会有所不同,具体取决于您的优化设置,但您会看到COMISS+SETE、UCOMISS+SETNP+CMOVNE 或CMPEQSS+MOVD+ NEG(后者仅供 ICC 使用)。您尝试使用内在函数几乎肯定会导致输出效率降低,因此这可能需要#ifdef'ed 以将其限制为 MSVC。
这是单精度值,宽度为 32 位。两倍长的双精度值呢?你会认为这些将有 63 位要测试(因为符号位仍然被忽略),但有一个转折点。如果您可以排除 反规范 数字的可能性,那么您可以只测试高位(再次假设 little-endian)。
Agner Fog 也讨论了这一点(示例 17.4d)。如果排除非正规数的可能性,则值 0 对应于指数位全为 0 的情况。高位是符号位和指数位,因此您可以像对单数一样进行测试-精度值:
mov eax, DWORD PTR [floatingPointValue+4] ; load upper bits only
add eax, eax ; shift out sign bit to ignore -0.0
sete al ; set AL if the remaining bits were 0
在不安全的 C 中:
const uint64_t bits = *(reinterpret_cast<uint64_t*>(&value);
const uint32_t upperBits = (bits & 0xFFFFFFFF00000000) >> 32;
return ((upperBits + upperBits) == 0);
如果您确实需要考虑非规范值,那么您并没有为自己节省任何东西。我还没有对此进行测试,但是让编译器生成代码以进行简单的比较可能不会更糟。至少,不适用于 x86-32。您可能仍然会在 x86-64 上有所收获,因为您拥有 64 位宽的 GP 寄存器。
如果您可以假设 SSE2 支持(这将是所有 x86-64 系统,以及所有现代 x86-32 构建),那么您只需使用内在函数,您就可以免费获得非正规支持(嗯,不是真的免费;我相信 CPU 有内部惩罚,但我们会忽略这些):
return (_mm_ucomieq_sd(_mm_set_sd(floatingPointValue), _mm_setzero_pd()) != 0);
同样,与单精度值一样,在 MSVC 以外的编译器上不需要使用内在函数来获得最佳代码,并且确实可能导致次优代码,因此应该避免。