在clang/macOS/arm64和clang/macOS/x86_64上,一个微不足道的C程序会产生不同的结果。

4

在将一些复杂的代码移植到macOS/arm64时,我遇到了一些问题,并最终得到了以下简单的代码来展示与macOS/x86_64的不同行为(使用conda-forge中的本机osx/arm64 clang版本14.0.6,并进行x86_64的交叉编译):

#include "assert.h"
#include "stdio.h"
int main()
{
    double y[2] = {-0.01,0.9};
    double r;
    r = y[0]+0.03*y[1];
    printf("r = %24.26e\n",r);
    assert(r == 0.017);
}

在arm64上的结果是
$ clang -arch arm64 test.c -o test; ./test
Assertion failed: (r == 0.017), function main, file test.c, line 9.
r = 1.69999999999999977517983751e-02
zsh: abort      ./test

在x86_64上的结果是
$ clang -arch x86_64 test.c -o test; ./test
r = 1.70000000000000012212453271e-02
$       

测试程序也在x86_64机器上编译/运行过,结果与上述(在arm64上交叉编译并使用Rosetta运行)相同。
实际上,arm64的结果与以IEEE754数值解析和存储的1.7不完全相等,并不重要,而是与x86_64的表达式值不同。
更新1:
为了检查可能存在的不同约定(例如舍入模式),以下程序已在两个平台上进行了编译和运行。
#include <iostream>
#include <limits>

#define LOG(x) std::cout << #x " = " << x << '\n'

int main()
{
    using l = std::numeric_limits<double>;
    LOG(l::digits);
    LOG(l::round_style);
    LOG(l::epsilon());
    LOG(l::min());

    return 0;
}

它产生相同的结果:
l::digits = 53
l::round_style = 1
l::epsilon() = 2.22045e-16
l::min() = 2.22507e-308

因此,问题似乎出在其他地方。
更新2:
如果有帮助的话:在arm64下,使用该表达式得到的结果与使用refBLAS ddot函数调用向量{1,0.03}和y得到的结果相同。
更新3:
工具链似乎是原因。使用macOS 11.6.1的默认工具链:
mottelet@portmottelet-cr-1 ~ % clang -v
Apple clang version 13.0.0 (clang-1300.0.29.30)
Target: arm64-apple-darwin20.6.0
Thread model: posix
InstalledDir: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin

给出相同的结果,无论是哪种架构!所以问题似乎出在我正在使用的实际工具链上:我使用的是conda软件包的1.5.2版本(我需要conda作为软件包管理器,因为我正在构建的应用程序有很多依赖项,而conda可以提供给我)。
使用-v选项会显示一堆编译标志,最终可能会怀疑哪一个?

1
所以显然问题比我们预期的更加复杂。你可以打印每一步的十六进制值来发现三个值之间的差异。我没有 M1 来测试这个问题。 - Marek R
1
不同的舍入模式也可能生效。我不记得如何查询舍入模式,但你可以尝试找出来。顺便问一下,x86版本是在实际的x86硬件上运行,还是通过Rosetta仿真在arm64上运行? - Nate Eldredge
2
我认为你也许需要添加你的工具链版本。在arm64上,我使用从-O0-O3任何选项都得到了1.70000000000000012212453271e-02的结果。 - Siguza
2
我认为你可能还需要添加你的工具链版本。在arm64上,我得到了1.70000000000000012212453271e-02的结果,对于任何从-O0-O3的选项都是如此。 - Siguza
2
我觉得你可能还需要添加你的工具链版本。在arm64上,我使用从-O0-O3的任何选项都会得到1.70000000000000012212453271e-02的结果。 - undefined
显示剩余33条评论
2个回答

5
由于编译器和架构的不同,结果在最低有效位上有所差异。您可以使用%a以十六进制形式查看双精度数中的所有位。然后在arm64上得到以下结果:

0x1.16872b020c49bp-6

在x86_64上:

0x1.16872b020c49cp-6

