如何在 AVX 寄存器上打包 16 个 16 位寄存器/变量
Posted
技术标签:
【中文标题】如何在 AVX 寄存器上打包 16 个 16 位寄存器/变量【英文标题】:How to pack 16 16-bit registers/variables on AVX registers 【发布时间】:2017-08-11 03:33:46 【问题描述】:我使用内联汇编,我的代码是这样的:
__m128i inl = _mm256_castsi256_si128(in);
__m128i inh = _mm256_extractf128_si256(in, 1);
__m128i outl, outh;
__asm__(
"vmovq %2, %%rax \n\t"
"movzwl %%ax, %%ecx \n\t"
"shr $16, %%rax \n\t"
"movzwl %%ax, %%edx \n\t"
"movzwl s16(%%ecx, %%ecx), %%ecx \n\t"
"movzwl s16(%%edx, %%edx), %%edx \n\t"
"xorw %4, %%cx \n\t"
"xorw %4, %%dx \n\t"
"rolw $7, %%cx \n\t"
"rolw $7, %%dx \n\t"
"movzwl s16(%%ecx, %%ecx), %%ecx \n\t"
"movzwl s16(%%edx, %%edx), %%edx \n\t"
"pxor %0, %0 \n\t"
"vpinsrw $0, %%ecx, %0, %0 \n\t"
"vpinsrw $1, %%edx, %0, %0 \n\t"
: "=x" (outl), "=x" (outh)
: "x" (inl), "x" (inh), "r" (subkey)
: "%rax", "%rcx", "%rdx"
);
我在代码中省略了一些 vpinsrw,这或多或少是为了说明原理。真正的代码使用了 16 个 vpinsrw 操作。但输出与预期不符。
b0f0 849f 446b 4e4e e553 b53b 44f7 552b 67d 1476 a3c7 ede8 3a1f f26c 6327 bbde
e553 b53b 44f7 552b 0 0 0 0 b4b3 d03e 6d4b c5ba 6680 1440 c688 ea36
第一行是真实答案,第二行是我的结果。 C代码在这里:
for(i = 0; i < 16; i++)
arr[i] = (u16)(s16[arr[i]] ^ subkey);
arr[i] = (arr[i] << 7) | (arr[i] >> 9);
arr[i] = s16[arr[i]];
我的任务是让这段代码更快。
在旧代码中,数据从 ymm 移动到堆栈,然后像这样从堆栈移动到 16 字节寄存器。所以我想将数据直接从 ymm 移动到 16 字节寄存器。
__asm__(
"vmovdqa %0, -0xb0(%%rbp) \n\t"
"movzwl -0xb0(%%rbp), %%ecx \n\t"
"movzwl -0xae(%%rbp), %%eax \n\t"
"movzwl s16(%%ecx, %%ecx), %%ecx \n\t"
"movzwl s16(%%eax, %%eax), %%eax \n\t"
"xorw %1, %%cx \n\t"
"xorw %1, %%ax \n\t"
"rolw $7, %%cx \n\t"
"rolw $7, %%ax \n\t"
"movzwl s16(%%ecx, %%ecx), %%ecx \n\t"
"movzwl s16(%%eax, %%eax), %%eax \n\t"
"movw %%cx, -0xb0(%%rbp) \n\t"
"movw %%ax, -0xae(%%rbp) \n\t"
【问题讨论】:
开头in
的值是多少?
哦,对不起,变量 in 是 256 字节存储在 ymm 寄存器中。
我需要知道您提供的输入,以便确保输出正确。您刚刚发布了函数的输出,但没有发布您给它的输入。
另外,在你的问题中留下一半的代码并不是获得正确答案的好方法。
@huseyintugrulbuyukisik:它使编译器生成的 asm 输出 (gcc O3 -S
) 比仅使用 ;
来分隔指令并让 C 字符串文字连接将所有内容打包到一行中更具可读性。您可以省略最后一行末尾的 \n\t
,但 OP 的内联 asm 格式样式很好。 (指令选择OTOH....不是那么多。)
【参考方案1】:
一个 Skylake(聚集速度很快),使用 Aki 的答案将两个聚集在一起可能是一个胜利。这使您可以使用向量整数的东西非常有效地进行旋转。
在 Haswell 上,继续使用标量代码可能会更快,具体取决于周围代码的外观。 (或者也许用矢量代码做矢量旋转+异或仍然是一个胜利。试试看。)
你有一个非常糟糕的性能错误,还有其他几个问题:
"pxor %0, %0 \n\t"
"vpinsrw $0, %%ecx, %0, %0 \n\t"
使用旧版 SSE pxor
将 %0
的低 128b 归零,同时保持高 128b 不变,将导致 Haswell 上的 SSE-AVX 转换惩罚;我想,pxor
和第一个 vpinsrw
各大约 70 个周期。 On Skylake, it will only be slightly slower,并且有一个错误的依赖关系。
改为使用vmovd %%ecx, %0
,它将向量reg的高字节归零(从而打破对旧值的依赖)。
其实用
"vmovd s16(%%rcx, %%rcx), %0 \n\t" // leaves garbage in element 1, which you over-write right away
"vpinsrw $1, s16(%%rdx, %%rdx), %0, %0 \n\t"
...
当您可以直接插入向量时,将指令(和微指令)加载到整数寄存器然后从那里进入向量是一种巨大的浪费。
您的索引已经是零扩展的,所以我使用 64 位寻址模式来避免在每条指令上浪费地址大小前缀。 (由于您的表是static
,它位于低 2G 的虚拟地址空间中(在默认代码模型中),因此 32 位寻址确实有效,但它没有为您带来任何好处。)
不久前,我尝试将标量 LUT 结果(用于 GF16 乘法)转换为向量,并针对英特尔 Sandybridge 进行调整。不过,我并没有像您那样链接 LUT 查找。见https://github.com/pcordes/par2-asm-experiments。在发现 GF16 使用 pshufb
作为 4 位 LUT 后,我有点放弃了它,但无论如何我发现如果你没有收集指令,从内存到向量的 pinsrw
是好的。
您可能希望通过同时对两个向量进行交错操作来提供更多 ILP。或者甚至可能进入 4 个向量的低 64b,并与vpunpcklqdq
结合。 (vmovd
比 vpinsrw
更快,因此它在 uop 吞吐量方面几乎是收支平衡。)
"xorw %4, %%cx \n\t"
"xorw %4, %%dx \n\t"
这些可以而且应该是xor %[subkey], %%ecx
。 32 位操作数大小在这里更有效,只要您的输入在高 16 位中没有设置任何位,就可以正常工作。使用[subkey] "ri" (subkey)
约束允许在编译时知道立即值。 (这可能会更好,并且稍微减少了注册压力,但会以代码大小为代价,因为您多次使用它。)
不过,rolw
指令必须保持 16 位。
您可以考虑将两个或四个值打包到一个整数寄存器中(使用 movzwl s16(...), %%ecx
/ shl $16, %%ecx
/ mov s16(...), %cx
/ shl $16, %%rcx
/ ...),但随后您必须模拟旋转与移位/ 或和掩蔽。并再次解包以将它们用作索引。
整数的东西出现在两个 LUT 查找之间太糟糕了,否则你可以在解包之前在向量中执行它。
你提取 16b 块向量的策略看起来不错。 movdq
从 xmm 到 GP 寄存器在 Haswell/Skylake 的端口 0 上运行,shr
/ror
在端口 0/端口 6 上运行。所以你确实会争夺一些端口,但是存储整个向量并重新加载它会占用更多的加载端口。
可能值得尝试做一个 256b 存储,但仍然可以从 vmovq
获得低 64b,因此前 4 个元素可以在没有太多延迟的情况下开始。
至于得到错误答案:使用调试器。调试器非常适合 asm;有关使用 GDB 的一些提示,请参阅 x86 tag wiki 的末尾。
查看编译器生成的代码,该代码在您的 asm 和编译器正在执行的操作之间进行接口:也许您的约束有误。
也许您与%0
或%1
或其他东西混淆了。我绝对推荐使用%[name]
而不是操作数。另请参阅 inline-assembly tag wiki 以获取指南链接。
避免内联 asm 的 C 版本(但 gcc 会浪费指令)。
您根本不需要 inline-asm,除非您的编译器在将向量解包为 16 位元素时做得不好,并且没有生成您想要的代码。 https://gcc.gnu.org/wiki/DontUseInlineAsm
我把 up on Matt Godbolt's compiler explorer 放在你可以看到 asm 输出的地方。
// This probably compiles to code like your inline asm
#include <x86intrin.h>
#include <stdint.h>
extern const uint16_t s16[];
__m256i LUT_elements(__m256i in)
__m128i inl = _mm256_castsi256_si128(in);
__m128i inh = _mm256_extractf128_si256(in, 1);
unsigned subkey = 8;
uint64_t low4 = _mm_cvtsi128_si64(inl); // movq extract the first elements
unsigned idx = (uint16_t)low4;
low4 >>= 16;
idx = s16[idx] ^ subkey;
idx = __rolw(idx, 7);
// cast to a 32-bit pointer to convince gcc to movd directly from memory
// the strict-aliasing violation won't hurt since the table is const.
__m128i outl = _mm_cvtsi32_si128(*(const uint32_t*)&s16[idx]);
unsigned idx2 = (uint16_t)low4;
idx2 = s16[idx2] ^ subkey;
idx2 = __rolw(idx2, 7);
outl = _mm_insert_epi16(outl, s16[idx2], 1);
// ... do the rest of the elements
__m128i outh = _mm_setzero_si128(); // dummy upper half
return _mm256_inserti128_si256(_mm256_castsi128_si256(outl), outh, 1);
我必须进行指针转换才能将 vmovd
直接从 LUT 获取到第一个 s16[idx]
的向量中。没有它,gcc 使用 movzx 加载到整数 reg 中,然后从那里使用 vmovd
。这避免了由于执行 32 位加载而导致缓存行拆分或页面拆分的任何风险,但对于平均吞吐量而言,这种风险可能是值得的,因为这可能会成为前端 uop 吞吐量的瓶颈。
注意 x86intrin.h 中 __rolw
的使用。 gcc supports it, but clang doesn't。它编译为 16 位循环,无需额外指令。
不幸的是,gcc 没有意识到 16 位循环将寄存器的高位保持为零,因此它在使用 %rdx
作为索引之前做了一个毫无意义的 movzwl %dx, %edx
。即使使用 gcc7.1 和 8-snapshot 也是一个问题。
顺便说一句,gcc 将s16
表地址加载到寄存器中,因此它可以使用vmovd (%rcx,%rdx,2), %xmm0
之类的寻址模式,而不是将 4 字节地址嵌入到每条指令中。
由于额外的movzx
是 gcc 唯一比你手工做的更糟糕的事情,你可以考虑在 gcc 认为需要 32 位或 64 位输入寄存器的内联 asm 中创建一个旋转 7 函数. (使用这样的东西来获得“一半”大小的旋转,即 16 位:
// pointer-width integers don't need to be re-extended
// but since gcc doesn't understand the asm, it thinks the whole 64-bit result may be non-zero
static inline
uintptr_t my_rolw(uintptr_t a, int count)
asm("rolw %b[count], %w[val]" : [val]"+r"(a) : [count]"ic"(count));
return a;
然而,即便如此,gcc 仍然希望发出无用的movzx
或movl
指令。通过为idx
使用更广泛的类型,我摆脱了一些零扩展,但仍然存在问题。 (source on the compiler explorer)。出于某种原因,使用 subkey
函数 arg 而不是编译时常量会有所帮助。
您可以让 gcc 假设某物是一个零扩展的 16 位值:
if (x > 65535)
__builtin_unreachable();
然后你可以完全放弃任何内联汇编,只使用__rolw
。
但请注意icc
会将其编译为实际检查,然后跳转到函数末尾。它应该适用于 gcc,但我没有测试。
不过,如果需要进行如此多的调整以使编译器不会自相残杀,那么将整个内容写在内联 asm 中是非常合理的。
【讨论】:
您是否知道任何已发布的基准测试表明 Skylake 上的收集速度有多快?我看到的最后结果(可能来自 Broadwell)表明您最好改为执行多次加载和插入。 @JasonR:Agner Fog 的指令表表明 Skylakevgatherdps ymm
已降至 4 个融合域微指令,并且以每 5c 吞吐量运行一个微指令。如果可以避免前端瓶颈(以及更低的前端问题成本),这仍然比每个周期使用标量负载可以获得 2 个负载略差。在 Broadwell 上,这种算法可能是关于收集与解包到标量以链接 LUT 查找的收支平衡。在 Skylake 上,几乎可以肯定的是链式聚集是一场胜利,因为前端存在标量瓶颈(因为所有额外的 ALU 工作都用于轮换)。
非常感谢,你怎么知道这么多intel指令的?
@Bai:我一直对计算机架构(CPU 的设计和内部工作方式)感兴趣。我对性能的了解很多来自阅读 Agner Fog 的指南,以及查看编译器输出以查看它是否做得很好。也来自尝试使用该信息来优化真实代码。 (在 asm 或 by tweaking the C to get the compiler to make better code 中,当我想知道某事时,我会在 Intel 的 PDF 手册中查找!
@Bai:另外,我从回答 SO 问题中学到了很多。【参考方案2】:
内联汇编器有点像 C 代码,所以我很想假设这两个是相同的。
这主要是一种意见,但我建议使用intrinsics 而不是扩展汇编程序。内在函数允许编译器完成寄存器分配和变量优化,以及可移植性——每个向量操作都可以在没有目标指令集的情况下由函数模拟。
下一个问题是内联源代码似乎只处理两个索引i
的替换块arr[i] = s16[arr[i]]
。使用 AVX2,这应该通过 两个 收集操作来完成,因为 Y 寄存器只能保存 8 个 uint32_ts 或查找表的偏移量,或者当它可用时,替换阶段应该由分析执行可以并行运行的函数。
使用内在函数,操作可能看起来像这样。
__m256i function(uint16_t *input_array, uint16_t subkey)
__m256i array = _mm256_loadu_si256((__m256i*)input_array);
array = _mm256_xor_si256(array, _mm256_set_epi16(subkey));
__m256i even_sequence = _mm256_and_si256(array, _mm256_set_epi32(0xffff));
__m256i odd_sequence = _mm256_srli_epi32(array, 16);
even_sequence = _mm256_gather_epi32(LUT, even_sequence, 4);
odd_sequence = _mm256_gather_epi32(LUT, odd_sequence, 4);
// rotate
__m256i hi = _mm256_slli_epi16(even_sequence, 7);
__m256i lo = _mm256_srli_epi16(even_sequence, 9);
even_sequence = _mm256_or_si256(hi, lo);
// same for odd
hi = _mm256_slli_epi16(odd_sequence, 7);
lo = _mm256_srli_epi16(odd_sequence, 9);
odd_sequence = _mm256_or_si256(hi, lo);
// Another substitution
even_sequence = _mm256_gather_epi32(LUT, even_sequence, 4);
odd_sequence = _mm256_gather_epi32(LUT, odd_sequence, 4);
// recombine -- shift odd by 16 and OR
odd_sequence = _mm256_slli_epi32(odd_sequence, 16);
return _mm256_or_si256(even_sequence, odd_sequence);
通过优化,一个体面的编译器将在每条语句中生成大约一条汇编指令;如果没有优化,所有中间变量都会溢出到堆栈中以便于调试。
【讨论】:
非常感谢,看了你的回答,我知道_mm256_i32gather_epi32指令了。但我的实验环境是 HASWELL,所以可能就像 Peter Cordes 所说的,在 skylake 上,gather 很快。 Fast 在所有架构中都可能用词不当...您知道替换函数是否是分析性的(例如,GF2^8 上的操作),这可以从 clmul 指令中受益吗? 非常感谢,我的英文不太好。我的任务是优化Kasumi算法。我使用名为 s16 的数组,它有 65536 个元素(2 ^ 16),它满足均匀分布。所以数组的大小超过了缓存线的大小,所以我达不到要求。上面的代码占用了整个算法大约 90% 的时间。谢谢!以上是关于如何在 AVX 寄存器上打包 16 个 16 位寄存器/变量的主要内容,如果未能解决你的问题,请参考以下文章
如何将 AVX512 寄存器 zmm26 中的 QuadWord 写入 rax 寄存器?