如何强制GCC假定浮点表达式为非负数?

62

有些情况下你知道某个浮点表达式肯定是非负的。例如,当计算向量长度时,我们会这样做:sqrt(a[0]*a[0] + ... + a[N-1]*a[N-1])(注意:我知道 std::hypot,但这与问题无关),而根号下的表达式显然是非负的。然而,GCC为sqrt(x*x)生成了以下汇编代码:(链接)

        mulss   xmm0, xmm0
        pxor    xmm1, xmm1
        ucomiss xmm1, xmm0
        ja      .L10
        sqrtss  xmm0, xmm0
        ret
.L10:
        jmp     sqrtf

也就是说,它比较了 x*x 的结果和零,如果结果为非负数,则执行 sqrtss 指令,否则调用 sqrtf

因此,我的问题是:我如何强制GCC假定x * x始终为非负数,以便跳过比较和sqrtf调用,而不编写内联汇编?

我希望强调我对本地解决方案感兴趣,而不是像-ffast-math-fno-math-errno-ffinite-math-only这样的事情(尽管评论中的ks1322,harold和Eric Postpischil确实解决了这个问题,谢谢他们)。

此外,“强制GCC假定x * x为非负数”应解释为assert(x * x >= 0.f),因此也排除了x * x为NaN的情况。

我可以接受特定于编译器、平台、CPU等的解决方案。


12
x*x 不一定是零或正数,它可能是一个 NaN。虽然我不确定 GCC 在此处理的是否为 NaN。 - Eric Postpischil
8
-fno-math-errno 是更安全的选项,同时也会移除对 sqrtf 的调用。 - harold
1
如果sqrtf在出现错误时设置了errno,那么GCC正确地不使用sqrtss来处理NaN情况。(根据C标准,数学函数是否设置errno是与实现相关的,而C++继承了这一点。) - Eric Postpischil
6
添加参数-ffinite-math-only告诉GCC可以假设不存在无穷大或NaN。使用此选项可以消除分支和对sqrtf的调用。由于无穷大不是sqrtf的错误,因此这证实了GCC在问题示例代码中的担忧是一个NaN。不幸的是,我没有看到一个选项可以仅仅假设没有NaN,而不是假设没有NaN或无穷大;在sqrt之前插入if (std::isnan(x)) return x;并不能导致GCC意识到x*x不可能是一个NaN。 - Eric Postpischil
4
@dan04说的是开关并没有禁止你使用NaN,而是告诉编译器可以假定不存在NaN。所以你需要避免使用NaN或者承担后果。比如,如果你计算两个无穷大的商,后续代码可能会被优化,认为不会产生NaN,并且走入错误的路径。 - Eric Postpischil
显示剩余7条评论
4个回答

52

在GNU C中,您可以将assert(x*x >= 0.f)编写为编译时承诺,而不是运行时检查,如下所示:

#include <cmath>

float test1 (float x)
{
    float tmp = x*x;
    if (!(tmp >= 0.0f)) 
        __builtin_unreachable();    
    return std::sqrt(tmp);
}

(相关: __builtin_unreachable有哪些优化? 你也可以将if(!x)__builtin_unreachable()封装在宏中,并将其命名为promise()或其他名称。)

但是gcc不知道如何利用tmp非NaN和非负的承诺。我们仍然会得到(Godbolt)相同的预设汇编序列,检查x>=0,否则调用sqrtf设置errno假定将这个展开成比较和分支发生在其他优化传递之后,因此让编译器了解更多并没有帮助。
这是一个错失优化的逻辑,当启用-fmath-errno(默认情况下启用)时,它会在内联sqrt时进行推测。您需要的是-fno-math-errno,这在全局范围内是安全的。
如果您从不依赖于数学函数设置errno,则这是100%安全的。没有人想要那样,这就是NaN传播和/或记录掩码FP异常的粘性标志所用。例如,通过#pragma STDC FENV_ACCESS ON访问C99/C++11 fenv,然后使用fetestexcept()等函数。请参见feclearexcept中的示例,其中显示了使用它来检测除以零。
FP环境是线程上下文的一部分,而errno是全局的。
支持这种过时的功能并非免费;除非您有使用它的旧代码,否则应将其关闭。不要在新代码中使用它:请使用fenv。理想情况下,对于-fmath-errno的支持应尽可能便宜,但由于几乎没有人实际使用__builtin_unreachable()或其他排除NaN输入的方法,因此开发人员没有实现优化,这很可能是不值得的。不过,如果您愿意,可以报告未优化错误。