IEEE 754标准本身并不能保证符合规范的实现在结果上完全相同,特别是由于目标精度、十进制转换和指令选择等因素。最低有效位或多个操作中的变化是可以预期的。
在这种情况下,arm64架构上使用了fmadd操作,将乘法和加法合并为一次操作。这与x86_64架构中使用的分开的乘法和加法XMM操作得到不同的结果。
在评论中,Eric指出了C库函数fma()来进行组合乘加运算。确实,如果我在x86_64架构上使用该调用(以及在arm64上),我会得到arm64的fmadd结果。
如果编译器在示例中优化掉了该操作,你可能会在相同的架构中获得不同的行为,因为此时编译器正在执行计算。编译器在编译时很可能会使用分开的乘法和加法操作,这会导致在未被优化掉的情况下,arm64上的结果与fmadd操作不同。此外,如果你进行交叉编译,那么被优化掉的计算可能取决于你编译所用的机器的架构,而不是运行它的机器的架构。
浮点数值的精确相等比较充满了风险。每当你发现自己试图这样做时,你需要更深入地思考你的意图。

IEEE 754标准确保符合规范的实现在这些操作中得到完全相同的结果。C标准不保证符合IEEE 754标准,通常编译器也不能保证在所有模式下符合C标准。如果符合IEEE 754标准,则此处的结果将完全相同。 - Eric Postpischil
@EricPostpischil 不一定。IEEE 754并不像你认为的那样保证。请参见https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html#3098。 - Mark Adler
1
@MarkAdler:这不符合IEEE 754的规范。y[0]+0.03*y[1];是一个乘法和加法操作。在IEEE 754中,融合乘加是一种单独的操作,称为fusedMultiplyAdd。甚至有一个标准的C库例程,叫做fma - Eric Postpischil
1
@MarkAdler:这不符合IEEE 754标准。y[0]+0.03*y[1];是一个乘法和一个加法。融合乘加是IEEE 754中的一个单独操作,称为fusedMultiplyAdd。甚至有一个标准的C库函数fma来执行此操作。 - Eric Postpischil
1
@MarkAdler:这不符合IEEE 754标准。y[0]+0.03*y[1];是一个乘法和一个加法。融合乘加是IEEE 754中的一个单独操作,称为fusedMultiplyAdd。甚至有一个标准的C库函数fma来执行此操作。 - undefined
显示剩余21条评论

3

看起来在13.x和14.x之间,clang的行为发生了变化。当使用-O时,结果在编译时计算,与目标的浮点数无关,所以这严格来说是一个编译器问题。

在godbolt上尝试一下

在十六进制浮点输出中,差异更容易看出。clang 13及更早版本计算出的值为0x1.16872b020c49cp-6,略大于1.7。而clang 14及更高版本计算出的值为0x1.16872b020c49bp-6,略小(最低有效位相差1)。

无论是在arm64还是x86-64上,这两个版本之间存在同样的差异。

我不确定哪个更好或更糟。如果你真的在意的话,我想你可以使用git bisect命令,并查看相应提交的理由,看看它是否正确。作为对比,所有测试版本的gcc都给出了"旧版clang"的值0x1.16872b020c49cp-6


我提供的汇编代码是否证实了这个问题:https://stackoverflow.com/questions/76409008/simple-c-program-yields-different-results-on-macos-arm64-depending-on-toolchain? - Stéphane Mottelet
你发布的汇编输出是不完整的。看起来计算中使用的值是从内存加载的(可能在文字池或.rodata部分)。但是这些值在你发布的汇编中没有显示出来。它们可能是带有奇怪十进制或十六进制值的.long指令,或者类似于此类的东西。如果你使用优化进行编译,代码将会更短,更易于跟踪,我相信你仍然会看到差异。 - Nate Eldredge
@StéphaneMottelet:我猜那是一个单独的问题。从你的原始帖子中,我并不清楚你是否在两个架构上使用了相同的编译器版本。 - Nate Eldredge
是的,M1 Mac使用相同的编译器(clang 14.0.6),只需添加“-arch x86_64”即可交叉编译Intel二进制文件。但问题似乎是相同的:在arm64汇编代码中有一个fmadd(三个操作数的组合乘法/加法),而在x86_64汇编代码中有一系列的mulsd和addsd。 - St&#233;phane Mottelet
是的,M1 Mac使用相同的编译器(clang 14.0.6),只需添加-arch x86_64来交叉编译Intel二进制文件。但问题似乎是相同的:在arm64汇编代码中是一个fmadd(三个操作数的乘法和加法组合),而在x86_64汇编代码中是一系列的mulsd和addsd操作。 - Stéphane Mottelet
显示剩余9条评论

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