我们有 C 代码,可以循环大型 (10M - 1000M) 双精度数组(32 位对齐)并聚合它们。代码(如下)看起来很简单,但速度是我们能得到的最快的。超过 20 种其他方法,从简单到非常复杂 - 使用 SIMD、多线程和/或多处理 - 最终都会导致执行速度变慢。任何智取编译器的尝试都失败了。我们的收获:相信编译器!不要混淆编译器!我们正在使用最新的 clang 以及所有相关的编译器标志 on-fire、-O3 等。
double sum_double(const double *restrict values, const size_t n) {
double r = 0;
#pragma omp simd reduction(+:r)
for (size_t i = 0; i < n; i++)
r += values[i];
return r;
}
事情是这样的:在具有 100GB/s 带宽的基础 M2 Mac 上,我们可以达到最大。 65GB/s ≙ ±8B 64 位聚合/秒。作为参考,Numpy 达到了完全相同的吞吐量。
问题:
为什么我们不能超越这个 65GB/s 的阈值?
还剩下 35GB/s。 这只是相当于我们处理的时间吗?
为什么多线程/处理也值得?
我们预计多重处理至少相当于
单线程方法。但每次性能都会显着下降
添加进程或线程?
对此有什么经验或看法吗?还是我们做错了什么?谢谢
在大多数平台上,只要操作正确向量化和展开,此计算主要是内存限制。
Here
#pragma omp simd reduction(+:r)
使用 -fopenmp
或 -fopenmp-simd
正确矢量化循环(问题中未提及,但对于任何 OpenMP 代码来说相当明显)。这是生成的 ARM 汇编循环:
.LBB0_5:
ldp q2, q3, [x9, #-16]
subs x10, x10, #4
add x9, x9, #32
fadd v1.2d, v1.2d, v2.2d
fadd v0.2d, v0.2d, v3.2d
b.ne .LBB0_5
问题是它肯定不是最佳的,因为fadd
指令的延迟通常为 2-4 个周期(据我所知,Apple M1/M2 CPU 上为 3 个周期),而有 4 个 SIMD 单元可以执行每个周期。因此,如果代码尚未受内存限制,则它可能会受延迟限制。更糟糕的是:SIMD 单元可能会缺乏等待指令的时间!编译器不进一步优化循环的原因之一是 IEEE-754 标准禁止这样做。解决此问题的一种方法是使用
#pragma omp simd reduction(+:r) simdlen(8)
告诉 OpenMP 使用更广泛的 SIMD 操作。这是生成的代码:
.LBB0_5:
ldp q17, q16, [x9, #-64]
subs x10, x10, #16
ldp q19, q18, [x9, #-32]
ldp q20, q21, [x9]
fadd v2.2d, v16.2d, v2.2d
fadd v0.2d, v17.2d, v0.2d
ldp q17, q16, [x9, #32]
fadd v1.2d, v19.2d, v1.2d
fadd v3.2d, v18.2d, v3.2d
fadd v6.2d, v21.2d, v6.2d
fadd v4.2d, v20.2d, v4.2d
add x9, x9, #128
fadd v5.2d, v17.2d, v5.2d
fadd v7.2d, v16.2d, v7.2d
b.ne .LBB0_5
现在,CPU 可以并行执行更多指令,因为它们是独立的:4 条指令/周期,而且它们还可以更好地流水线化。对于内存限制的代码来说,这应该还远远不够。
请注意,-march=native
也可能有帮助。最重要的是,一段代码可能不会使内存饱和,因为对硬件而言,使 DRAM 饱和并不那么容易。这需要大型缓冲区和大量晶体管,以减轻多个 LPDDR5 DRAM 的巨大(且可变)延迟。因此,某些 CPU 架构即使使用完美优化的代码,也无法仅用一个核心来使 DRAM 内存饱和。我的 Intel Skylake CPU 就是这种情况,其中 CPU 的 LFB(行填充缓冲区)单元是阻止这种情况的瓶颈。
对于此类代码来说这不是问题,因为它可以并行化,并且所有内核通常都足以使 DRAM 内存饱和。事实上,通常只需很少的就足够了。
为什么多线程/处理也值得?如果线程被调度在高效的核心上,它们的速度可能会明显变慢。您应该检查此处是否正确使用了性能核心。这是 Apple M1/M2 甚至 Intel Alderlake(不太严重)等
big-little CPU 上的常见问题。 AFAIK,M1/M2“Pro”版本默认情况下不会发生这种情况。