实际的FPU硬件确实具有这些粘性标志,这些标志保持设置直到被清除,例如x86的mxcsr状态/控制寄存器用于SSE/AVX数学,或者其他ISA中的硬件FPU。在FPU可以检测异常的硬件上,优质的C++实现将支持像fetestexcept()这样的内容。如果不支持,则math-errno可能也无法正常工作。

数学方面的errno是一个旧的过时设计,C/C++仍然默认使用它,但现在被广泛认为是一个坏主意。这使得编译器难以高效地内联数学函数。或者也许我们没有像我想的那样被它牢牢束缚:为什么即使sqrt取出域参数,errno也没有设置为EDOM?解释了在ISO C11中,在数学函数中设置errno是可选的,实现可以指示他们是否这样做。大概在C++中也是这样。

-fno-math-errno与改变值的优化(如-ffast-math-ffinite-math-only)混为一谈是一个大错误。你应该强烈考虑全局启用它,或者至少对包含此函数的整个文件启用它。

float test2 (float x)
{
    return std::sqrt(x*x);
}

# g++ -fno-math-errno -std=gnu++17 -O3
test2(float):   # and test1 is the same
        mulss   xmm0, xmm0
        sqrtss  xmm0, xmm0
        ret

如果你永远不会使用feenableexcept()来取消屏蔽FP异常,那么最好也使用-fno-trapping-math。 (虽然这个选项对于此优化并不是必需的,只有设置errno的问题才是问题所在)。-fno-trapping-math不假设没有NaN或其他任何东西,它只假定FP异常(如无效或不精确)永远不会实际调用信号处理程序,而是产生NaN或舍入结果。-ftrapping-math是默认值,但据GCC开发人员Marc Glisse称,它已经坏掉了并且“从未工作过”。(即使打开它,GCC也会进行一些优化,这些优化可以将引发的异常数量从零变为非零或反之。它还阻止了一些安全优化)。但不幸的是,https://gcc.gnu.org/bugzilla/show_bug.cgi?id=54192(默认情况下关闭)仍然存在。如果您确实取消了异常屏蔽,则最好使用-ftrapping-math,但再次强调,很少需要这样做,而只需在某些数学运算后检查标志或检查NaN即可。而且它实际上也不能保留精确的异常语义。

参见SIMD for float threshold operation,其中一个案例中,-ftrapping-math默认设置错误地阻止了一项安全优化。(即使将可能触发异常的操作提升到 C 无条件执行,gcc 生成的非向量化汇编代码仍然有条件地执行!因此,GCC不仅阻止了向量化,还改变了异常语义与C抽象机器的不同。)-fno-trapping-math启用了预期的优化。


3
在发布模式(已定义NDEBUG)下,assert(x*x >= 0.f)不会进入预处理代码。 - Ruslan
2
@Ruslan:我无法想到一种措辞方式,既清晰易读,又避免暗示assert()始终是运行时检查,而有时根本不执行。:/ 我只能将其保留。我想我可以在答案中加一个脚注,但如果其他人对我忽略这一点感到困扰,请为Ruslan的评论点赞 :) - Peter Cordes
1
@StephenG:在C语言中,#pragma STDC FENV_ACCESS ON支持和相关的fenv stuff至少在理论上是作为ISO C实现的一部分所必需的。我的观点是,由于广泛的硬件(或软fp)支持,大多数C++实现在实践中也支持它。这些标志不是特殊或最近的硬件功能,它是主流FPU硬件的标准。 (例如,在x86上,它自8087以来就是x87的一部分,还有SSE。)当然也适用于非x86 ISA。无论如何,这就是为什么有一种可移植的 ISO C方法来访问它! - Peter Cordes
2
@StephenG -- 除了一些相当不寻常的环境(Cell向量处理器、一些早期的GPGPU和大多数人永远不会接触的史前事物),IEEE 754支持(这就是给你粘性标志等的东西)存在。 - LThode
1
@lisyarus:这个答案的真正意义在于,检查errno是第一次检测数学错误的可怕方式。如果您想要任何原因(包括报告和中止,而不仅仅是尝试恢复),请改用fetestexcept。FPU标志(FP环境)是线程上下文的一部分,而errno是全局的。-fno-math-errno并不具有“侵入性”,它只是关闭了一个您没有使用且永远不应该使用的向后兼容功能。支持此错误特性并非免费,花费精力使其在某些罕见情况下更便宜的好处与关闭相比较低。 - Peter Cordes
显示剩余7条评论

