AVX2 基于面具的最有效打包方式是啥?

Posted

技术标签:

【中文标题】AVX2 基于面具的最有效打包方式是啥?【英文标题】:AVX2 what is the most efficient way to pack left based on a mask?AVX2 基于面具的最有效打包方式是什么? 【发布时间】:2016-04-29 07:30:10 【问题描述】:

如果您有一个输入数组和一个输出数组,但您只想编写那些通过特定条件的元素,那么在 AVX2 中执行此操作的最有效方法是什么?

我在 SSE 中看到它是这样完成的: (来自:https://deplinenoise.files.wordpress.com/2015/03/gdc2015_afredriksson_simd.pdf)

__m128i LeftPack_SSSE3(__m128 mask, __m128 val)

 // Move 4 sign bits of mask to 4-bit integer value.
 int mask = _mm_movemask_ps(mask);
 // Select shuffle control data
 __m128i shuf_ctrl = _mm_load_si128(&shufmasks[mask]);
 // Permute to move valid values to front of SIMD register
 __m128i packed = _mm_shuffle_epi8(_mm_castps_si128(val), shuf_ctrl);
 return packed;

这对于 4 宽的 SSE 似乎很好,因此只需要 16 个条目 LUT,但对于 8 宽的 AVX,LUT 变得非常大(256 个条目,每个 32 字节或 8k)。

我很惊讶 AVX 似乎没有简化此过程的说明,例如带包装的屏蔽商店。

我认为通过一些位改组来计算左侧设置的符号位#,您可以生成必要的置换表,然后调用_mm256_permutevar8x32_ps。但这也是我认为的相当多的指令..

有谁知道用 AVX2 做到这一点的任何技巧?或者什么是最有效的方法?

以下是上述文档中左包装问题的说明:

谢谢

【问题讨论】:

您可以使用VGATHERDPS,假设 src 在内存中。在此之前,您必须从掩码创建适当的索引。 这比你想象的还要糟糕。 AVX2 256 位 VPSHUFB 指令无法在 128 位向量通道之间移动数据。您需要 vpermd 来执行此操作,这需要第二个查找表。 @EOF:感谢您的重要补充。值得注意的是,VPSHUFB, (scroll down to 'VEX.256 encoded version') 不是对 256 位向量进行操作,而是对 YMM 中的两个单独的 128 位向量进行操作。英特尔 ISA 中的另一个主要不一致。 @zx485:我不得不在“不一致”上不同意你的观点。单独的 AVX 通道实际上是相当一致的,很少有可以跨越它们的指令被明确记录。此外,还有什么其他 ISA 甚至提供 256 位向量?是的,兼容性是有代价的,但 AVX2 是一个非常好的向量指令集。 @EOF:我也不得不不同意你之前的阐述,但从我/另一个角度来看。由于 legacy 优于 legacy,英特尔 ISA 高度分散。恕我直言,彻底清理将是有益的。英特尔在 IA-64 上尝试过,但方式很奇怪。几天前,我读到了Agner Fog 的帖子,其中他解释了 x86/64 架构激增的不一致之处,标题为“……向前迈出了一大步——但重复过去的错误!”。 【参考方案1】:

AVX2 + BMI2。请参阅我对 AVX512 的其他答案。 (更新:在 64 位版本中保存了 pdep。)

我们可以使用AVX2 vpermps (_mm256_permutevar8x32_ps)(或整数等价物,vpermd)来进行车道交叉可变洗牌。

我们可以动态生成掩码,因为 BMI2 pext (Parallel Bits Extract) 为我们提供了所需操作的按位版本。

请注意 pdep/pext 在 Zen 3 之前的 AMD CPU 上非常很慢,例如 Ryzen Zen 1 和 Zen 2 上的 6 uops / 18 周期延迟和吞吐量强>。此实现将在那些 AMD CPU 上执行得非常糟糕。对于 AMD,您最好使用 pshufbvpermilps LUT 或 cmets 中讨论的一些 AVX2 可变移位建议来使用 128 位向量。特别是如果您的掩码输入是矢量掩码(不是内存中已打包的位掩码)。

Zen2 之前的 AMD 反正只有 128 位向量执行单元,而且 256 位车道交叉洗牌很慢。所以 128 位向量在 Zen 1 上非常有吸引力。但是 Zen 2 有 256 位加载/存储和执行单元。 (而且微编码 pext/pdep 仍然很慢。)


对于具有 32 位或更宽元素的整数向量:1) _mm256_movemask_ps(_mm256_castsi256_ps(compare_mask))。 或者 2) 使用_mm256_movemask_epi8,然后将第一个 PDEP 常量从 0x0101010101010101 更改为 0x0F0F0F0F0F0F0F0F 以分散 4 个连续位的块。将乘以 0xFFU 更改为 expanded_mask |= expanded_mask<<4;expanded_mask *= 0x11;(未测试)。无论哪种方式,使用带有 VPERMD 而不是 VPERMPS 的 shuffle 掩码。

对于 64 位整数或 double 元素,一切仍然正常;比较掩码恰好总是具有相同的 32 位元素对,因此生成的 shuffle 将每个 64 位元素的两半放在正确的位置。 (所以您仍然使用 VPERMPS 或 VPERMD,因为 VPERMPD 和 VPERMQ 仅适用于直接控制操作数。)

对于 16 位元素,您也许可以使用 128 位向量进行调整。

对于 8 位元素,请参阅 Efficient sse shuffle mask generation for left-packing byte elements 了解另一种技巧,将结果存储在多个可能重叠的块中。


算法:

从一个压缩的 3 位索引常量开始,每个位置都有自己的索引。即[ 7 6 5 4 3 2 1 0 ],其中每个元素为 3 位宽。 0b111'110'101'...'010'001'000.

