将 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
+SETE
、UCOMISS
+SETNP
+CMOVNE
或 CMPEQSS
+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 以外的编译器上使用内在函数并不是获得最佳代码所必需的,而且实际上可能会导致次优代码,因此应该避免。
到目前为止我有以下内容:
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
+SETE
、UCOMISS
+SETNP
+CMOVNE
或 CMPEQSS
+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 以外的编译器上使用内在函数并不是获得最佳代码所必需的,而且实际上可能会导致次优代码,因此应该避免。