11
将选项-fno-math-errno传递给gcc。这将解决问题,而不会使您的代码无法移植或离开ISO/IEC 9899:2011(C11)的领域。
此选项的作用是当数学库函数失败时不尝试设置errno
-fno-math-errno 调用执行单个指令的数学函数(例如"sqrt")后,不设置"errno"。依赖IEEE异常进行数学错误处理的程序可能希望在保持IEEE算术兼容性的同时使用此标志以实现更快的速度。
由于该选项可能导致依赖于对数学函数的IEEE或ISO规则/规范的精确实现的程序输出不正确,因此任何-O选项都不会打开此选项。但是,对于不需要这些规范保证的程序,它可能会产生更快的代码。
默认设置为-fmath-errno。
在Darwin系统上,数学库从不设置"errno"。因此,编译器不考虑可能发生这种情况,并且-fno-math-errno是默认设置。
考虑到您似乎对数学例程不太感兴趣设置errno,这似乎是一个很好的解决方案。

谢谢您的努力,但我在问题中明确说明编译器选项(特别是-fno-math-errno)不是一个选项;我想要一个特定情况下的临时解决方案。 - lisyarus
@lisyarus 抱歉,看起来我错过了这个问题。我认为你可以使用 __attribute__ 为单个函数设置此选项。这样能解决你的问题吗? - fuz
这似乎是我会满意的东西!但是,我不知道如何将“no-math-errno”放入函数属性中。 - lisyarus
5
它应该能够与__attribute__((optimize ("no-math-errno")))或者#pragma GCC optimize ("no-math-errno")一起使用,但我尝试都无法生效。很奇怪。 - fuz
也许我也会对此报告一个程序错误。 - lisyarus
@lisyarus 当你完成了,请告诉我。 - fuz

5

没有全局选项,这里有一种(低开销但不是免费的)方法来得到一个没有分支的平方根:

#include <immintrin.h>

float test(float x)
{
    return _mm_cvtss_f32(_mm_sqrt_ss(_mm_set1_ps(x * x)));
}

(在godbolt平台上)

通常情况下,Clang在洗牌操作方面表现得很聪明。然而,GCC和MSVC在这方面落后,并且无法避免广播(的出现)。同时,MSVC也进行了一些神秘的移动操作..

还有其他的方法可以将浮点数转换为__m128类型的数据,例如_mm_set_ss函数。对于Clang来说,这没有任何区别,而对于GCC而言,代码会变得更加臃肿且效率不佳(包括一个movss reg, reg指令,在Intel平台上计算为一次洗牌操作,因此甚至不能节省洗牌次数)。


谢谢!我不确定直接调用SSE指令是否合适(这应该是编译器的工作,对吧?),但这仍然是一种有趣的方法。 - lisyarus
@lisyarus 嗯,它们存在的目的是让你在编译器无法使用时使用它们,所以这对我来说似乎是一个不错(但也许不寻常)的用例。 - harold
没错,但我希望代码在没有 SSE 支持的平台上仍能正常工作。 - lisyarus
@lisyarus 那是奔腾2吗?还是像ARM这样的不同ISA? - harold
ARM 可能是可以的,但重点是我只想帮助编译器进行优化,而不是完全自己来做。 - lisyarus
@lisyarus 好的,我能看到,但我不知道如何做,抱歉。 - harold

4
大约一周后,我在GCC Bugzilla上询问了这个问题,他们提供了一个最接近我想法的解决方案。
float test (float x)
{
    float y = x*x;
    if (std::isless(y, 0.f))
        __builtin_unreachable();
    return std::sqrt(y);
}

编译成以下汇编代码的 程序 为:

test(float):
    mulss   xmm0, xmm0
    sqrtss  xmm0, xmm0
    ret

虽然我仍然不确定到底发生了什么,


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接