使用pext 将我们想要的索引提取到整数寄存器底部的连续序列中。例如如果我们想要索引 0 和 2,我们的 pext 的控制掩码应该是 0b000'...'111'000'111pext 将抓取与选择器中的 1 位对齐的 010000 索引组。选定的组被打包到输出的低位中,因此输出将为0b000'...'010'000。 (即[ ... 2 0 ]

有关如何从输入向量掩码生成pext0b111000111 输入,请参阅注释代码。

现在我们与压缩 LUT 在同一条船上:解压缩多达 8 个压缩索引。

当你把所有的部分放在一起时,总共有三个pext/pdeps。我从我想要的东西向后工作,所以在那个方向上也可能最容易理解它。 (即从 shuffle 行开始,然后从那里向后工作。)

如果我们使用每个字节一个索引而不是打包的 3 位组,我们可以简化解包。由于我们有 8 个索引,这仅适用于 64 位代码。

见this and a 32bit-only version on the Godbolt Compiler Explorer。我使用了#ifdefs,因此它可以与-m64-m32 进行最佳编译。 gcc 浪费了一些指令,但是 clang 编写了非常好的代码。

#include <stdint.h>
#include <immintrin.h>

// Uses 64bit pdep / pext to save a step in unpacking.
__m256 compress256(__m256 src, unsigned int mask /* from movmskps */)

  uint64_t expanded_mask = _pdep_u64(mask, 0x0101010101010101);  // unpack each bit to a byte
  expanded_mask *= 0xFF;    // mask |= mask<<1 | mask<<2 | ... | mask<<7;
  // ABC... -> AAAAAAAABBBBBBBBCCCCCCCC...: replicate each bit to fill its byte

  const uint64_t identity_indices = 0x0706050403020100;    // the identity shuffle for vpermps, packed to one index per byte
  uint64_t wanted_indices = _pext_u64(identity_indices, expanded_mask);

  __m128i bytevec = _mm_cvtsi64_si128(wanted_indices);
  __m256i shufmask = _mm256_cvtepu8_epi32(bytevec);

  return _mm256_permutevar8x32_ps(src, shufmask);

这将编译为没有从内存加载的代码,只有立即常量。 (有关此版本和 32 位版本,请参见 godbolt 链接)。

    # clang 3.7.1 -std=gnu++14 -O3 -march=haswell
    mov     eax, edi                   # just to zero extend: goes away when inlining
    movabs  rcx, 72340172838076673     # The constants are hoisted after inlining into a loop
    pdep    rax, rax, rcx              # ABC       -> 0000000A0000000B....
    imul    rax, rax, 255              # 0000000A0000000B.. -> AAAAAAAABBBBBBBB..
    movabs  rcx, 506097522914230528
    pext    rax, rcx, rax
    vmovq   xmm1, rax
    vpmovzxbd       ymm1, xmm1         # 3c latency since this is lane-crossing
    vpermps ymm0, ymm1, ymm0
    ret

(后来 clang 像 GCC 一样编译,用 mov/shl/sub 代替 imul,见下文。)

因此,根据Agner Fog's numbers 和https://uops.info/,这是 6 uop(不包括常量,或内联时消失的零扩展 mov)。在 Intel Haswell 上,延迟为 16c(vmovq 为 1,每个 pdep/imul/pext / vpmovzx / vpermps 为 3)。没有指令级并行性。但是,在一个不属于循环携带依赖项的循环中(就像我在 Godbolt 链接中包含的那个),瓶颈希望只是吞吐量,同时保持多次迭代。

这可以管理每 4 个周期 1 个的吞吐量,在端口 1 上为 pdep/pext/imul 加上循环中的 popcnt 造成瓶颈。当然,由于加载/存储和其他循环开销(包括比较和 movmsk),总 uop 吞吐量也很容易成为问题。

例如我的godbolt链接中的过滤器循环是14 uops,带有clang,-fno-unroll-loops使其更易于阅读。如果幸运的话,它可能会维持每 4c 一次迭代,跟上前端。

clang 6 和更早的版本使用 popcnt's false dependency on its output 创建了一个循环携带的依赖项,因此它将在 compress256 函数延迟的 3/5 处成为瓶颈。 clang 7.0 及更高版本使用 xor-zeroing 来打破错误依赖(而不是仅使用 popcnt edx,edx 或类似 GCC 的东西:/)。

gcc(以及后来的 clang)使用多条指令进行乘以 0xFF,使用左移 8 和 sub,而不是 imul 乘 255。这需要 3 总 uops,而前面需要 1-结束,但延迟只有 2 个周期,低于 3。(Haswell 在寄存器重命名阶段以零延迟处理 mov。)最重要的是,imul 只能在端口 1 上运行,与 pdep/pext 竞争/popcnt,所以最好避免这个瓶颈。


由于所有支持 AVX2 的硬件也支持 BMI2,因此提供没有 BMI2 的 AVX2 版本可能没有意义。

如果您需要在一个很长的循环中执行此操作,那么如果初始缓存未命中在足够多的迭代中分摊,并且仅解包 LUT 条目的开销较低,那么 LUT 可能是值得的。你仍然需要movmskps,所以你可以弹出掩码并将其用作LUT索引,但你保存了一个pdep/imul/pexp。

您可以使用我使用的相同整数序列解压缩 LUT 条目,但是当 LUT 条目在内存中开始并且不需要进入整数寄存器时,@Froglegs 的 set1() / vpsrlvd / vpand 可能更好首先。 (32 位广播负载不需要 Intel CPU 上的 ALU uop)。但是,Haswell 上的可变移位是 3 微秒(但 Skylake 上只有 1 微秒)。

【讨论】:

