您绝对不需要 256 位累加器。 N 整数的总和只能产生 大多数 N 进位,因此 64 位 overflow 进位计数器足以满足 2^64 个元素的向量的点积长。即 128 位乘积之和的总宽度只需 192 = 64*3 或 160 = 128 + 32 位。即一个额外的寄存器。
是的,最佳的 x86-64 asm 是 mov-从阵列加载,mul 或 mulx 使用来自另一个阵列的内存源,然后将 add + adc 加载到 @987654337 @, 和adc reg, 0 累积结转。
(可能有一些循环展开,可能有 2 个或更多累加器(3 个寄存器组)。如果为 Intel CPU 展开,可能避免mul 内存源的索引寻址模式,因此它可以微融合不幸的是,GCC / clang 不会创建相对于另一个数组索引一个数组的循环,以最小化循环开销(1 个指针增量),同时还避免其中一个数组的索引寻址模式,因此您不会获得最佳效果来自编译器的 asm。但是 clang 非常好。)
clang9.0 使用 -O3 -march=broadwell -fno-unroll-loops 为您的代码生成它。 Clang 识别通常的 a<b 习惯用法,用于执行无符号 a+=b,即使使用 128 位整数,导致 @ 987654343@ / adc reg,reg / adc reg,0 (不幸的是,clang 的循环展开优化为 mov ; setc ; movzx ; add 而不是 adc reg,0,破坏了循环展开的好处!应该报告的错过优化错误。)
GCC 实际上在您的if() 上分支,您可以通过将其无分支地编写为
overflow += sum<prod; 来修复它。但这对 GCC 的其他主要遗漏优化没有帮助:实际上对 sum < prod 与 cmp/sbb 进行 128 位比较,而不是仅使用最后一个 adc 的 CF 输出。 GCC 知道如何为 uint64_t (Efficient 128-bit addition using carry flag) 优化它,但显然不适合从 __uint128_t 执行。
可能无法通过更改源来手动控制 gcc 以避免错过优化,这是 GCC 开发人员必须在 GCC 中修复的错误。(因此您应该将其报告为他们的 bugzilla https://gcc.gnu.org/bugzilla/ 上的一个错过的优化错误;包括指向此 Q&A 的链接)。尝试自己使用 uint64_t 块进行全手动操作会更糟:中间块有进位和进位。这很难在 C 中正确编写,而且 GCC 不会将其优化为 adc。
即使使用 overflow += __builtin_add_overflow(sum, prod, &sum); 也没有完全帮助,给我们同样的 cmp/sbb/adc GCC 代码生成! https://gcc.gnu.org/onlinedocs/gcc/Integer-Overflow-Builtins.html 说“编译器将尝试使用硬件指令在可能的情况下实现这些内置函数,例如加法后溢出条件跳转,进位条件跳转等。“但显然它只是不知道如何处理 128 位整数。
#include <stdint.h>
static const int N = 2048;
using u128 = unsigned __int128;
u128 dp(uint64_t a[], uint64_t b[], uint64_t *overflow_out)
{
u128 sum = 0;
uint64_t overflow = 0;
for(int i=0;i<N;++i){
u128 prod = a[i] * (unsigned __int128) b[i];
//sum += prod;
//overflow += sum<prod; // gcc uses adc after a 3x mov / cmp / sbb; clang is still good
overflow += __builtin_add_overflow(sum, prod, &sum); // gcc less bad, clang still good
}
*overflow_out = overflow;
return sum;
}
Clang 很好地编译了这个 (Godbolt):
# clang9.0 -O3 -march=broadwell -fno-unroll-loops -Wall -Wextra
... zero some regs, copy RDX to R8 (because MUL uses that implicitly)
.LBB0_1: # =>This Inner Loop Header: Depth=1
mov rax, qword ptr [rsi + 8*rcx]
mul qword ptr [rdi + 8*rcx]
add r10, rax
adc r9, rdx # sum += prod
adc r11, 0 # overflow += carry_out
inc rcx
cmp rcx, 2048
jne .LBB0_1 # }while(i < N);
mov qword ptr [r8], r11 # store the carry count
mov rax, r10
mov rdx, r9 # return sum in RDX:RAX
ret
请注意,您不需要需要 ADOX / ADCX 或 MULX 来提高效率。乱序 exec 可以交错多个短 FLAGS 依赖链。
您可以将另外 3 个寄存器用于另一个 192 位累加器(求和和进位),但这可能没有必要。
这看起来像前端的 9 微指令(假设 mul 不能保持索引寻址模式微熔(非层压),但 cmp/jcc 将宏熔)所以它最多只能运行 1每 2.25 个时钟周期相乘。 Haswell 和更早的版本将 adc reg,reg 运行为 2 uop,但 Broadwell 将 3 输入指令(FLAGS + 2 regs)改进为 1 uop。 adc reg,0 在 SnB 系列上特例为 1 uop。
循环携带的依赖链每个只有 1 个循环:add 到 r10,adc 到 r9,adc 到 r11。这些 ADC 指令的 FLAGS 输入是一个短的非循环携带依赖链的一部分,乱序执行将在早餐时吃掉它。
具有 5 宽管道的 Ice Lake 运行速度会更快一些,例如每次迭代可能需要 1.8 个周期,假设它仍然未分层 mul 的内存操作数。
Zen 有一条 5 条指令宽的流水线,如果它包含任何多 uop 指令,则为 6 uop 宽。因此,它可能会在每次迭代 2 个周期时运行它,在其 2c 吞吐量方面遇到瓶颈以进行完全乘法。 (正常imul r64, r64 非扩展乘法是 1/clock,Zen 上的延迟为 3c,与 Intel 相同。)
Zen 2 将 mul m64 和 mulx 加速到 1 个/时钟 (https://www.uops.info/html-tp/ZEN2/MUL_M64-Measurements.html),并且在时钟换时钟的基础上可能是该循环中最快的 CPU。
通过相对于另一个数组展开和索引一个数组,手动优化版本的每次乘法成本可能接近 6 uop = mov-load (1 uop) + mul mem ( 2 uops)+ add + 2x adc(3 uops),所以在 Broadwell 上大约 1.5 个周期/乘法。
它仍然会成为前端的瓶颈,并且 2 uop(指针增量和 cmp/jcc)的最小循环开销意味着展开 4 可以为我们每 6.5 个周期而不是每 6 个周期提供 4 次乘法,或者完全展开理论最大值的 92%。 (假设内存或次优调度没有停顿,或者至少无序 exec 足以吸收这一点并且不会停顿前端。后端应该能够跟上前端在这里,在 Haswell 和更高版本上,所以 ROB 应该有空间来吸收一些小气泡,但可能不是 L3 或 TLB 未命中。)
我认为在这里使用 SIMD 没有任何好处;加法的最宽元素大小是 64 位,甚至 AVX512 也只有 64x64 => 128 位乘法。除非您可以转换为 double,在这种情况下,AVX512 可以非常更快地运行。