处理融合乘法加法浮点不准确性的通用方法

时间:2017-02-09 09:04:47

标签: c++ floating-point precision floating-accuracy fma

昨天我正在跟踪我项目中的一个错误 - 几个小时之后 - 我已经缩小到一段或多或少做这样的代码:

#include <iostream>
#include <cmath>
#include <cassert>

volatile float r = -0.979541123;
volatile float alpha = 0.375402451;

int main()
{
    float sx = r * cosf(alpha); // -0.911326
    float sy = r * sinf(alpha); // -0.359146
    float ex = r * cosf(alpha); // -0.911326
    float ey = r * sinf(alpha); // -0.359146
    float mx = ex - sx;     // should be 0
    float my = ey - sy;     // should be 0
    float distance = sqrtf(mx * mx + my * my) * 57.2958f;   // should be 0, gives 1.34925e-06

//  std::cout << "sv: {" << sx << ", " << sy << "}" << std::endl;
//  std::cout << "ev: {" << ex << ", " << ey << "}" << std::endl;
//  std::cout << "mv: {" << mx << ", " << my << "}" << std::endl;
    std::cout << "distance: " << distance << std::endl;

    assert(distance == 0.f);
//  assert(sx == ex && sy == ey);
//  assert(mx == 0.f && my == 0.f);
} 

编译和执行后:

$ g++ -Wall -Wextra -Wshadow -march=native -O2 vfma.cpp && ./a.out 
distance: 1.34925e-06
a.out: vfma.cpp:23: int main(): Assertion `distance == 0.f' failed.
Aborted (core dumped)

从我的角度来看,有些事情是错误的,因为我要求两个按位相同的对减2次(我希望得到两个零),然后将它们平方(再次两个零)并将它们加在一起(零)。

事实证明,问题的根本原因是使用了融合乘法 - 加法运算,沿线的某处使得结果不精确(从我的观点来看)。一般来说,我没有反对这种优化,因为它承诺提供更多精确的结果,但在这种情况下,1.34925e-06真的远远超出我期望的0。

测试用例非常脆弱&#34; - 如果你启用更多打印或更多断言,它会停止断言,因为编译器不再使用融合乘法 - 加法。例如,如果我取消注释所有行:

$ g++ -Wall -Wextra -Wshadow -march=native -O2 vfma.cpp && ./a.out 
sv: {-0.911326, -0.359146}
ev: {-0.911326, -0.359146}
mv: {0, 0}
distance: 0

由于我认为这是编译器中的一个错误,我已经报告了这一点,但是关于这是正确行为的解释已经结束了。

https://gcc.gnu.org/bugzilla/show_bug.cgi?id=79436

所以我想知道 - 如何编写这样的计算来避免这个问题呢?我在考虑一个通用的解决方案,但比以下更好:

mx = ex != sx ? ex - sx : 0.f;

我想修复或改进我的代码 - 如果有什么需要修复/改进的 - 而不是为我的整个项目设置-ffp-contract=off,因为在编译器内部使用了fusion-multiply-add无论如何我都看到了库(我在sinf()和cosf()中看到了很多这样的东西),所以这将是一个部分解决方案&#34;而不是解决方案...我也想避免像&#34;不要使用浮点数&#34; (;

3 个答案:

答案 0 :(得分:3)

一般情况下没有:这正是您使用-ffp-contract=fast所支付的价格(恰巧,正是这个例子William Kahan notes in the problems with automatic contraction

理论上,如果你使用C(不是C ++),并且你的编译器支持C-1999 pragma(即不是gcc),你可以使用

#pragma STDC FP_CONTRACT OFF
// non-contracted code
#pragma STDC FP_CONTRACT ON

答案 1 :(得分:3)

有趣的是,多亏了fma,浮点数mx和my给出了乘以r和cos时产生的舍入误差。

fma( r,cos, -r*cos) = theoretical(r*cos) - float(r*cos)

因此,你得到的结果表明计算(sx,sy)与理论(sx,sy)的距离是多远,因为浮点数的乘法(但不考虑cos和sin的计算中的舍入误差)。 / p>

所以问题是你的程序如何依赖于与浮点舍入相关的不确定区间内的差异(ex-sx,ey-sy)?

答案 2 :(得分:1)

我可以看到这个问题已经存在了一段时间,但是如果其他人遇到它来寻找答案,我想我会提到几点。

首先,不分析最终的汇编代码就很难准确分辨,但是我怀疑FMA给出的结果到目前为止超出预期的原因不仅在于FMA本身,还在于您假设所有计算是按照您指定的顺序进行的,但是通过优化C / C ++编译器通常不是这种情况。这也可能是为什么取消注释打印语句会更改结果的原因。

如果按照注释所建议的那样计算mxmy,那么即使最后的mx*mx + my*my是用FMA完成的,仍将导致预期的0结果。问题在于,由于sx / sy / ex / ey / mx / my变量都不被其他任何变量使用,极有可能编译器根本不会真正将它们作为独立变量来求值,而只是将所有数学运算混和为一大批乘法,加法和减法,从而一步一步即可计算出distance,然后以各种不同的方式用机器代码表示(以任何顺序表示,可能有多个FMA,等等),但是它表示在一次大型计算中它将获得最佳性能。

但是,如果其他内容(例如打印语句)引用了mxmy,则编译器很有可能在第二步计算distance之前分别计算它们。在那种情况下,数学运算确实可以按照注释的建议进行计算,甚至最终distance计算中的FMA都不会改变结果(因为输入都完全为0)。

答案

但这并不能真正回答真正的问题。对此的回答是,通常可以避免这种问题的最可靠(并且通常推荐)的方法是:永远不要假设浮点运算会永远产生确切的数字,即使该数字为0。通常,这意味着永远不使用==比较浮点数是一个坏主意。相反,您应该选择一个较小的数字(通常称为epsilon),该数字大于任何可能的/可能的累积误差,但仍小于任何明显的结果(例如,如果您知道自己关心的距离仅是有效到小数点后两位,那么您可以选择EPSILON = 0.01,这意味着“任何小于0.01的差异,我们都将认为与零相同”)。然后,不要说:

assert(distance == 0.f);

您会说:

assert(distance < EPSILON);

(您的epsilon的确切值可能取决于应用程序,当然对于不同类型的计算甚至可能会有所不同)

同样,您无需说诸如if (a == b)之类的浮点数,而是说诸如之类的if (abs(a - b) < EPSILON)等。

减少(但不一定消除)此问题的另一种方法是在应用程序中实现“快速失败”逻辑。例如,在上面的代码中,您无需完全研究并计算distance,然后查看其结尾是否为0,而是可以通过测试if (mx < EPSILON && my < EPSILON)来“短路”某些数学运算甚至在计算distance之前,如果它们都为零,则跳过其余部分(因为您知道在这种情况下结果将为零)。您越早发现情况,错误累积的机会就越少(有时您也可以避免进行不必要的昂贵计算)。