我在 haswell 上测试过,效果很好,干得好!唯一的问题是,出于某种原因,MSVC 上的 _pdep_u64 和 _mm_cvtsi64_si128 仅在为 x64 编译时可用。它们在 32 位版本中得到定义。 恭喜在没有硬件的情况下做到这一点。我很惊讶你没有收到超过两票(来自 OP 和我)。我使用指令 LUT 添加了答案。你觉得这个解决方案怎么样?也许这是个坏主意。 @Christoph :更正:在 Skylake 上 vpand 具有延迟 1 和吞吐量 1/3。请注意,vpsrlvd 在 Haswell 上非常慢:延迟 2 和吞吐量 2。因此,在 Haswell 上,您的解决方案会更快。 @wim:我认为 AMD 的新 Zen 仍然有 128b 向量执行单元(所以 256b 操作有一半的吞吐量)。如果pdep 在 Zen 上速度很快,那么在标量整数中做更多的事情将是一个胜利。 (它受支持,但我认为还没有延迟数字)。我认为这里的整体吞吐量应该比延迟更重要,因为循环携带的依赖项仅在 popcnt 及其输入上。感谢vpmovmskb 的想法;我会在某个时候更新我的答案。 (或者您可以随意添加一个段落和一个神螺栓链接到答案;我可能不会很快回到这个问题)。 @PeterCordes : This 网页列出了 AMD Ryzen/Zen CPU 的延迟和吞吐量数字。这些数字非常有趣。例如:vpand 指令的延迟和吞吐量为 ymm(256 位)操作数为 1c 和 0.5c,我认为这对于没有 256 位执行单元的处理器来说是相当惊人的。另一方面,pextpdep 指令都有 L=18c 和 T=18c....vpsrlvd 指令:L=T=4c。【参考方案2】:

查看我对没有 LUT 的 AVX2+BMI2 的其他答案。

既然您提到了对 AVX512 的可扩展性的担忧:别担心,有一条 AVX512F 指令可以解决这个问题

VCOMPRESSPS — Store Sparse Packed Single-Precision Floating-Point Values into Dense Memory。 (还有双精度、32 位或 64 位整数元素 (vpcompressq) 的版本,但不包括字节或字(16 位))。类似于 BMI2 pdep / pext,但用于向量元素而不是整数 reg 中的位。

目标可以是向量寄存器或内存操作数,而源是向量和掩码寄存器。使用寄存器 dest,它可以合并或归零高位。使用内存 dest,“仅将连续向量写入目标内存位置”。

要确定指针向前移动多远指向下一个向量,请弹出掩码。

假设您想从数组中过滤掉除值 >= 0 之外的所有内容:

#include <stdint.h>
#include <immintrin.h>
size_t filter_non_negative(float *__restrict__ dst, const float *__restrict__ src, size_t len) 
    const float *endp = src+len;
    float *dst_start = dst;
    do 
        __m512      sv  = _mm512_loadu_ps(src);
        __mmask16 keep = _mm512_cmp_ps_mask(sv, _mm512_setzero_ps(), _CMP_GE_OQ);  // true for src >= 0.0, false for unordered and src < 0.0
        _mm512_mask_compressstoreu_ps(dst, keep, sv);   // clang is missing this intrinsic, which can't be emulated with a separate store

        src += 16;
        dst += _mm_popcnt_u64(keep);   // popcnt_u64 instead of u32 helps gcc avoid a wasted movsx, but is potentially slower on some CPUs
     while (src < endp);
    return dst - dst_start;

这会编译(使用 gcc4.9 或更高版本)为 (Godbolt Compiler Explorer):

 # Output from gcc6.1, with -O3 -march=haswell -mavx512f.  Same with other gcc versions
    lea     rcx, [rsi+rdx*4]             # endp
    mov     rax, rdi
    vpxord  zmm1, zmm1, zmm1             # vpxor  xmm1, xmm1,xmm1 would save a byte, using VEX instead of EVEX
.L2:
    vmovups zmm0, ZMMWORD PTR [rsi]
    add     rsi, 64
    vcmpps  k1, zmm0, zmm1, 29           # AVX512 compares have mask regs as a destination
    kmovw   edx, k1                      # There are some insns to add/or/and mask regs, but not popcnt
    movzx   edx, dx                      # gcc is dumb and doesn't know that kmovw already zero-extends to fill the destination.
    vcompressps     ZMMWORD PTR [rax]k1, zmm0
    popcnt  rdx, rdx
    ## movsx   rdx, edx         # with _popcnt_u32, gcc is dumb.  No casting can get gcc to do anything but sign-extend.  You'd expect (unsigned) would mov to zero-extend, but no.
    lea     rax, [rax+rdx*4]             # dst += ...
    cmp     rcx, rsi
    ja      .L2

    sub     rax, rdi
    sar     rax, 2                       # address math -> element count
    ret

性能:256 位向量在 Skylake-X / Cascade Lake 上可能更快

