【问题标题】:Unpack m128i/m256i to m64 (MMX, SSE2, AVX2)将 m128i/m256i 解压为 m64(MMX、SSE2、AVX2)
【发布时间】:2017-09-04 13:44:06
【问题描述】:

我的记忆是这样组织的:

block1(m64), block2(m64), block3(m64), block4(m64), ....

现在我在 for 循环中执行此操作:

迭代 1 .....

    x = block1 XOR block2
    y = block1 AND block2
    block1 = x
    block2 = y

迭代2.....

    x = block3 XOR block4
    y = block3 AND block4
    block3 = x
    block4 = y

等等……

我现在尝试将 m64 块合并到 m128i 块:

block1_block3(m128), block2_block4(m128),....

现在我可以使用 128 位 SIMD 指令,而 for 循环将只占 m64 指令的 50%。

但不好的是我不能将内存直接转换为 m128i/m256i,因为 m64 值不在一行中。所以我需要像这样组装和分解这些值:

// combine two 128 bit to one 256 bit nummber
__m256i static inline iCombine_128_256(__m128i *a, __m128i *b)
{
  __m256i ret = _mm256_castsi128_si256(*a);
  return _mm256_inserti128_si256(ret, *b, 1);
}

// combine four 64 bit to one 256 bit nummber
__m256i static inline iCombine_64_256(__m64 *a, __m64 *b, __m64 *c, __m64 *d)
{
  __m256i ret = _mm256_castsi128_si256(_mm_set_epi64(*b, *a));
  return _mm256_inserti128_si256(ret, _mm_set_epi64(*d, *c), 1);
}

// combine eight 32 bit to one 256 bit nummber
__m256i static inline iCombine_32_256(unsigned int *a, unsigned int *b, unsigned int *c, unsigned int *d, unsigned int *e, unsigned int *f, unsigned int *g, unsigned int *h)
{
  __m256i ret = _mm256_castsi128_si256(_mm_set_epi32(*d, *c, *b, *a));
  return _mm256_inserti128_si256(ret, _mm_set_epi32(*h, *g, *f, *e), 1);
}

所以这将需要一些额外的指令来组装这些块。 难道没有办法“欺骗”m256i吗?假设我告诉 x.m256i_u64[0] 第一个 block1 的指针,x.m256i_u64[1] 第二个指针离开 block2,... 总而言之,它向我展示了这 4 个 m64 值的组装 m256i 值? 这有可能吗?

【问题讨论】:

    标签: x86 sse simd intrinsics avx2


    【解决方案1】:

    _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&amp;x = xx ^ 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 存储时并没有太多优势。

    【讨论】:

    • 噗。很多东西要学!我尝试了 AVX2 版本,但它看起来没有速度改进 - 速度相同或稍慢。但是我使用的是 _mm256_unpacklo_epi64 而不是 _mm256_blend_epi32,然后结果是正确的。
    • @Portisch:那么,您错过了 AVX2 策略的主要优势之一; _mm256_blend_epi32 的吞吐量比 _mm256_unpacklo_epi64 好得多,并且不与其他 shuffle 竞争。您还必须展开循环,或者让编译器使用-funroll-loops 或其他东西来执行此操作。 (或者甚至可能是 -funroll-all-loops 用于包含该函数的文件;通常您通常不希望这样做,因为它也会通过展开不重要的循环而使您的代码膨胀。)
    • 如果您的数据是 16B 对齐但不是 32B 对齐的,您可能需要做一对 16B,以便主循环执行 32B 对齐的加载/存储。
    • @Portisch:更新为 SSE2 手动矢量化版本,节省了大量指令。周末我有一些停机时间,让我烦恼的是 AVX2 在这方面比 SSE2 效率高得多,最终我想出了一种方法来利用 &amp;^ 的性质来减少洗牌.
    猜你喜欢
    • 1970-01-01
    • 2019-03-15
    • 2019-12-09
    • 2020-02-29
    • 2013-07-24
    • 2012-11-25
    • 2013-03-17
    • 2021-07-15
    相关资源
    最近更新 更多