_mm_set_epi64() 内在函数并不神奇。它们编译为加载或随机播放。手动向量化时,给编译器多个指针进行排序通常是错误的方法:弄清楚在执行向量加载后可以使用哪些 SSE/AVX shuffle。
对于 128b SSE2,(或带有-mprefer-avx128 的 AVX)gcc 如果知道指针至少是 16B 对齐的,则它可以合理地自动矢量化简单的标量实现。 (所以一对需要一起处理的块会在同一个对齐的 16B 块中)。我没有看到更好的方法,它可能比标量 64 位略快。奇怪的是,Clang 不会自动矢量化,除非它具有 AVX512(对于 vpermt2q)。
(对于 AVX2,gcc 洗牌方式太多。报告为 https://gcc.gnu.org/bugzilla/show_bug.cgi?id=82137。请参阅下面的手动矢量化版本,它应该比 Haswell 上的标量或 SSE2 快 2 倍以上。)
查看所有源代码on the Godbolt compiler explorer,了解它是如何矢量化的。
// scalar version for compilers to autovectorize
#include <stdint.h>
void foo(uint64_t blocks[]) {
// tell gcc the pointer is 64-byte aligned, to get a simpler auto-vectorization strategy.
blocks = __builtin_assume_aligned(blocks, 64);
for (intptr_t i = 0 ; i<10240 ; i+=2) {
uint64_t x = blocks[i];
uint64_t y = blocks[i+1];
blocks[i] = x^y;
blocks[i+1] = x&y;
}
}
在您的用例中,您的指针是否至少与 128b 对齐?您可能应该尝试实现这一点,因此“对”不会跨越缓存行边界。 SSE2 版本可以对 SSE 指令使用对齐的加载/存储或内存操作数,而不是单独加载。
有许多不同的方法可以自动矢量化任何东西。您甚至可以考虑进行未对齐的重叠加载,以获得第二个向量,其中 blocks[0] 和 blocks[2] 在每个 128b 通道的低 64b 中排列。 (负载吞吐量对于现代 CPU 上的 L1 缓存命中通常非常好。值得考虑使用未对齐负载来减少 shuffle,但我认为这不是 AVX2 在这种情况下的最佳选择)。
首先让我们看一下标量(或在 32 位代码中,使用 SSE2 进行 64 位标量整数数学运算。gcc -m32 does exactly that with unaligned pointers and no AVX or -mprefer-avx128):
mov # load
mov # load
mov # copy a register
and
xor
mov # store
mov # store
每 128b 对:7 个融合域微指令(所有指令都是单微指令)。 2xload、2xstore、3x ALU(如果 mov 不需要端口,则更少)。前端可以在 1.75c(或 Ryzen 上更少)中发出 7 条微指令。在所有当前 CPU 上以每个时钟 1 个存储吞吐量瓶颈,因此通过足够的循环展开,您可以使用标量 x86-64、MMX 或标量 SSE2 进行大约 每 2 个时钟 1 对,即使在 Core2 等旧 CPU 上也是如此或推土机。
SSE2
这就是 gcc 自动矢量化的方式,每次循环迭代处理 2 对。使用 AVX-128 看起来不错,但使用 SSE2 需要 3 个额外的 movdqa 指令来复制寄存器,然后才能将它们作为组合的 src/destination 销毁。 (有关应该更好的手动矢量化版本,请参阅下一节)。
b0 b1 # load128
b2 b3 # load128
# movdqa copy a reg
b0 b2 # punpcklqdq
b1 b3 # punpckhqdq
# movdqa copy a reg
b0&b1 b2&b3 # pand
b0^b1 b2^b3 # pxor
# movdqa copy a reg
b0^b1 b0&b1 # punpcklqdq
# store 128
b2^b3 b2&b3 # punpckhqdq
# store 128
13 个融合域微指令。 (Ryzen 以外的 CPU 上的前端周期为 3.25c)。 4x shuffle,2xload,2x store,2x boolean。 3x reg-reg 副本,uses an ALU execution port or it doesn't,取决于 CPU。不过这里没关系,3.25 个周期内 5 个 ALU 微指令不是瓶颈。
gcc -m32 做出了有趣的选择,即两次使用具有相同内存操作数的 punpckh/l,而不是为第二个向量单独加载 movdqa。这节省了一个融合域 uop(因为punpck 可以微融合),但保持加载端口更繁忙。不过,这不是瓶颈。
Intel Haswell 和后来的瓶颈是每时钟 1 次随机播放,因此它们的吞吐量为 4c,或 每对 2c,与标量相同(但可能更容易接近该限制,并且可能会达到即使没有循环展开。)
AMD CPU 和 Intel Core2 到 IvyBridge,每个时钟可以执行 2x 128b shuffle,因此它们只是在 3.25c + 循环开销的前端成为瓶颈,而不是在任何特定端口上。加上一点循环开销,每对可能是 1.75c。 (或者 Ryzen 可以在每时钟运行单 uop 指令时执行大约 5 uop,因此每 ~2.6 个周期两对,或每 ~1.3 个周期 + 1 对 + 开销)。
使用 AVX-128 和微融合负载,它是 9 个融合域微指令(2.25c + 要发出的循环开销)。仍然是 4x shuffle,并且需要 AVX1,但这对于 Sandybridge 和 AMD 来说非常好。 SnB 上每对大约 1.125c + 循环开销。
SSE2/SSE3 手动矢量化
上述 SSE2 版本的最大问题是所有额外的 movdqa 指令在销毁寄存器之前复制寄存器。
我们可以利用 AND 和 XOR 的特性来节省一些 asm 指令。 x&x = x 和 x ^ 0 = x。
这个版本在 Haswell 上可能会很好,使用 3 次加载相同的数据。但在其他 CPU(包括 AMD)上,太多的加载和存储将成为瓶颈。
x x # movddup load (SSE3)
x x&y # pand [mem]
y 0 # movq load
x^y x&y # pxor ([x x&y], [y 0])
store
5 uops (1.25c front-end), 3 loads + 1 store (1.5c HSW, or 2c AMD/SnB, or 3c NHM)
或者这个版本很好地平衡了负载和随机播放。它实际上在 AVX2 之前的硬件上非常好。
x y # load
x x # movddup or pshufd to copy+shuffle
x x&y # pand
y 0 # movq load or PSRLDQ by 8 bytes
x^y x&y # pxor
store
6 uops (1.5c front-end + loop overhead)
movq-load version: 2 loads + 1 store (1c HSW, 1.5c AMD/SNB, 2c NHM)
PSRLDQ version: 1 load + 1 store, 2 shuffles, 2 boolean: (2c HSW, 1.33c AMD and Intel NHM/SnB)
所以前端是 2-shuffle 版本的瓶颈,即使在 Nehalem 上,它不能每时钟执行 2 次加载。在没有 AVX2 的 CPU 上,这可能比标量要好得多:
#include <immintrin.h>
void pair_u64_sse2(uint64_t blocks[]) {
// take advantage of x&x = x
// and x&y ^ 0 = x&y
for (int i = 0 ; i<10240 ; i+=2) {
__m128i v = _mm_loadu_si128((__m128i*)&blocks[i]);
__m128i dup = _mm_shuffle_epi32(v, _MM_SHUFFLE(1,0, 1,0));
__m128i and = _mm_and_si128(v, dup); // x x&y
__m128i y = _mm_srli_si128(v, 8); // y 0
__m128i xor = _mm_xor_si128(and, y); // x^y x&y
_mm_storeu_si128((__m128i*)&blocks[i], xor);
}
}
在 Godbolt 链接上,查看非 AVX asm 输出的 clang 选项卡。 gcc 无缘无故地使用了额外的 movdqa,但 clang 成功地不浪费指令。 使用循环展开,它应该接近每 1.5 个时钟 1 个向量(如果数据在缓存中很热),在 Intel pre-Haswell 或某些 AMD CPU 上。在 Ryzen 上,甚至可能比这更好。
AVX2
这是编译器做得很糟糕的地方,请参阅我之前提交的 gcc 错误报告,链接。
一次手动向量化一个 256b 向量,使用这样的数据移动模式应该很好:
b0 b1 | b2 b3 # load 256b
b1 b0 | b3 b2 # vpshufd
b0^b1 b0^b1 | b2^b3 b2^b3 # vpxor
b0&b1 b0&b1 | b2^b3 b2&b3 # vpand
b0^b1 b0&b1 | b2^b3 b2&b3 # vpblendd
# store 256b
这是一个 C/C++ 内部函数版本:
#include <immintrin.h>
void pairs_u64_avx2(uint64_t blocks[]) {
for (int i = 0 ; i<10240 ; i+=4) {
__m256i v = _mm256_loadu_si256((__m256i*)&blocks[i]);
__m256i swapped = _mm256_shuffle_epi32(v, _MM_SHUFFLE(1,0, 3,2));
__m256i and = _mm256_and_si256(v, swapped);
__m256i xor = _mm256_xor_si256(v, swapped);
__m256i blend = _mm256_blend_epi32(xor, and, _MM_SHUFFLE(3,0,3,0));
_mm256_storeu_si256((__m256i*)&blocks[i], blend);
}
}
这是 Intel 上的 6 个融合域微指令,应该可以轻松地以每 1.5 个周期 1 个迭代(+ 循环开销)运行,而不会在任何端口上出现瓶颈。瓶颈是前端,所以展开会有所帮助。
这是在 Haswell 上 每 128b 对 0.75 个周期,加上循环开销。
即时混合可以在 HSW+ 上的任何端口上运行,或者在 SnB 上的 p0/p5 上运行(并且在 BD/Ryzen 上具有良好的吞吐量),因此它比使用 vunpcklqdq 组合 AND / XOR 结果向量对吞吐量更加友好。
其他看起来没有希望的废弃想法
b0 b1 load 128
b2 b3 load 128
b0 b1 | b3 b4 vinsertf128 y,y,m,1 (SKL: 2 uops, load + p015 ALU)
b2 b3 | b5 b6 vinsertf128
不,更容易得到它
b0 b1 | b2 b3 v = load256 aligned
b4 b5 | b6 b7 v2 = load256 aligned
b0 b1 | b6 b7 vpblendd //vinserti128 (v, v2)
b2 b3 | b4 b5 vperm2i128 (v, v2) (doesn't micro-fuse, unlike vpunpck, so not helpful to use with a memory operand)
Then vpunpck l/h in-lane shuffles, then a AND/XOR,
then 2x VPERMQ + 2x vpunpck?
Or vpunpck and split 128b stores? vmovdqa 128b + vextracti128
b0 b1 # load128
b1 b0 # pshufd (copy+shuffle)
# movdqa copy
b0&b1 b1&b0 # pand
movq # store low half
b0^b1 b1^b0 # pxor
movq # store low half
基本上没有标量的优势。
可以将两个向量组合在一起并使用movhps 来存储高半部分吗?不过,它需要一个 shuffle-port uop,因此在 punpckhqdq 或 movhlps 将两个寄存器组合为一个 128b 存储时并没有太多优势。