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

时间:2016-03-31 07:40:23

标签: x86 vectorization simd sse2 avx2

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

ABC DEF GHI JKL

变为

ABBC DEEF GHHI JKKL

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

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

2 个答案:

答案 0 :(得分:9)

到目前为止,您最好的选择是使用字节随机播放(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 uops,因此需要循环展开以维持每个时钟16B存储。
  • double-shuffle:32B load / vpermd在通道/ 32B vpshufb / 32B存储之间移动字节。应该在没有展开的情况下使shuffle端口饱和,每2个时钟维持一个32B存储。 (这有助于vpermd可以作为加载和随机播放,节省了uops。)
  • inserti128:两个16B加载/ 32B vpshufb / 32B存储。可以更快,但需要大量展开(以及清理代码)。
  • 使用对齐的负载,以便不需要跨站数据移动。 (在缓冲区的开头需要特殊情况)。见BeeOnRope的答案;这显然是最好的方法,只需要一个vpshufb ymm,因此它会淘汰这个答案的其余部分。无论如何,我们需要做不对齐的载荷。

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

SnB / IvB可以进行128b shuffle,每0.5c吞吐量一次,但由于它们只有16B数据路径到L1缓存,你可能只需要它们(和AMD Bulldozer系列)最大化它们的存储吞吐量非AVX版本。它们支持AVX1但不支持AVX2。 不要打扰制作AVX1版本;在SSSE3版本上没有什么可以获得的,除了可能在某个地方避免vzeroupper。 (在存储之前,您可以将两个128b shuffle结果与vinsertf128结合起来,这可能是一个很小的优势。)

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

double-shuffle 也更加超线程友好,因为它运行的uops更少。在这种情况下,它可能仍然可以从小的展开中减少循环开销,因此当它只有前端/发出周期的一半时,它仍然可以使shuffle端口饱和。它还增加了无序窗口:〜相同数量的飞行中加载和存储正在访问两倍的内存。这可能有助于减少缓存未命中的管道气泡,但可能对这样的顺序访问几乎没有影响。高速缓存行交叉32B加载/存储可能比16B更糟。 (对齐输出缓冲区是一个非常好的主意,并确保输入缓冲区至少是4B对齐的。)

vinserti128版本:

诀窍是带有内存源的vinserti128不需要shuffle端口:任何ALU端口都可以。因此理论上我们可以在每个周期内完成两个重叠的16B负载和一个32B存储。 Haswell / Skylake在实践中无法维持这一点,因为有些商店会在端口2或3上运行AGU uop,而不是端口7专用商店AGU。英特尔的优化手册(在第2.1.3节,参见标签wiki中的链接)给出了Skylake上L1,L2等的峰值与持续吞吐量的表格。 Skylake只能维持~81B /周期的L1D缓存,而不是每个时钟96B的峰值(2个负载和一个存储)。我认为有些商店会从负载中窃取执行端口,因此即使我们的负载仅为16B,这也会影响我们。

另一个主要问题:4个融合域uops每时钟流水线宽度:vinserti128是2个融合域uops,因此vmovdqu(16B load)/ vinserti128 y,y,m,i / {{ 1}} / vpshufb(32B存储)已经有5个uop而没有考虑循环开销。因此,即使有大量展开,我们能做的最好的事情就是保持shuffle和加载/存储端口4/5占用。这仅仅略低于每时钟81B的瓶颈,所以这可能根本不会发挥作用。尽管如此,近32B * 4 / 5c仍然是16B / c的稳固胜利。

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

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

代码:

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

您希望整理这些并清理清理代码以符合您的要求。可能还会对两个版本进行基准测试(如果编写非AVX版本,则为三个版本)。

请参阅godbolt compiler explorer上的代码和asm:

vinserti128

clang bug report filed for the mis-compiled shuffles

内圈from gcc 5.3 -O3 -march=haswell -masm=intel

#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;
}

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

#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 #,

6个融合域uops,每2个时钟也应运行一次迭代。然而,这是因为洗牌端口瓶颈所能达到的速度。如果您要展开,我会测试两者,但我怀疑这个会很好。

答案 1 :(得分:2)

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

例如:

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

现在不需要进行车道交叉移动,因此在展开原始SSE3解决方案的同时,我认为你很容易达到32个字节 - 除非缓存线交叉错位访问对你造成太大伤害。