【问题标题】:Fastest way to compare a double to exact 0 while both +0.0 or -0.0 are accepted在接受 +0.0 或 -0.0 时将双精度数与精确 0 进行比较的最快方法
【发布时间】:2017-10-16 22:45:12
【问题描述】:

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

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

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

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

【问题讨论】:

  • 简单的x == 0Demo
  • 您认为您要解决的真正问题是什么?
  • @CraigYoung 我不确定是什么让你这么问。 OP 想知道double 是否恰好是+0.0-0.0,并且(正确地)担心他们的第一个解决方案fabs(x) == +0.0 不是最直接的。
  • @SergeRogatch 因为 C++ 编译器将 double 上的 == 映射到 IEEE 754 相等性,它根据定义具有此属性。 en.wikipedia.org/wiki/Signed_zero#Comparisons 。真正区分+0.0-0.0 是困难的(但绝不是不可能的)。
  • @PascalCuoq 我之所以问,是因为 OP 根本没有问题,并想让他退后一步重新思考。首先,在数学上区分+/-0.0 是没有意义的。然而,OP 意识到内存中的double 表示可以区分 2 和恐慌;但没有真正的问题。如果他确实有真正的问题,他将能够提供minimal reproducible example,类似于:x = //expression to assign -0.0; if (x == 0) cout << "== 0.0\n"; 未输出预期结果。因为 OP 没有真正的问题,他问了一个不好的问题。

标签: c++ optimization floating-point comparison zero


【解决方案1】:

既然您说您想要尽可能快的代码,我将在整个答案中做出一些重要的简化假设。根据问题,这些是合法的。特别是,我假设浮点值的 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+SETEUCOMISS+SETNP+CMOVNECMPEQSS+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 以外的编译器上不需要使用内在函数来获得最佳代码,并且确实可能​​导致次优代码,因此应该避免。

【讨论】:

  • 由于double 0 由设置为0 的所有位表示,您可以使用xorpd xmm1, xmm1 将2 个值为0 的双精度值加载到xmm1 中。无需从内存中加载 constantZero 可能会导致缓存未命中。
  • 确实,@Maxim,但在所有要批评 MSVC 生成的代码的事情中,这可能是最少的。分支会花费更多。
【解决方案2】:

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

x == 0.0

您可以使用 cmath 库:

int fpclassify( double arg ) 对于 -0.0 或 +0.0 将返回“零”

【讨论】:

  • 我认为std::fpclassify() 会很慢,因为它会执行多项检查以将号码识别为属于众多案例之一。
【解决方案3】:

如果您打开代码的汇编程序,您会发现不同版本的代码使用了哪些汇编指令。有了汇编器,您可以估计哪个更好。

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

gcc -save-temps main.cpp

【讨论】:

  • 这似乎更像是评论而不是答案。这是有用的建议,但不能直接回答问题。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-11-21
  • 2022-10-01
  • 1970-01-01
  • 1970-01-01
  • 2017-07-07
  • 2011-11-14
相关资源
最近更新 更多