将 double 与 exact 0 进行比较的最快方法,同时接受 +0.0 或 -0.0

Fastest way to compare a double to exact 0 while both +0.0 or -0.0 are accepted

到目前为止我有以下内容:

bool IsZero(const double x) {
  return fabs(x) == +0.0;
}

这是与精确 0 进行比较的最快的正确方法吗,同时 +0.0-0.0 都被接受?

如果 CPU-specific,让我们考虑 x86-64。如果特定于编译器,让我们考虑 MSVC++2017 工具集 v141.

简单来说,如果你想准确地接受+0.0和-0.0,你只需要使用:

x == 0.0

您可以使用 cmath 库:

int fpclassify( double arg ) 这将 return "zero" for -0.0 or +0.0

如果您打开代码的汇编器,您可以找到不同版本的代码使用了何种汇编器指令。有了汇编器,你可以估计哪个更好。

在 GCC 编译器中,您可以通过这种方式保留中间文件(包括汇编版本):

gcc -save-temps main.cpp

既然你说你想要最快的代码,我将在整个答案中做出一些重要的简化假设。根据问题,这些都是合法的。特别是,我假设 x86 和 IEEE-754 表示浮点值。我还将在适用的情况下提及特定于 MSVC 的怪癖,尽管一般性讨论将适用于任何针对此体系结构的编译器。

测试浮点值是否为零的方法是测试它的所有位。如果所有位均为 0,则值为零。实际上,该值为 +0.0。符号位可以是 0 或 1,因为正如您在问题中提到的那样,表示形式允许正负 0.0 之类的东西。但是这种差异 实际上 不存在(实际上没有 +0.0 和 −0.0 这样的东西),所以你真正需要的是测试所有位 except符号位。

这可以通过一些位操作快速有效地完成。在像 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 将倾向于生成 less 高效代码,从而降低此技巧的有效性。如果您想安全地执行此操作,则需要验证编译器的输出并确保它正在执行您想要的操作。还推荐了一些断言。

如果您接受这种方法的不安全性,您会发现它 比预测不佳的条件分支更快,所以当您处理随机输入值,这可能是性能上的胜利。出于比较目的,如果您只是针对 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 不会使用它们。

上述 "optimized" 实现的一个明显缺点是它需要从内存中加载浮点值才能访问其位。没有可以直接访问这些位的 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+SETEUCOMISS+SETNP+CMOVNECMPEQSS+MOVD+NEG (后者专供 ICC 使用)。你试图用内在函数来控制它们几乎肯定会导致输出效率降低,所以这可能需要 #ifdef' 以将其限制为 MSVC。

这是单精度值,宽度为 32 位。两倍长的双精度值呢?你会认为这些将有 63 位来测试(因为符号位仍然被忽略),但有一个转折。如果您可以排除 denormal 数字的可能性,那么您可以只测试高位(再次假设小端)。

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 以外的编译器上使用内在函数并不是获得最佳代码所必需的,而且实际上可能会导致次优代码,因此应该避免。