使用矢量指令进行复杂数据重组

Complex data reorganization with vector instructions

我需要按照以下模式将 12 个字节加载并重新排列为 16 个(或 24 个为 32 个):

ABC DEF GHI JKL

变成

ABBC DEEF GHHI JKKL

您能否建议使用 SSE(2) and/or AVX(2) 指令实现此目的的有效方法?

这需要重复执行,因此允许预存掩码或常量。

到目前为止,最好的选择是使用字节随机播放 (pshufb)。在元素内移动本身是不够的,因为 JKL 必须比 DEF 向右移动得更远,等等。因此您需要多个指令来执行不同的移动并混合结果。

pshufb (_mm_shuffle_epi8) 需要 SSSE3,但可以在一条快速指令中完成 12B->16B 的工作。它使用向量作为随机播放控制掩码。这是第一个可变控制洗牌,也是第一个灵活的字节洗牌。 (SSE2 shuffle 全部使用 imm8 控制操作数,或者有固定的数据移动(例如 punpcklbw)。

编写一个循环加载 16B,将第一个 12B 洗牌到 16B,然后存储应该很容易。使用未对齐的加载,并在必要时使用未对齐的存储。不是使用标量清理循环来处理最后几个字节,而是将输入的最后 16B 加载到向量中,然后打乱其中的 last 12B。如果数组不是 12B 的倍数,则存储将与循环中的最后一个存储重叠,但这没关系。


如果输入和输出在 L1 缓存中是热的,则 128b 循环应该能够维持每个输出时钟 16B。可能需要一些展开才能实现这一点,例如:

# shuffle mask in xmm5
# rsi=src, rdi=dst,  rcx=src+size (pointer to last full vector)

.loop:
    movdqu   xmm0, [rsi]
    pshufb   xmm0, xmm5
    movdqu   [rdi], xmm0

    movdqu   xmm0, [rsi+12]
    pshufb   xmm0, xmm5
    movdqu   [rdi+16], xmm0

    add      rsi, 24
    add      rdi, 32
    cmp      rsi, rcx       ;; still 9 fused-domain uops in the loop, so it bottlenecks on the frontend.  Need more unroll :/
    jb     .loop

或者使用索引加载之类的技巧,索引计数为零。那将节省一个uop。 (add rsi, 24 / jl .loop)。 (如果你试图诱使编译器这样做,或者 实际上 手动编写 asm,请确保它是使用 2 寄存器寻址模式的负载,因为 would stop the stores from micro-fusing.)


AVX2

有四种处理通道交叉的选项(32B 负载将在低源通道中为高结果通道提供 4B 数据):

  • 使用 16B load/pshufb/store,与不使用 AVX 相同。 3 微指令,因此需要循环展开以维持每个时钟 16B 的存储。
  • double-shuffle:32B 加载/vpermd 在通道之间移动字节/32B vpshufb/32B 存储。应该在没有展开的情况下使洗牌端口饱和,每 2 个时钟维持一个 32B 存储。 (这有助于 vpermd 可以作为加载和洗牌工作,节省 uops。)
  • inserti128:两个 16B 加载/32B vpshufb/32B 存储。可以走得更快,但需要大量展开(因此需要清理代码)。
  • 使用对齐的负载,这样就不需要跨通道数据移动。 (需要缓冲区开头的特殊情况)。参见 BeeOnRope 的回答;这显然是最好的方法,只需要一个 vpshufb ymm,因此它废弃了这个答案的大部分其余部分。无论如何我们都需要做未对齐的加载。

  • (AVX512)vpermb 是一个完整的跨通道字节洗牌,控制掩码中有 6 位索引(对于 512b版本)。要洗牌的字节可以是内存操作数,因此可以用作加载和洗牌。 (vpshufb 可以在内存中拥有其控制掩码,但不能作为加载和随机播放工作。大概是因为它是在 32 位仍然重要的情况下设计的,其中只有 8 个向量 reg 可用)。

SnB/IvB 可以进行 128b 洗牌,每 0.5c 吞吐量一次,但由于它们只有 16B 数据路径到 L1 缓存,所以您最好让它们(和 AMD Bulldozer 系列)最大限度地发挥它们的作用使用非 AVX 版本存储吞吐量。它们支持 AVX1 但不支持 AVX2。 不用费心制作 AVX1 版本; SSSE3 版本 没有任何好处,除了可能在某处避免 vzeroupper。 (您可以在存储之前将两个 128b 随机播放结果与 vinsertf128 合并,这可能是一个微小的优势。)


Haswell/Skylake 核心只有一个洗牌端口,因此每 32B 结果需要两次洗牌的 double-shuffle 版本将成为瓶颈。但是,它所需的总融合域 uop 吞吐量远低于 16B 版本,因此您根本不需要展开以最大化吞吐量。尽管如此,如果你要制作一个展开的 SSSE3 版本,你最好使用它而不是用这种方式制作 AVX2 版本。如果您不打算使用非 AVX 版本,或者希望保持简单,此 应该使用最不复杂的源代码 提供良好的性能。特别是如果您的输出缓冲区(通常)是 32B 对齐的。

double-shuffle 对超线程也更友好,因为它 运行 的微指令更少。在这种情况下,它可能仍然受益于一个小的展开以减少循环开销,因此当它只获得一半的前端/发布周期时它仍然可以使洗牌端口饱和。它还增加了乱序 window:〜相同数量的飞行中加载和存储正在访问两倍的内存。这可能有助于减少缓存未命中造成的管道气泡,但对于像这样的顺序访问可能几乎没有影响。 Cache-line-crossing 32B loads/stores 可能比 16B 差。 (对齐输出缓冲区是一个非常好的主意,并确保输入缓冲区至少 4B 对齐。)


vinserti128版本:

诀窍是 vinserti128 具有内存源不需要随机端口:任何 ALU 端口都可以。所以理论上我们每个周期可以做两个重叠的 16B 加载和一个 32B 存储。 Haswell/Skylake 在实践中无法维持这一点,因为一些商店将 运行 他们的 AGU uop 在端口 2 或 3 上,而不是端口 7 专用商店 AGU。英特尔的优化手册(在第 2.1.3 节中,请参阅 标记 wiki 以获取链接)给出了 table Skylake 上 L1、L2 等的峰值吞吐量与持续吞吐量的对比。 Skylake 只能维持约 81B/周期总 to/from L1D 缓存,而峰值为每个时钟 96B(2 次加载和一次存储)。我认为有些商店从负载中窃取执行端口的原因,即使我们的负载只有 16B,这也会影响我们。

另一个主要问题:每个时钟流水线宽度的 4 个融合域微指令:vinserti128 是 2 个融合域微指令,所以 vmovdqu(16B 负载) / vinserti128 y,y,m,i / vpshufb / vmovdqu(32B store) 在不考虑循环开销的情况下已经是 5 uops。因此,即使有大量展开,我们能做的最好的事情就是保持随机播放和 load/store 端口的 4/5 被占用。这略低于每个时钟 81B 的瓶颈,所以这可能不会发挥作用。尽管如此,将近 32B * 4 / 5c 还是对 16B / c 的稳固胜利。

不要展开太多,因为我们需要前端提供每个时钟 4 微指令。如果循环低于 28 微指令左右,循环缓冲区将有助于避免出现瓶颈。 (或禁用超线程后更大,Skylake 可能增加了它。)

gcc 和 clang 即使使用 -funroll-loops 也无法展开循环,大概是因为迭代次数在编译时未知。 -funroll-all-loops 几乎没有减少开销,只是在循环体中放置了多个增量和循环退出分支。因此,您需要手动展开循环以使 vinserti128 版本具有任何优势。


代码:

插入和双随机版本,没有展开。既未测试也未调试,但 asm 看起来不错。

您需要整理这些并完善清理代码以满足您的要求。可能还会对这两个版本进行基准测试(如果您编写的是非 AVX 版本,则可能是三个版本)。

查看 godbolt compiler explorer 上的代码和 asm:

#include <immintrin.h>
#include <assert.h>

// This version won't have much advantage over a 16B loop,
// without significant loop unrolling in the source and expanding the cleanup code to match
void expand12to16_insert128(char *restrict dst, const char *restrict src, size_t src_bytes) {
  // setr: args in little-endian order
  const __m256i byteshuf = _mm256_setr_epi8(0,1,1,2, 3,4,4,5, 6,7,7,8, 9,10,10,11,
                                            0,1,1,2, 3,4,4,5, 6,7,7,8, 9,10,10,11);  
  //const __m256i byteshuf = _mm256_broadcastsi128_si256(byteshuf128);  // gcc is dumb and makes bad code for this, but it does save space


  assert(src_bytes >= 28);  // 28 because the cleanup code reads 4B before the last 24B and then shifts.  That can potentially segfault
    // FIXME: handle this case if needed.
    // maybe with a load that avoids crossing any cache-line boundaries not crossed by the input,
    // and then a VPMASKMOVD conditional store

  const char *lastsrcvec = src + src_bytes - 24;
  for ( ; src < lastsrcvec ; dst += 32, src += 24 ){
#if 1
    __m256i in    = _mm256_castsi128_si256( _mm_loadu_si128((__m128i*)src) );
    __m128i in_hi = _mm_loadu_si128((__m128i*)(src+12) );
    in = _mm256_inserti128_si256(in, in_hi, 1);
#else
    __m128i in_lo = _mm_loadu_si128((__m128i*)(src+0));
    __m128i in_hi = _mm_loadu_si128((__m128i*)(src+12) );
    __m256i in    = _mm256_set_m128i(in_hi, in_lo);  // clang supports this, but gcc doesn't.  Same asm, nicer C syntax
#endif
    __m256i out   = _mm256_shuffle_epi8(in, byteshuf);
    _mm256_storeu_si256((__m256i*)dst, out);
  }

  // grab the last 24B with loads that don't go past the end of the array (i.e. offset by -4)
  // Instead of using a 2nd shuffle mask to shuffle from these offset positions,
  // byte-shift each lane back down to the bottom of the 16B
  // Note that the shift count is a compile time constant: it's the amount of overlap that can vary

  // movq / pinsrd could be useful as a 12B load
  __m256i in    = _mm256_castsi128_si256( _mm_loadu_si128((__m128i*)(lastsrcvec-4)) );
  __m128i in_hi = _mm_loadu_si128((__m128i*)(lastsrcvec-4 + 12) );
  // byte shifting just the hi lane would mean the low lane wouldn't have to be offset
  // but then there'd have to be a load separate from the inserti128
  in = _mm256_inserti128_si256(in, in_hi, 1);  // [ ABC DEF GHI JKL XXXX | same ]

  in = _mm256_bsrli_epi128(in, 4);             // [ 0000 ABC DEF GHI JKL | same ]
  __m256i out   = _mm256_shuffle_epi8(in, byteshuf);

  dst -= (src - lastsrcvec) * 16 / 12;  // calculate the overlap
  // If the caller already needs to calculate dst_bytes, pass that instead of src_bytes
  // Because *3/4 is cheaper than *4/3
  _mm256_storeu_si256((__m256i*)dst, out);

  //return byteshuf;
}


// clang-3.8 miscompiles this to shuffle one shuffle mask with the other, and copy that constant to the whole dst
void expand12to16_doubleshuffle(char *restrict dst, const char *restrict src, size_t src_bytes) {

  assert(src_bytes >= 24);

  // setr: args in little-endian order
  const __m128i byteshuf128 = _mm_setr_epi8(0,1,1,2, 3,4,4,5, 6,7,7,8, 9,10,10,11);
                                            //0,1,1,2, 3,4,4,5, 6,7,7,8, 9,10,10,11);
  const __m256i byteshuf = _mm256_broadcastsi128_si256(byteshuf128);  // gcc is dumb and use a 128b load then vinserti128, instead of a vpbroadcast128i load :/

  // const __m256i lane_adjust_shuf = _mm256_setr_epi32(0,1,2,2, 3,4,5,5);
  // save some space by using a 8->32 pmovzx load.
  const __m256i lane_adjust_shuf = _mm256_cvtepu8_epi32(_mm_setr_epi8(0,1,2,2, 3,4,5,5,
               /* unused padding that isn't optimized away :( */      0,0,0,0, 0,0,0,0));

  const char *lastsrcvec = src + src_bytes - 24;
  for ( ; src < lastsrcvec ; dst += 32, src += 24 ){
    __m256i in    = _mm256_loadu_si256((__m256i*)(src+0));
            in    = _mm256_permutevar8x32_epi32(in, lane_adjust_shuf);
    __m256i out   = _mm256_shuffle_epi8(in, byteshuf);
    _mm256_storeu_si256((__m256i*)dst, out);
  }

  // Use the insert cleanup code because it's easier to load just the last 24B we want
  // slightly modified from the insert128 version to only load the last 24, not 28B
  __m256i in    = _mm256_castsi128_si256( _mm_loadu_si128((__m128i*)(lastsrcvec)) );
  __m128i in_hi = _mm_loadu_si128((__m128i*)(lastsrcvec-4 + 12) );
   // byte shift pshufd instead of bsrli, so the load can fold into it
                                                  // before:    [ LKJ IHG FED CBA XXXX ]
  in_hi = _mm_shuffle_epi32(in_hi, _MM_SHUFFLE(3,3,2,1));    // [ LKJI LKJ IHG FED CBA ]
  in = _mm256_inserti128_si256(in, in_hi, 1);

  __m256i out   = _mm256_shuffle_epi8(in, byteshuf);

  // see the full comments in the other version
  dst -= (src - lastsrcvec) * 16 / 12;  // calculate the overlap
  _mm256_storeu_si256((__m256i*)dst, out);

  //return byteshuf;
}

(clang bug report filed for the mis-compiled shuffles)

内部循环,from gcc 5.3 -O3 -march=haswell -masm=intel:

#insert version
.L4:
        vmovdqu xmm0, XMMWORD PTR [rsi]   #,* src
        add     rsi, 24   # src,
        vinserti128     ymm0, ymm0, XMMWORD PTR [rsi-12], 0x1     # tmp128, tmp124,
        add     rdi, 32   # dst,
        vpshufb ymm0, ymm0, ymm1  # tmp131, tmp128, tmp157
        vmovdqu YMMWORD PTR [rdi-32], ymm0        #, tmp131
        cmp     rax, rsi  # lastsrcvec, src
        ja      .L4 #,

7 个融合域 uops,应该 运行 每 2 个时钟迭代一次。 (即每个周期存储 16B)。展开可以更快。

#double-shuffle version
.L16:
        vpermd  ymm0, ymm2, YMMWORD PTR [rsi]       # tmp126, D.27207,* src
        add     rsi, 24   # src,
        vpshufb ymm0, ymm0, ymm1  # tmp127, tmp126, D.27202
        add     rdi, 32   # dst,
        vmovdqu YMMWORD PTR [rdi-32], ymm0        #, tmp127
        cmp     rax, rsi  # lastsrcvec, src
        ja      .L16        #,

6 个融合域 uops,还应该 运行 每 2 个时钟迭代一次。但是,由于洗牌端口瓶颈,这是它所能达到的最快速度。如果您 打算展开,我会测试两者,但我怀疑这个会很好。

继 Peter 的解决方案之后,对于 AVX2,您似乎可以通过抵消 32B 负载达到 32B/​​周期(输出字节),因此 16B 边界落在正确的位置,在两组 12 字节之间:

例如:

byte: 0123456789012345|0123456789012345
load: xxxxAAABBBCCCDDD|EEEFFFGGGHHHxxxx 
pshuf AAAABBBBCCCCDDDD|EEEEFFFFGGGGHHHH

现在不需要跨车道移动,所以通过相同的展开原始 SSE3 解决方案,我认为您很容易达到 32 字节 - 除非缓存行交叉未对齐的访问对您造成太大伤害。