为啥 C/RUST 中的一个加法计算在结果 ASM 中有 3 个双精度浮点加法工具?

Posted

技术标签:

【中文标题】为啥 C/RUST 中的一个加法计算在结果 ASM 中有 3 个双精度浮点加法工具?【英文标题】:Why one add calcuation in C/RUST has 3 double-precision floating-point add instruments in result ASM?为什么 C/RUST 中的一个加法计算在结果 ASM 中有 3 个双精度浮点加法工具? 【发布时间】:2019-07-12 08:46:38 【问题描述】:

简单的 C 代码,只有一个双精度加法。

void test(double *a, double *b, long n) 
    for (long j = 0; j < n; j++)
    for (long i = 0; i < n; i++) 
        b[i] = b[i] + a[j];
    

在编译器资源管理器中获取 ASM 结果:https://godbolt.org/z/tJ-d39

有一个addpd 和两个addsd。两者都是双精度相关的。

另一个类似的 rust 代码,得到了更多的双精度加法器:https://godbolt.org/z/c49Wuh

pub unsafe fn test(a: &mut [f64], b: &mut [f64], n: usize) 
    for j in 0..n 
        for i in 0..n 
            *b.get_unchecked_mut(i) = *b.get_unchecked_mut(i) + *a.get_unchecked_mut(j);
        
    

【问题讨论】:

我很确定这段代码可以更容易编写 您的实际问题是什么。您正在比较不同的语言(使用不同的编译器(gcc vs LLVM/clang))。请澄清您的问题,以便回答。 @hellow 我正在将 C 版本的 linpack 基准重写为 Rust。该基准主要测量大数组中的双精度计算。 Rust 版本的性能大约是 C 版本的 50%。我仔细检查了编译优化选项。删除带有不安全代码的切片索引边界检查。性能差距还是很大的。所以我想知道装配视图的区别。 你不能期望不同的编译器产生相同的代码。此外,Rust 代码非常危险且不习惯。用 Rust 重写应该是为了避免 C 的安全问题。This version 会好一点。 这个 Rust 代码更加地道,并且做同样的事情:godbolt.org/z/L32qQ8 a 不一定是可变的,顺便说一句 【参考方案1】:

尝试compiling without optimizations,您只会得到一条addsd 指令。 C 代码中的两个额外添加是由于自动矢量化。特别是如果您查看反汇编的第 34 和 37 行,您将看到向量内存访问。 addpd 是矢量化代码的主要补充部分,两个 addsds 用于处理边界条件。

Rust 代码中的额外指令是由于循环展开。

正如@Peter Cordes 所指出的,gcc 默认情况下在-O3 优化时不会进行循环展开,而 LLVM(Rust 编译器所基于的)会这样做。因此 C 代码和 Rust 代码之间的区别。

【讨论】:

GCC 默认情况下不会在-O3 进行循环展开。 -funroll-loops 作为-fprofile-use 的一部分启用,或者可以手动启用。 Clang/LLVM 确实循环展开。普通的 Rust 编译器基于 LLVM,它 确实 将微小/小循环展开 4 或 2 倍或更多,具体取决于调整选项,所以这就是 OP 在 Rust 中看到的,但它是 没有发生在您正在谈论的代码中。 (当行程计数是一个小的恒定迭代次数时,GCC 仍会完全展开一些循环。特别是如果跨迭代的恒定传播可以大大简化循环体) 对于简单的 asm,比 -O0 更好的建议是 gcc -O2-O3 -fno-tree-vectorize。 (请注意,即使在-O2,clang 也可以启用自动矢量化,但 gcc 不会。)使用 clang,您还可以使用 -fno-unroll-loops-O0 是一个难以阅读的可怕混乱,因为它不会在语句之间的寄存器中保留任何内容。无论如何,使用-fno-tree-vectorize 表明额外的指令来自矢量化,而不是其他优化。【参考方案2】:

在 C++ 的 GCC 输出中,前 2 个来自使用 addpd(压缩双精度)的自动矢量化 + 使用 addsd(双精度标量)进行的标量清理。如果您想将其编译为 C,请在编译器选项中使用 -xc

底部的额外addsd 位于单独的纯标量循环中,用于输入数组重叠的情况。


这两个标量addsd 指令是必要的,因为您没有向编译器保证输入数组不重叠(与double *restrict a),并且您没有保证大小是@ 的偶数987654327@s.

所以要使用 SIMD 自动矢量化,我们需要检查重叠。如果长度不是 SIMD 向量的整数,我们需要进行清理。

这也是为什么函数中有这么多整数指令,而不仅仅是两个简单的嵌套循环。

您的 Rust/LLVM 输出是相同的,但对主 SIMD 循环进行了循环展开(LLVM 默认执行此操作)。因此标量清理循环可能需要运行超过 1 次迭代,因为 1 次 SIMD 循环迭代不仅仅执行 2 个元素。


不幸的是,GCC/clang 没有优化你的函数来总结 a[0..n-1] 然后循环一次 b,将总数添加到每个元素。这对于-ffast-math 是合法的(否则不是因为 FP 数学不是严格关联的),但不幸的是编译器无论如何都不会这样做。您必须在源代码中自己完成。

这是一个主要错过的优化,从O(n^2)O(n) 的复杂性。但它是编译器不会为你做的,即使是-ffast-math

【讨论】:

以上是关于为啥 C/RUST 中的一个加法计算在结果 ASM 中有 3 个双精度浮点加法工具?的主要内容,如果未能解决你的问题,请参考以下文章

在 HLSL 中进行 64 位加法,为啥我的一种实现会产生不正确的结果?

加法的Java 中的加法

为啥 pushl %ebp 和 movl %esp, %ebp 在每个 ASM 函数的开头?

C++ 浮点加法(从头开始):无法计算负结果

计算器一位加法计算,并将实际值,测试结果输出,最终导入到本地

Oracle内部表X$KFFXP为啥为空?