繁体   English   中英

为什么这个使用 gcc、-mfpmath=387 和优化级别 -O2 或 -O3 编译的简单程序会产生 NaN 值?

[英]Why does this simple program compiled with gcc,-mfpmath=387, and an optimization level of -O2 or -O3 produce NaN values?

我有一个执行数值计算的短程序,当某些特定条件成立时,会得到不正确的 NaN 结果。 我看不出这个 NaN 结果是如何产生的。 请注意,我没有使用允许重新排序算术运算的编译器选项,例如-ffath-math

问题:我正在寻找 NaN 结果如何产生的解释。 在数学上,计算中没有任何东西会导致除以零或类似的东西。 我错过了一些明显的东西吗?

请注意,我不是在问如何解决问题——这很容易。 我只是想了解 NaN 是如何出现的。

最小的例子

请注意,此示例非常脆弱,即使是很小的修改(例如在循环中添加printf()调用以观察值)也会改变行为。 这就是为什么我无法进一步最小化它的原因。

// prog.c

#include <stdio.h>
#include <math.h>

typedef long long myint;

void fun(const myint n, double *result) {
    double z = -1.0;
    double phi = 0.0;
    for (myint i = 0; i < n; i++) {
        double r = sqrt(1 - z*z);

        /* avoids division by zero when r == 0 */
        if (i != 0 && i != n-1) {
            phi += 1.0 / r;
        }

        double x = r*cos(phi);
        double y = r*sin(phi);

        result[i + n*0] = x;
        result[i + n*1] = y;
        result[i + n*2] = z;

        z += 2.0 / (n - 1);
    }
}

#define N 11

int main(void) {
    // perform computation
    double res[3*N];
    fun(N, res);

    // output result
    for (int i=0; i < N; i++) {
        printf("%g %g %g\n", res[i+N*0], res[i+N*1], res[i+N*2]);
    }

    return 0;
}

编译:

gcc -O3 -mfpmath=387 prog.c -o prog -lm

output 的最后一行是:

nan nan 1

我希望数字接近于零,而不是 NaN。

示例的关键特征

以下必须全部成立才能出现 NaN output:

  • 在 x86 平台上用 GCC 编译。 我能够在 macOS 10.14.6 上使用 GCC 12.2.0(来自 MacPorts)进行重现,在 Linux(openSUSE Leap 15.3)上使用 GCC 版本 9.3.0、8.3.0 和 7.5.0 进行重现。

    无法在 Linux 上使用 GCC 10.2.0 或更高版本,或者在 macOS 上使用 GCC 11.3.0 重现它。

  • 选择使用带有-mfpmath=387的 x87 指令,以及-O2-O3的优化级别。

  • myint必须是带符号的 64 位类型。

  • result视为 n×3 矩阵,它必须按列优先顺序存储。

  • fun()的主循环中没有printf()调用。

没有这些功能,我确实得到了预期的 output,即最后一行类似于1.77993e-08 -1.12816e-08 10 0 1

程序说明

尽管这对问题来说并不重要,但我还是对程序的作用做了一个简短的解释,以使其更容易理解。 它以特定排列计算球体表面n个点的xyz三维坐标。 z值 go 从 -1 到 1 以相等的增量,但是,由于数值舍入误差,最后一个值不会恰好为 1。 坐标被写入一个n × 3 矩阵result ,以列优先顺序存储。 rphi是 (x, y) 平面中的极坐标。

请注意,当z-11时, r变为 0。这发生在第一个和最后一个迭代步骤中。 这将导致在1.0 / r表达式中除以 0。 但是, 1.0 / r被排除在循环的第一次和最后一次迭代之外。

这是由 x87 80 位内部精度的相互作用、GCC 的不一致行为以及编译器版本之间的优化决策不同引起的。

x87 仅支持 IEEE binary32 和 binary64 作为存储格式,在加载/存储时与其 80 位表示形式相互转换。 为了使程序行为可预测,C 标准要求在赋值时放弃额外精度,并允许通过FLT_EVAL_METHOD宏检查中间精度。 使用-mfpmath=387时, FLT_EVAL_METHOD为 2,因此您知道中间精度对应于long double类型。

不幸的是, GCC 不会降低分配的额外精度,除非您通过-std=cNN (而不是-std=gnuNN )请求更严格的一致性,或者明确传递-fexcess-precision=standard

在你的程序中, z += 2.0 / (n - 1); 声明应通过以下方式计算:

  1. 以中间 80 位精度计算2.0 / (n - 1)
  2. 添加到之前的z值(仍然是 80 位精度)。
  3. 四舍五入到z的声明类型(即到 binary64)

在以 NaN 结尾的版本中,GCC 改为执行以下操作:

  1. 在循环之前计算2.0 / (n - 1)一次。
  2. 将该分数从 binary80 舍入为 binary64 并存储在堆栈中。
  3. 在循环中,它从堆栈重新加载此值并添加到z

这是不符合要求的,因为2.0 / (n - 1)进行了两次舍入(首先是 binary80,然后是 binary64)。


上面解释了为什么您看到不同的结果取决于编译器版本和优化级别。 但是,通常您不能期望您的计算在最后一次迭代中不产生 NaN。 n - 1不是 2 的幂时, 2.0 / (n - 1)不能精确表示,可能会四舍五入。 在这种情况下,“z”的增长速度可能比真正的和-1.0 + 2.0 / (n - 1) * i快一点,并且对于i == n - 1可能最终超过 1.0,导致sqrt(1 - z*z)由于参数是否定而产生 NaN。

事实上,如果您在程序中将#define N 11更改为#define N 12 ,您将确定性地获得具有 80 位和 64 位中间精度的 NaN。

... NaN 结果是如何产生的(?)

尽管更好地遵守 C 规范显然可以解决 OP 的直接问题,但我断言应该考虑其他预防措施。


|z| > 1.0时, sqrt(1 - z*z)是候选 NaN |z| > 1.0

除以零的索引测试预防可能还不够,然后导致cos(INFINITE) ,这是另一种 NaN 可能性。

// /* avoids division by zero when r == 0 */
//    if (i != 0 && i != n-1) {
//        phi += 1.0 / r;
//    }

为了避免这些,1) 直接测试和 2) 使用更精确的方法。

if (r) {
  phi += 1.0 / r;
}

// double r = sqrt(1 - z*z);
double rr = (1-z)*(1+z);  // More precise than 1 - z*z
double r = rr < 0.0 ? 0.0 : sqrt(rr);

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM