为什么 C/RUST 中的一个加法运算在结果 ASM 中有 3 个双精度浮点加法工具?
Why one add calcuation in C/RUST has 3 double-precision floating-point add instruments in result ASM?
简单的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);
}
}
}
在 C++ 的 GCC 输出中,前 2 个来自 auto-vectorization with addpd
(Packed Double) + scalar cleanup with addsd
(Scalar Double)。如果您想将其编译为 C,请在编译器选项中使用 -xc
。
底部的额外 addsd
位于单独的 pure-scalar 循环中,用于输入数组重叠的情况。
两个标量 addsd
指令是必需的,因为您没有向编译器保证输入数组不重叠(与 double *restrict a
),并且您没有向编译器保证大小是偶数个 double
s.
所以对于 SIMD auto-vectorize,我们需要检查重叠。我们需要清理以防长度不是 SIMD 向量的整数。
这也是为什么函数中有这么多整数指令,而不仅仅是 2 个简单的嵌套循环。
您的 Rust/LLVM 输出是相同的,但主 SIMD 循环使用 loop-unrolling(LLVM 默认执行)。因此,标量清理循环可能需要 运行 超过 1 次迭代,因为 1 次 SIMD 循环迭代执行的不仅仅是 2 个元素。
不幸的是,GCC/clang 没有优化您的函数来求和 a[0..n-1]
,然后循环 b
一次,将总数添加到每个元素。这对于 -ffast-math
是合法的(否则不是因为 FP 数学不是严格关联的),但不幸的是编译器无论如何都不这样做。你必须在源代码中自己做。
这是一个重大的优化失误,从 O(n^2)
到 O(n)
复杂度。但这是编译器不会为你做的,即使 -ffast-math
.
试试compiling without optimizations,你只会得到一个addsd
指令。 C 代码中的两个额外添加是由于 auto-vectorization。特别是如果您查看反汇编的第 34 和 37 行,您将看到向量内存访问。 addpd
是矢量化代码的主要添加部分,两个 addsd
用于处理边界条件。
Rust 代码中的额外指令是由于循环展开。
正如@Peter Cordes 所指出的,gcc 在 -O3
优化时默认不会展开循环,而 LLVM(Rust 编译器所基于的)会这样做。因此 C 代码和 Rust 代码之间的区别。
简单的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);
}
}
}
在 C++ 的 GCC 输出中,前 2 个来自 auto-vectorization with addpd
(Packed Double) + scalar cleanup with addsd
(Scalar Double)。如果您想将其编译为 C,请在编译器选项中使用 -xc
。
底部的额外 addsd
位于单独的 pure-scalar 循环中,用于输入数组重叠的情况。
两个标量 addsd
指令是必需的,因为您没有向编译器保证输入数组不重叠(与 double *restrict a
),并且您没有向编译器保证大小是偶数个 double
s.
所以对于 SIMD auto-vectorize,我们需要检查重叠。我们需要清理以防长度不是 SIMD 向量的整数。
这也是为什么函数中有这么多整数指令,而不仅仅是 2 个简单的嵌套循环。
您的 Rust/LLVM 输出是相同的,但主 SIMD 循环使用 loop-unrolling(LLVM 默认执行)。因此,标量清理循环可能需要 运行 超过 1 次迭代,因为 1 次 SIMD 循环迭代执行的不仅仅是 2 个元素。
不幸的是,GCC/clang 没有优化您的函数来求和 a[0..n-1]
,然后循环 b
一次,将总数添加到每个元素。这对于 -ffast-math
是合法的(否则不是因为 FP 数学不是严格关联的),但不幸的是编译器无论如何都不这样做。你必须在源代码中自己做。
这是一个重大的优化失误,从 O(n^2)
到 O(n)
复杂度。但这是编译器不会为你做的,即使 -ffast-math
.
试试compiling without optimizations,你只会得到一个addsd
指令。 C 代码中的两个额外添加是由于 auto-vectorization。特别是如果您查看反汇编的第 34 和 37 行,您将看到向量内存访问。 addpd
是矢量化代码的主要添加部分,两个 addsd
用于处理边界条件。
Rust 代码中的额外指令是由于循环展开。
正如@Peter Cordes 所指出的,gcc 在 -O3
优化时默认不会展开循环,而 LLVM(Rust 编译器所基于的)会这样做。因此 C 代码和 Rust 代码之间的区别。