理论上,加载位图并将一个数组过滤到另一个数组的循环应该在 SKX / CSLX 上以每 3 个时钟 1 个向量运行,无论向量宽度如何,在端口 5 上会出现瓶颈。(kmovb/w/d/q k1, eax 在 p5 上运行,并且根据 IACA 和 http://uops.info/ 的测试,vcompressps 进入内存是 2p5 + 存储。

@ZachB 在 cmets 中报告说,在实践中,在实际 CSLX 硬件上使用 ZMM _mm512_mask_compressstoreu_ps 的循环比 _mm256_mask_compressstoreu_ps 稍慢。(我不确定这是否是一个微基准测试这将允许 256 位版本退出“512 位矢量模式”并提高时钟频率,或者如果周围有 512 位代码。)

我怀疑未对齐的存储正在损害 512 位版本。 vcompressps 可能有效地执行了一个屏蔽的 256 位或 512 位矢量存储,如果它跨越了缓存行边界,那么它必须做额外的工作。由于输出指针通常不是 16 个元素的倍数,因此全行 512 位存储几乎总是会错位。

由于某种原因,未对齐的 512 位存储可能比缓存行拆分的 256 位存储更糟糕,而且发生得更频繁;我们已经知道,其他事物的 512 位矢量化似乎对对齐更加敏感。这可能只是因为每次都发生拆分加载缓冲区时用完,或者处理缓存行拆分的回退机制对于 512 位向量的效率较低。

vcompressps 基准测试到寄存器中会很有趣,具有单独的全向量重叠存储。这可能是相同的微指令,但是当它是一个单独的指令时,存储可以微融合。如果蒙面商店与重叠商店之间存在一些差异,这将揭示它。


下面 cmets 中讨论的另一个想法是使用 vpermt2ps 为对齐的存储构建完整向量。这个would be hard to do branchlessly 和填充向量时的分支可能会错误预测,除非位掩码具有非常规则的模式,或者全0 和全1 的大量运行。

一个无分支的实现是可能的,它在正在构建的向量中具有 4 或 6 个循环的循环携带依赖链,使用 vpermt2ps 和混合或其他东西在它“满”时替换它。每次迭代都使用对齐的向量存储,但仅在向量已满时移动输出指针。

这可能比当前 Intel CPU 上未对齐存储的 vcompressps 慢。

【讨论】:

您的 AVX2 版本在使用 GCC8.2 的 CSL 上比此版本的基准测试速度 (~3%)。那里的工作令人印象深刻。 (AVX2 版本的运行速度也比 SSE2 LUT 版本快约 4.52 倍。) 对不清楚的 cmets 感到抱歉。在 SKL 上,您的 AVX2 pdep/pext/shuf 比 @ZBoson 的 SSE2 LUT 版本快约 4.5 倍。在 SKX 和 CLX 上,这个 512 位 vcompressps 版本比在相同芯片上运行的 pdep/pext/shuf 慢约 3%。由于 pdep/pext/shuf 版本稍快一些,我认为这意味着它没有内存瓶颈。我在 SKX/CLX 上没有 PMU 访问权限。在 CLX 上,256 位 vcompressps 比 512 位 vcompressps 快约 10%;比 pdep/pex/shuf 快约 6%。 @ZachB:我通过他的博客 (agner.org/optimize/blog/read.php?i=962) 向 Agner 发送了一条关于该错误的消息,因此它应该在表格的下一个修订版中得到修复。 uops.info/html-lat/SKX/… 具有从向量到结果 (3c) 和从掩码到结果 (6c) 的 SKX 延迟,以及在他们的表中的实际测量值 + IACA 输出。 Memory-destination vcompressps 和我猜的一样是 4 uop,没有存储的微融合。 @ZachB:我认为 AVX2 关于使用可变移位的一些建议 do 适用于掩码位图,而不是矢量比较掩码。您可以使用广播 + 变量移位廉价地从位图转到矢量,例如_mm256_set1_epi32(mask[i]) 然后可变移位以将适当的位作为每个元素的高位。或者使用 AVX512,vpmovm2d。但是你需要在k 寄存器中的每个掩码块,并且加载到k 寄存器中是昂贵的。广播加载 32 位掩码然后转移多种方式更便宜。 @PeterCordes 哦,好主意——我实际上是在最后一次迭代中使用广播+可变移位技术为vmaskmovps 制作掩码,没想过将其应用于早期厘米。 -- 在vcompressps 上,我使用的是 256b ops b/c,它比 512b 略快;所以movzx eax, byte [rdi]kmovb k1, eax。 godbolt.org/z/BUw7XL 是我最快的 AVX2 和 AVX512。展开 2x 或 4x 对 AVX2 没有帮助,在 p1 和 p5 上仍然存在瓶颈。在 CLX/SKX 上没有 PMU 访问权限,但那里也没有可测量的时差。【参考方案3】:

如果您的目标是 AMD Zen,则可能首选此方法,因为 ryzen 上的 pdepand pext 非常慢(每个 18 个周期)。

我想出了这个方法,它使用一个压缩的 LUT,它是 768(+1 填充)字节,而不是 8k。它需要广播单个标量值,然后在每个通道中移动不同的量,然后掩码到低 3 位,从而提供 0-7 LUT。

这是内部版本,以及构建 LUT 的代码。

//Generate Move mask via: _mm256_movemask_ps(_mm256_castsi256_ps(mask)); etc
__m256i MoveMaskToIndices(u32 moveMask) 
    u8 *adr = g_pack_left_table_u8x3 + moveMask * 3;
    __m256i indices = _mm256_set1_epi32(*reinterpret_cast<u32*>(adr));//lower 24 bits has our LUT

   // __m256i m = _mm256_sllv_epi32(indices, _mm256_setr_epi32(29, 26, 23, 20, 17, 14, 11, 8));

    //now shift it right to get 3 bits at bottom
    //__m256i shufmask = _mm256_srli_epi32(m, 29);

    //Simplified version suggested by wim
    //shift each lane so desired 3 bits are a bottom
    //There is leftover data in the lane, but _mm256_permutevar8x32_ps  only examines the first 3 bits so this is ok
    __m256i shufmask = _mm256_srlv_epi32 (indices, _mm256_setr_epi32(0, 3, 6, 9, 12, 15, 18, 21));
    return shufmask;


u32 get_nth_bits(int a) 
    u32 out = 0;
    int c = 0;
    for (int i = 0; i < 8; ++i) 
        auto set = (a >> i) & 1;
        if (set) 
            out |= (i << (c * 3));
            c++;
        
    
    return out;

u8 g_pack_left_table_u8x3[256 * 3 + 1];

void BuildPackMask() 
    for (int i = 0; i < 256; ++i) 
        *reinterpret_cast<u32*>(&g_pack_left_table_u8x3[i * 3]) = get_nth_bits(i);
    

这是由 MSVC 生成的程序集:

  lea ecx, DWORD PTR [rcx+rcx*2]
  lea rax, OFFSET FLAT:unsigned char * g_pack_left_table_u8x3 ; g_pack_left_table_u8x3
  vpbroadcastd ymm0, DWORD PTR [rcx+rax]
  vpsrlvd ymm0, ymm0, YMMWORD PTR __ymm@00000015000000120000000f0000000c00000009000000060000000300000000
  

【讨论】:

我的意思是,用英特尔非常长的函数名称以无聊/烦人的方式编写它会使其成为一个更好的答案,因为它可以更清楚地确切地采取了哪些步骤。我认为您的 LUT 将随机掩码打包成 3 个字节。然后你用pmovzx或其他东西解压,然后vpsrlv,然后屏蔽掉每个元素中的高垃圾?还是广播一个 32b 元素,然后使用变量移位提取八个 3b 元素?我认为是后者。随意复制/粘贴我对您所做工作的文字描述。 是的,也许我应该将它与原始内在函数一起发布,然后我将其转换并再次发布。我也可以发布表格生成代码 我发布了原始内部代码和 LUT 生成代码。是的,我广播了 1 个 32 位整数,但只使用它的低 24 位。每 3 位包含要从 (0-7) 加载的索引。 @Froglegs:我认为您可以使用单个_mm256_srlv_epi32 而不是_mm256_sllv_epi32_mm256_srli_epi32,因为您只需要在正确的位置使用3 位(每个元素),因为@ 987654328@不关心高29位的垃圾。 您好,谢谢您的提示。你是正确的,只有低 3 位很重要,我已经更新了帖子,所以它显示了你的建议。【参考方案4】:

将为@PeterCordes 的精彩回答添加更多信息:https://***.com/a/36951611/5021064。

我用它为整数类型实现了std::remove from C++ standard。该算法一旦可以进行压缩,就相对简单:加载寄存器,压缩,存储。首先,我将展示变化,然后展示基准。

我最终对提议的解决方案提出了两个有意义的变化:

    __m128i 寄存器,任何元素类型,使用_mm_shuffle_epi8 指令 __m256i寄存器,元素类型至少4字节,使用_mm256_permutevar8x32_epi32

当 256 位寄存器的类型小于 4 字节时,我将它们分成两个 128 位寄存器并分别压缩/存储。

链接到编译器资源管理器,您可以在其中查看完整的程序集(底部有 using typewidth(每个包中的元素),您可以插入它们以获得不同的变体):https://gcc.godbolt.org/z/yQFR2t 注意:我的代码使用 C++17 并使用自定义 simd 包装器,所以我不知道它的可读性如何。如果您想阅读我的代码-> 大部分代码都在顶部的链接后面,包括在 Godbolt 上。或者,所有代码都在github。

两种情况下@PeterCordes 答案的实现

注意:与掩码一起,我还使用 popcount 计算剩余元素的数量。也许有一种情况是不需要的,但我还没有看到。

_mm_shuffle_epi8 的掩码

    将每个字节的索引写入半字节:0xfedcba9876543210 将索引对放入打包到__m128i 中的 8 个短裤中 使用x &lt;&lt; 4 | x &amp; 0x0f0f 将它们展开

传播索引的示例。假设选择了第 7 个和第 6 个元素。 这意味着相应的短路将是:0x00fe。在&lt;&lt; 4| 之后,我们会得到0x0ffe。然后我们清除第二个f

完整的掩码代码:

// helper namespace
namespace _compress_mask 

// mmask - result of `_mm_movemask_epi8`, 
// `uint16_t` - there are at most 16 bits with values for __m128i. 
inline std::pair<__m128i, std::uint8_t> mask128(std::uint16_t mmask) 
    const std::uint64_t mmask_expanded = _pdep_u64(mmask, 0x1111111111111111) * 0xf;

    const std::uint8_t offset = 
        static_cast<std::uint8_t>(_mm_popcnt_u32(mmask));  // To compute how many elements were selected

    const std::uint64_t compressed_idxes = 
        _pext_u64(0xfedcba9876543210, mmask_expanded); // Do the @PeterCordes answer

    const __m128i as_lower_8byte = _mm_cvtsi64_si128(compressed_idxes); // 0...0|compressed_indexes
    const __m128i as_16bit = _mm_cvtepu8_epi16(as_lower_8byte);         // From bytes to shorts over the whole register
    const __m128i shift_by_4 = _mm_slli_epi16(as_16bit, 4);             // x << 4
    const __m128i combined = _mm_or_si128(shift_by_4, as_16bit);        // | x
    const __m128i filter = _mm_set1_epi16(0x0f0f);                      // 0x0f0f
    const __m128i res = _mm_and_si128(combined, filter);                // & 0x0f0f

    return res, offset;


  // namespace _compress_mask

template <typename T>
std::pair<__m128i, std::uint8_t> compress_mask_for_shuffle_epi8(std::uint32_t mmask) 
     auto res = _compress_mask::mask128(mmask);
     res.second /= sizeof(T);  // bit count to element count
     return res;

_mm256_permutevar8x32_epi32 的掩码

这几乎是一对一的 @PeterCordes 解决方案 - 唯一的区别是 _pdep_u64 位(他建议将此作为注释)。

我选择的掩码是0x5555'5555'5555'5555。这个想法是 - 我有 32 位 mmask,8 个整数中的每一个都有 4 位。我想要得到 64 位 => 我需要将 32 位的每一位转换为 2 => 因此 0101b = 5。乘数也从 0xff 变为 3,因为我将得到每个整数的 0x55,而不是 1。

完整的掩码代码:

// helper namespace
namespace _compress_mask 

// mmask - result of _mm256_movemask_epi8
inline std::pair<__m256i, std::uint8_t> mask256_epi32(std::uint32_t mmask) 
    const std::uint64_t mmask_expanded = _pdep_u64(mmask, 0x5555'5555'5555'5555) * 3;

    const std::uint8_t offset = static_cast<std::uint8_t(_mm_popcnt_u32(mmask));  // To compute how many elements were selected

    const std::uint64_t compressed_idxes = _pext_u64(0x0706050403020100, mmask_expanded);  // Do the @PeterCordes answer

    // Every index was one byte => we need to make them into 4 bytes
    const __m128i as_lower_8byte = _mm_cvtsi64_si128(compressed_idxes);  // 0000|compressed indexes
    const __m256i expanded = _mm256_cvtepu8_epi32(as_lower_8byte);  // spread them out
    return expanded, offset;


  // namespace _compress_mask

template <typename T>
std::pair<__m256i, std::uint8_t> compress_mask_for_permutevar8x32(std::uint32_t mmask) 
    static_assert(sizeof(T) >= 4);  // You cannot permute shorts/chars with this.
    auto res = _compress_mask::mask256_epi32(mmask);
    res.second /= sizeof(T);  // bit count to element count
    return res;

基准测试

处理器:Intel Core i7 9700K(现代消费级 CPU,不支持 AVX-512) 编译器:clang,从 10 版附近的主干构建 编译器选项:--std=c++17 --stdlib=libc++ -g -Werror -Wall -Wextra -Wpedantic -O3 -march=native -mllvm -align-all-functions=7 微基准库:google benchmark

控制代码对齐:如果您不熟悉这个概念,请阅读 this 或观看 this 基准二进制文件中的所有函数都与 128 字节边界对齐。每个基准测试函数重复 64 次,在函数的开头(进入循环之前)使用不同的 noop 幻灯片。我显示的主要数字是每次测量的最小值。我认为这是有效的,因为算法是内联的。我也得到了非常不同的结果这一事实验证了我。在答案的最底部,我展示了代码对齐的影响。 注意:benchmarking code。 BENCH_DECL_ATTRIBUTES 只是内联

Benchmark 从数组中删除一定百分比的 0。我用 0, 5, 20, 50, 80, 95, 100% 的零来测试数组。 我测试了 3 种大小:40 字节(看看这是否适用于非常小的数组)、1000 字节和 10'000 字节。我按大小分组,因为 SIMD 取决于数据的大小而不是元素的数量。元素计数可以从元素大小(1000 字节是 1000 个字符,但 500 个短字节和 250 个整数)得出。由于非 simd 代码所花费的时间主要取决于元素数量,因此对于 char 而言,胜利应该更大。

绘图:x - 零的百分比,y - 以纳秒为单位的时间。 padding : min 表示这是所有对齐中的最小值。

40 字节的数据,40 个字符

对于 40 字节,即使对于字符也没有意义 - 当在非 simd 代码上使用 128 位寄存器时,我的实现会慢 8-10 倍。因此,例如,编译器应该小心执行此操作。

1000 字节的数据,1000 个字符

显然,非 simd 版本以分支预测为主:当我们得到少量零时,我们得到的加速较小:对于没有 0 - 大约 3 倍,对于 5% 零 - 大约 5-6 倍加速。当分支预测器无法帮助非 simd 版本时 - 大约有 27 倍的加速。 simd 代码的一个有趣特性是它的性能往往不太依赖于数据。使用 128 与 256 寄存器几乎没有区别,因为大部分工作仍分为 2 128 个寄存器。

1000 字节数据,500 条短裤

短裤的结果相似,但增益要小得多 - 最多 2 倍。 我不知道为什么对于非 simd 代码来说,shorts 比 chars 做得更好:我希望 shorts 快两倍,因为只有 500 条短裤,但实际上差异高达 10 倍。

1000 字节的数据,250 个整数

对于 1000,只有 256 位版本是有意义的 - 20-30% 的胜利,不包括没有 0 来删除以往的东西(完美的分支预测,不删除非 simd 代码)。

10'000 字节的数据,10'000 个字符

与 1000 个字符相同的数量级获胜:从分支预测器有帮助时快 2-6 倍到没有帮助时快 27 倍。

相同的情节,只有 simd 版本:

在这里,我们可以看到使用 256 位寄存器并将它们分成 2 128 位寄存器大约可以提高 10%:快了大约 10%。它的大小从 88 条指令增加到 129 条指令,数量不多,因此根据您的用例可能有意义。对于基线 - 非 simd 版本是 79 条指令(据我所知 - 这些比 SIMD 更小)。

10000 字节的数据,5000 条短裤

从 20% 到 9 次获胜,具体取决于数据分布。没有显示 256 位和 128 位寄存器之间的比较 - 它与 chars 的程序集几乎相同,而 256 位寄存器的结果相同,约为 10%。

10'000 字节的数据,2'500 个整数

使用 256 位寄存器似乎很有意义,这个版本比 128 位寄存器快大约 2 倍。与非 simd 代码进行比较时 - 从完美分支预测的 20% 获胜到不是时的 3.5 - 4 倍。

结论:当您有足够的数据量(至少 1000 字节)时,对于没有 AVX-512 的现代处理器来说,这可能是非常值得的优化

PS:

关于要移除的元素的百分比

一方面,过滤一半的元素并不常见。另一方面,在排序期间可以在分区中使用类似的算法 => 实际上预计会有大约 50% 的分支选择。

代码对齐影响

问题是:如果代码恰好对齐不佳,它值多少钱 (一般来说 - 对此几乎无能为力)。 我只显示 10'000 个字节。 对于每个百分比,这些图有两条线,分别代表最小值和最大值(意思是 - 这不是一个最佳/最差代码对齐方式 - 它是给定百分比的最佳代码对齐方式)。

代码对齐影响 - 非 simd

字符:

从差的分支预测的 15-20% 到分支预测有很大帮助的 2-3 倍。 (已知分支预测器会受到代码对齐的影响)。

短裤:

出于某种原因 - 0% 根本不受影响。可以解释为std::remove 首先进行线性搜索以找到要删除的第一个元素。显然,对短裤的线性搜索不受影响。 除此之外 - 从 10% 到 1.6-1.8 倍的价值

整数:

与短裤相同 - 无 0 不受影响。一旦我们进入删除部分,它的价值就会从 1.3 倍增加到 5 倍,然后是最佳大小写对齐。

代码对齐影响 - simd 版本

不显示 short 和 ints 128,因为它与 chars 几乎相同的程序集

字符 - 128 位寄存器 大约慢 1.2 倍

字符 - 256 位寄存器 大约慢 1.1 - 1.24 倍

整数 - 256 位寄存器 慢 1.25 - 1.35 倍

我们可以看到,对于算法的 simd 版本,与非 simd 版本相比,代码对齐的影响要小得多。我怀疑这是因为实际上没有分支。

【讨论】:

我对标量 char 的结果比 short 慢得多有一个疯狂的猜测:当使用 8 位整数时,clang 通常会鲁莽地处理错误的依赖关系,例如mov al, [mem] 合并到 RAX 而不是 movzx eax, byte [mem] 以零扩展而不依赖于旧内容。英特尔因为 Haswell 左右不会将 AL 与 RAX 分开重命名(而是合并),所以这种错误的依赖关系可以创建一个循环携带的依赖关系链。也许使用short,它通过使用movzxmovsx 负载来避免16 位操作数大小。我还没有检查 asm。 code: alignment: i7-9700k 是 Coffee Lake,它有一个工作循环缓冲区 (LSD),这与早期基于 Skylake 的微架构不同,其中微码更新禁用了 LSD。所以我猜这个循环太大了,不适合 LSD。除了特殊情况,例如std::remove 只是对要保留的任何元素进行线性搜索;即使clang展开它,那个紧密的循环也可能从LSD运行。 嗯,混合标量/SIMD 策略可能适用于这种稀疏情况,使用无分支 SIMD 扫描接下来的 16 或 32 个字节以查找不匹配的元素。 (vpcmpeqb/vpmovmskb/tzcnt)。但这会创建一个耦合到下一个加载地址的依赖链,因此它可能很可怕。嗯,也许循环覆盖掩码中的设置位会更好,blsr 重置最低设置位,tzcnt 找到该偏移量,并将标量复制到*dst++ ... ... 通过外部循环的软件流水线,您可以在执行当前内部循环之前加载和比较以获取 next 循环的掩码,以便工作当此 loop-over-mask-bits 中的循环分支在循环退出时预测错误时,它可以在飞行中。您可以将掩码组合成一个 64 位整数,这样您就可以在内部循环中停留更长时间。因此,每 64 个输入元素可能会有一个错误预测,无论输出元素有多少。并且一致的模式可能会让这种情况变得可预测。 3) 是的,对于大多数元素被删除,只保留一些元素的情况,我猜你会反转掩码,所以你想要保留的元素是 1 位。是的,然后您迭代 mask &amp;= mask-1 (BLSR) 以仅循环设置位。使用具有单周期延迟的 BMI1 作为循环携带的依赖项。在每次迭代中,您都执行*dst++ = srcptr[tzcnt(mask)];。其中srcptrmask 派生自的64 元素块的开始。所以标量工作是 BLSR / jnz(循环携带),而不是循环携带:TZCNT、mov load with scaled-index address、mov store、dst++。【参考方案5】:

如果有人对此感兴趣,可以使用 SSE2 的解决方案,它使用指令 LUT 而不是数据 LUT,即跳转表。不过,如果使用 AVX,这将需要 256 个案例。

每次调用下面的LeftPack_SSE2 时,它基本上都会使用三个指令:jmp、shufps、jmp。十六种情况中有五种不需要修改向量。

static inline __m128 LeftPack_SSE2(__m128 val, int mask)  
  switch(mask) 
  case  0:
  case  1: return val;
  case  2: return _mm_shuffle_ps(val,val,0x01);
  case  3: return val;
  case  4: return _mm_shuffle_ps(val,val,0x02);
  case  5: return _mm_shuffle_ps(val,val,0x08);
  case  6: return _mm_shuffle_ps(val,val,0x09);
  case  7: return val;
  case  8: return _mm_shuffle_ps(val,val,0x03);
  case  9: return _mm_shuffle_ps(val,val,0x0c);
  case 10: return _mm_shuffle_ps(val,val,0x0d);
  case 11: return _mm_shuffle_ps(val,val,0x34);
  case 12: return _mm_shuffle_ps(val,val,0x0e);
  case 13: return _mm_shuffle_ps(val,val,0x38);
  case 14: return _mm_shuffle_ps(val,val,0x39);
  case 15: return val;
  


__m128 foo(__m128 val, __m128 maskv) 
  int mask = _mm_movemask_ps(maskv);
  return LeftPack_SSE2(val, mask);

【讨论】:

如果您要在掩码上进行分支,您不妨在每种情况下对 popcnt 进行硬编码。在 int * 参数或其他东西中返回它。 (popcnt 出现在 pshufb 之后,因此如果您必须退回到 SSE2 版本,您也没有硬件 popcnt。)如果 SSSE3 pshufb 可用,则可能是随机掩码的(数据)LUT如果数据不可预测则更好。 由于 pshufb 掩码在每组 4B 中具有已知关系,因此可以将它们从 [ D+3 D+2 D+1 D | C+3 ... ] 压缩到仅 4B [ D C B A ],并使用 punpcklbw same,same / punpcklwd same,same / @ 解包987654334@。不过,这是 3 次洗牌和一次加法,而不仅仅是一次 pshufb。或者用pshufb 解压掩码,所以它是 2 次随机播放和一个 paddb。无论如何,这使得 LUT 只有 16 * 4B = 64B = 一个缓存行,代价是在寄存器中需要另外两个 16B 常量,或者作为内存操作数。 也许在决定跳表策略之前,它开始为分支决策树排序。 when making PIC code 让我很开心,它决定了一个 4B 位移表,它用 movsx 加载。如果它无论如何都要movsx,不妨将1B 位移用于较小的表。它也不知道输入将始终为 0..15,因此它检查该范围之外并返回零:/ re: hex: 你的意思是像这样Godbolt feature-request?让 gcc 在内部完成它可能是理想的,也许向 gcc 提交补丁会比让 Godbolt 对输出进行后处理更好。特别是。因为它在 godbolt.org 之外很有用! @Zboson:请注意,从 gcc 8.1 开始,最好在 switch 中添加 default: __builtin_unreachable();。这导致slightly more efficient code,比没有default 的情况少一个cmp/ja【参考方案6】:

这可能有点晚了,尽管我最近遇到了这个确切的问题并找到了一个使用严格 AVX 实现的替代解决方案。如果您不关心解压缩的元素是否与每个向量的最后一个元素交换,这也可以。以下是AVX版本:

inline __m128 left_pack(__m128 val, __m128i mask) noexcept

    const __m128i shiftMask0 = _mm_shuffle_epi32(mask, 0xA4);
    const __m128i shiftMask1 = _mm_shuffle_epi32(mask, 0x54);
    const __m128i shiftMask2 = _mm_shuffle_epi32(mask, 0x00);

    __m128 v = val;
    v = _mm_blendv_ps(_mm_permute_ps(v, 0xF9), v, shiftMask0);
    v = _mm_blendv_ps(_mm_permute_ps(v, 0xF9), v, shiftMask1);
    v = _mm_blendv_ps(_mm_permute_ps(v, 0xF9), v, shiftMask2);
    return v;

本质上,val 中的每个元素都使用位域 0xF9 向左移动一次,以便与其未移位的变体混合。接下来,将移位和未移位版本与输入掩码混合(在其余元素 3 和 4 中广播第一个非零元素)。再重复此过程两次,在每次迭代中将mask 的第二个和第三个元素广播到其后续元素,这应该提供_pdep_u32() BMI2 指令的AVX 版本。

如果您没有 AVX,您可以轻松地将每个 _mm_permute_ps() 替换为 _mm_shuffle_ps(),以获得与 SSE4.1 兼容的版本。

如果您使用双精度,这里是 AVX2 的附加版本:

inline __m256 left_pack(__m256d val, __m256i mask) noexcept

    const __m256i shiftMask0 = _mm256_permute4x64_epi64(mask, 0xA4);
    const __m256i shiftMask1 = _mm256_permute4x64_epi64(mask, 0x54);
    const __m256i shiftMask2 = _mm256_permute4x64_epi64(mask, 0x00);

    __m256d v = val;
    v = _mm256_blendv_pd(_mm256_permute4x64_pd(v, 0xF9), v, shiftMask0);
    v = _mm256_blendv_pd(_mm256_permute4x64_pd(v, 0xF9), v, shiftMask1);
    v = _mm256_blendv_pd(_mm256_permute4x64_pd(v, 0xF9), v, shiftMask2);

    return v;

另外_mm_popcount_u32(_mm_movemask_ps(val))可用于确定左打包后剩余的元素数量。

【讨论】:

这比_mm_shuffle_epi8 的随机控制向量查找表快吗?就像__m128i shuffles[16] = ... 一样,你用_mm_movemask_ps 结果索引它?如果每个向量只处理 4 个元素,则查找表足够小,可以快速使用。我想也许如果你只需要这样做几次,而不是在一个长时间运行的循环中,那么每个向量花费 9 条指令(其中 3 条是 Intel 上的多指令的 blendv)可能可以避免这种可能性LUT 上的缓存未命中。 能否将_mm256_permute4x64_pd(v, 0xF9) shuffle 替换为val 的不同shuffle 以稍微缩短依赖链,让乱序的exec 更容易隐藏延迟?还是他们都需要洗牌之前的混合结果? 我使用 LUT 进行了测试,类似于 Z boson 的回复,但使用了 _mm_shuffle_epi8,是的,它明显更快(至少在我目前的使用情况下,始终针对您的特定情况进行配置)。最后三个排列不会出现乱序执行,因为结果依赖于之前的每条指令。我确信应该有一种方法可以避免或至少减少依赖链。如果我找到了,我一定会发布的。

以上是关于AVX2 基于面具的最有效打包方式是啥?的主要内容,如果未能解决你的问题,请参考以下文章

Java 中将位打包到 byte[] 并读回的最有效方法是啥?

Angular 2~6:与“组件”通信的最有效方式是啥?

从地理坐标计算本地用户的最有效方法是啥?

在 Julia 中定义一个非常稀疏的网络矩阵的最有效方法是啥?

在 OpenGL 中绘制 3d 图形的最有效方法是啥?

从一维 numpy 数组中获取这种矩阵的最有效方法是啥?