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,您最好使用 pshufb
或 vpermilps
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'111
。 pext
将抓取与选择器中的 1 位对齐的 010
和 000
索引组。选定的组被打包到输出的低位中,因此输出将为0b000'...'010'000
。 (即[ ... 2 0 ]
)
有关如何从输入向量掩码生成pext
的0b111000111
输入,请参阅注释代码。
现在我们与压缩 LUT 在同一条船上:解压缩多达 8 个压缩索引。
当你把所有的部分放在一起时,总共有三个pext
/pdep
s。我从我想要的东西向后工作,所以在那个方向上也可能最容易理解它。 (即从 shuffle 行开始,然后从那里向后工作。)
如果我们使用每个字节一个索引而不是打包的 3 位组,我们可以简化解包。由于我们有 8 个索引,这仅适用于 64 位代码。
见this and a 32bit-only version on the Godbolt Compiler Explorer。我使用了#ifdef
s,因此它可以与-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 位执行单元的处理器来说是相当惊人的。另一方面,pext
和 pdep
指令都有 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 type
和 width
(每个包中的元素),您可以插入它们以获得不同的变体):https://gcc.godbolt.org/z/yQFR2t 注意:我的代码使用 C++17 并使用自定义 simd 包装器,所以我不知道它的可读性如何。如果您想阅读我的代码-> 大部分代码都在顶部的链接后面,包括在 Godbolt 上。或者,所有代码都在github。
两种情况下@PeterCordes 答案的实现
注意:与掩码一起,我还使用 popcount 计算剩余元素的数量。也许有一种情况是不需要的,但我还没有看到。
_mm_shuffle_epi8
的掩码
-
将每个字节的索引写入半字节:
0xfedcba9876543210
将索引对放入打包到__m128i
中的 8 个短裤中
使用x << 4 | x & 0x0f0f
将它们展开
传播索引的示例。假设选择了第 7 个和第 6 个元素。
这意味着相应的短路将是:0x00fe
。在<< 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
,它通过使用movzx
或movsx
负载来避免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 &= mask-1
(BLSR) 以仅循环设置位。使用具有单周期延迟的 BMI1 作为循环携带的依赖项。在每次迭代中,您都执行*dst++ = srcptr[tzcnt(mask)];
。其中srcptr
是mask
派生自的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[] 并读回的最有效方法是啥?