浮点舍入为 80 位寄存器和 64 位双精度寄存器提供不同的结果:ill-formed 代码或 gcc/clang 错误?

floating point rounding giving different results for 80-bit register and 64-bit double: ill-formed code or gcc/clang bug?

下面给出的代码显示了不同的结果,具体取决于 -O 或 -fno-inline 标志。 g++ 10.1 和 10.2 以及 x86 上的 clang++ 10 的结果相同(奇怪)。这是因为代码是 ill-formed 还是这是一个真正的错误?

每当它的 nakshatra (double) 字段 >= 27.0 时,应该设置 Nakshatra 构造函数中的“无效”标志。但是,当通过 Nakshatra(Nirayana_Longitude{360.0}) 初始化时,该标志未设置,即使缩放后的值正好变为 27.0。我假设原因是 360.0 的参数在缩放后变为 26.9999999999999990008(原始 0x4003d7ffffffffffffdc0)在 80 位内部寄存器中,它 < 27.0,但是,作为 64 位双精度存储,变为 27.0。不过,这种行为看起来很奇怪:同一个星座似乎同时 <27.0 和 >= 27.0。它应该是这样吗?

这是预期的行为吗,因为我的代码包含 UB 或其他 ill-formed?还是编译器错误?

要重现的最少代码(两个 .cpp 文件 + 一个 header,无法重现):

main.cpp:

#include "nakshatra.h"
#include <iostream>
#include <iomanip>

int main() {
    Nakshatra n{Nirayana_Longitude{360.0}};
    std::cout << std::fixed << std::setprecision(40) << std::boolalpha;
    std::cout << n.nakshatra << "\n";
    std::cout << "invalid (should be true): " << n.invalid << "\n";
    std::cout << "n.nakshatra >= 27.0: " << (n.nakshatra >= 27.0) << "\n";
}

nakshatra.h:

struct Nirayana_Longitude {
    double longitude;
};

class Nakshatra
{
public:
    double nakshatra;
    bool invalid = false;
    Nakshatra(double nakshatra_value) : nakshatra(nakshatra_value) {
        if (nakshatra < 0.0 || nakshatra >= 27.0) {
            invalid = true;
        }
    }
    Nakshatra(Nirayana_Longitude longitude);
};

nakshatra.cpp:

#include "nakshatra.h"

// this constructor has to be implemented in a separate .cpp file to reproduce the bug,
// moving it to ether nakshatra.h or main.c fixes the problem (perhaps due to
// compiler removing the relevant code from runtime).
Nakshatra::Nakshatra(Nirayana_Longitude longitude) : Nakshatra(longitude.longitude * (27.0 / 360.0))
{
}

编译和运行: $ g++ -O2 main.cpp nakshatra.cpp && ./a.out 或者 $ clang++ -O2 main.cpp nakshatra.cpp && ./a.out (或 ./a.exe 对于 Windows/msys2)

实际输出:

27.0000000000000000000000000000000000000000
invalid (should be true): false
n.nakshatra >= 27.0: true

预期输出:

27.0000000000000000000000000000000000000000
invalid (should be true): true
n.nakshatra >= 27.0: true

使用 -O、-Og、-O1 或 -O2 进行编译会出现这种奇怪的行为,不使用 -O 进行编译也能正常工作,就像使用 -fno-inline 的任何 -O 一样。使用 g++.exe(Rev5,由 MSYS2 项目构建)10.2.0 (Windows 7) 和 g++ 10.1 在 Ubuntu 18.04.5LTS 以及 clang 10 (Linux) 中复制。 msys2 下的 Clang 11 似乎工作正常(未广泛验证)。

此外,如果将所有代码合并到一个文件中,我也无法重现此行为。我还有 failed to reproduce this in wandbox,甚至使用 gcc 10.1,所以也许用于重现此行为的 CPU 是相关的:Intel Core i5-660 (3.33GHz)。不幸的是,否则优秀的编译器 exporer 不支持多个编译单元,因此无法在那里重现。

为了完整起见,这是显示奇怪行为时生成的汇编代码的一个示例。

   0x00401734 <+0>:     fldl   0x404080     ; (27.0/360.0)?
   0x0040173a <+6>:     fmull  0x4(%esp)    ; argument *= (27.0*360.0), giving 26.9999999999999990008 (raw 0x4003d7fffffffffffdc0)
   0x0040173e <+10>:    fstl   (%ecx)
   0x00401740 <+12>:    movb   [=17=]x0,0x8(%ecx)   ; set invalid=false
   0x00401744 <+16>:    fldz
   0x00401746 <+18>:    fcomip %st(1),%st
   0x00401748 <+20>:    ja     0x40175a <_ZN9NakshatraC2E18Nirayana_Longitude+38>   ; jump if argument < 0.0
   0x0040174a <+22>:    flds   0x404088     ; 27.0?
   0x00401750 <+28>:    fxch   %st(1)
   0x00401752 <+30>:    fcomip %st(1),%st
   0x00401754 <+32>:    fstp   %st(0)
   0x00401756 <+34>:    jb     0x401760 <_ZN9NakshatraC2E18Nirayana_Longitude+44>   ; jump if argument is >= 27.0
   0x00401758 <+36>:    jmp    0x40175c <_ZN9NakshatraC2E18Nirayana_Longitude+40>
   0x0040175a <+38>:    fstp   %st(0)
   0x0040175c <+40>:    movb   [=17=]x1,0x8(%ecx)   ; set invalid=true
   0x00401760 <+44>:    ret    [=17=]x8

UPDATE:在阅读 another question 的答案后,我发现使用 -mfpmath=sse -msse2 -ffp-contract=off-ffloat-store 进行编译修复了错误。尽管如此,问题仍然存在:默认情况下,g++ 和 clang 10 是否偏离优化 32 位 x86 的 C++ 标准,或者 C++ 标准是否允许这种行为? Double 同时 <27.0 和 >=27.0 在我看来不一致。

如果没有 -ffloat-store,面向 x87 的 GCC 确实违反了标准:它甚至在语句之间保持值不四舍五入。 (-mfpmath=387-m32 的默认值)。在 ISO C++ 中,像 double x = y; 这样的赋值应该四舍五入到实际的双精度值,并且可能还会传递一个函数 arg。

所以我认为您的代码对于 ISO C++ 规则是安全的,即使使用 GCC 声称的 FLT_EVAL_METHOD == 2 也是如此。 (https://en.cppreference.com/w/cpp/types/climits/FLT_EVAL_METHOD)

另请参阅 https://randomascii.wordpress.com/2012/03/21/intermediate-floating-point-precision/ 以了解有关实际问题的更多信息,以及针对 x86 的实际编译器。

https://gcc.gnu.org/wiki/x87note 并没有真正提到 GCC 舍入与 ISO C++ 需要舍入时的区别,只是描述了 GCC 的实际行为。