如何在 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 结合。 (vmovdvpinsrw 更快,因此它在 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 仍然希望发出无用的movzxmovl 指令。通过为idx 使用更广泛的类型,我摆脱了一些零扩展,但仍然存在问题。 (source on the compiler explorer)。出于某种原因,使用 subkey 函数 arg 而不是编译时常量会有所帮助。

您可以让 gcc 假设某物是一个零扩展的 16 位值:

if (x > 65535)
    __builtin_unreachable();

然后你可以完全放弃任何内联汇编,只使用__rolw

但请注意icc 会将其编译为实际检查,然后跳转到函数末尾。它应该适用于 gcc,但我没有测试。

不过,如果需要进行如此多的调整以使编译器不会自相残杀,那么将整个内容写在内联 asm 中是非常合理的。

【讨论】:

您是否知道任何已发布的基准测试表明 Skylake 上的收集速度有多快?我看到的最后结果(可能来自 Broadwell)表明您最好改为执行多次加载和插入。 @JasonR:Agner Fog 的指令表表明 Skylake vgatherdps 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 位寄存器/变量的主要内容,如果未能解决你的问题,请参考以下文章

AVX 或 SSE 上的水平尾随最大值

汇编第二章寄存器

如何将 AVX512 寄存器 zmm26 中的 QuadWord 写入 rax 寄存器?

如何在 AVX 中使用融合乘法和加法来处理 16 位压缩整数

AVX2 等效于 std::clamp

SIMD (AVX2) - 将 uint8_t 值加载到多个浮点 __m256 寄存器