您绝对不需要 256 位累加器。整数之和最多N
只能产生进位,因此 64 位进位计数器足以处理 2^64 个元素长的向量的点积。即 128 位乘积之和的总宽度只需 192 = 64*3 或 160 = 128 + 32 位。即一个额外的寄存器。 N
overflow
是的,为此最佳的 x86-64 asm 是mov
-load from on array,mul
或者mulx
使用来自另一个数组的内存源,然后add
+ adc
into ans
,并adc reg, 0
累积进位。
(可能有一些循环展开,可能有 2 个或更多累加器(3 个寄存器组)。如果为 Intel CPU 展开,可能会避免mul
内存源的索引寻址模式,因此它可以微融合负载。不幸的是 GCC / clang不要创建相对于另一个数组索引一个数组的循环,以最小化循环开销(1 个指针增量),同时避免其中一个数组的索引寻址模式,因此您不会从编译器获得最佳 asm。但是叮当很好。)
clang9.0 为您的代码生成-O3 -march=broadwell -fno-unroll-loops
. 即使使用 128 位整数, Clang 也能识别a<b
出无符号进位的常用习惯用法,从而导致/ / (不幸的是,clang 的循环展开取消优化为; ; ;而不是,破坏了循环展开的好处!这是一个错过的优化应该报告的错误。)a+=b
add reg,reg
adc reg,reg
adc reg,0
mov
setc
movzx
add
adc reg,0
GCC 实际上在if()
您的
overflow += sum<prod;
. 但这对 GCC 的其他主要缺失优化没有帮助:实际上与cmp/sbb
for 进行128 位比较,sum < prod
而不是仅使用 last 的 CF 输出adc
。GCC 知道如何针对uint64_t
(使用进位标志进行有效的 128 位加法)进行优化,但显然不适用于从__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
会宏融合),因此它最多每 2.25 个时钟周期运行 1 次乘法。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 个周期,假设它仍然 unlaminatemul
的内存操作数。
Zen 有一个 5 条指令宽的管道,如果它包含任何多 uop 指令,则为 6 uop 宽。因此,它可能会在每次迭代 2 个周期时运行它,在其 2c 吞吐量方面遇到瓶颈以进行完全乘法。(正常imul r64, r64
的非扩展乘法是 1/clock,Zen 上的延迟为 3c,与 Intel 相同。)
Zen 2 加速mul m64
到mulx
1/clock ( https://www.uops.info/html-tp/ZEN2/MUL_M64-Measurements.html ),并且可能是这个循环中最快的 CPU .
通过一些展开和索引一个数组相对于另一个数组的手动优化版本,每次乘法的成本可能接近 6 uop = mov-load (1 uop) + mul mem (2 uop) + 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 可以运行得更快。