只保留 16 位字中的 10 个有用位
Keep only the 10 useful bits in 16-bit words
我有 _m256i 向量,在 16 位整数中包含 10 位字(因此 16*16 位仅包含 16*10 有用位)。
best/fastest 仅提取那些 10 位并将它们打包以生成 10 位值的输出比特流的方法是什么?
这是我的尝试。
尚未进行基准测试,但我认为它总体上应该运行得相当快:指令不多,所有指令在现代处理器上都有 1 个周期的延迟。存储也很高效,20 字节数据的 2 条存储指令。
该代码仅使用了 3 个常量。如果你在循环中调用这个函数,好的编译器应该在循环之外加载所有三个并将它们保存在寄存器中。
// bitwise blend according to a mask
inline void combineHigh( __m256i& vec, __m256i high, const __m256i lowMask )
{
vec = _mm256_and_si256( vec, lowMask );
high = _mm256_andnot_si256( lowMask, high );
vec = _mm256_or_si256( vec, high );
}
// Store 10-bit pieces from each of the 16-bit lanes of the AVX2 vector.
// The function writes 20 bytes to the pointer.
inline void store_10x16_avx2( __m256i v, uint8_t* rdi )
{
// Pack pairs of 10 bits into 20, into 32-bit lanes
__m256i high = _mm256_srli_epi32( v, 16 - 10 );
const __m256i low10 = _mm256_set1_epi32( ( 1 << 10 ) - 1 ); // Bitmask of 10 lowest bits in 32-bit lanes
combineHigh( v, high, low10 );
// Now the vector contains 32-bit lanes with 20 payload bits / each
// Pack pairs of 20 bits into 40, into 64-bit lanes
high = _mm256_srli_epi64( v, 32 - 20 );
const __m256i low20 = _mm256_set1_epi64x( ( 1 << 20 ) - 1 ); // Bitmask of 20 lowest bits in 64-bit lanes
combineHigh( v, high, low20 );
// Now the vector contains 64-bit lanes with 40 payload bits / each
// 40 bits = 5 bytes, store initial 4 bytes of the result
_mm_storeu_si32( rdi, _mm256_castsi256_si128( v ) );
// Shuffle the remaining 16 bytes of payload into correct positions.
// The indices of the payload bytes are [ 0 .. 4 ] and [ 8 .. 12 ]
// _mm256_shuffle_epi8 can only move data within 16-byte lanes
const __m256i shuffleIndices = _mm256_setr_epi8(
// 6 remaining payload bytes from the lower half of the vector
4, 8, 9, 10, 11, 12,
// 10 bytes gap, will be zeros
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
// 6 bytes gap, will be zeros
-1, -1, -1, -1, -1, -1,
// 10 payload bytes from the higher half of the vector
0, 1, 2, 3, 4,
8, 9, 10, 11, 12
);
v = _mm256_shuffle_epi8( v, shuffleIndices );
// Combine and store the final 16 bytes of payload
const __m128i low16 = _mm256_castsi256_si128( v );
const __m128i high16 = _mm256_extracti128_si256( v, 1 );
const __m128i result = _mm_or_si128( low16, high16 );
_mm_storeu_si128( ( __m128i* )( rdi + 4 ), result );
}
此代码截断值的未使用的高 6 位。
如果你想饱和,你还需要一条指令,_mm256_min_epu16
。
此外,如果您这样做,函数的第一步可以使用 pmaddwd
。这是使源数字饱和的完整函数,并进行了一些额外的调整。
// Store 10-bit pieces from 16-bit lanes of the AVX2 vector, with saturation.
// The function writes 20 bytes to the pointer.
inline void store_10x16_avx2( __m256i v, uint8_t* rdi )
{
const __m256i low10 = _mm256_set1_epi16( ( 1 << 10 ) - 1 );
#if 0
// Truncate higher 6 bits; pmaddwd won't truncate, it needs zeroes in the unused higher bits.
v = _mm256_and_si256( v, low10 );
#else
// Saturate numbers into the range instead of truncating
v = _mm256_min_epu16( v, low10 );
#endif
// Pack pairs of 10 bits into 20, into 32-bit lanes
// pmaddwd computes a[ 0 ] * b[ 0 ] + a[ 1 ] * b[ 1 ] for pairs of 16-bit lanes, making a single 32-bit number out of two pairs.
// Initializing multiplier with pairs of [ 1, 2^10 ] to implement bit shifts + packing
const __m256i multiplier = _mm256_set1_epi32( 1 | ( 1 << ( 10 + 16 ) ) );
v = _mm256_madd_epi16( v, multiplier );
// Now the vector contains 32-bit lanes with 20 payload bits / each
// Pack pairs of 20 bits into 40 in 64-bit lanes
__m256i low = _mm256_slli_epi32( v, 12 );
v = _mm256_blend_epi32( v, low, 0b01010101 );
v = _mm256_srli_epi64( v, 12 );
// Now the vector contains 64-bit lanes with 40 payload bits / each
// 40 bits = 5 bytes, store initial 4 bytes of the result
_mm_storeu_si32( rdi, _mm256_castsi256_si128( v ) );
// Shuffle the remaining 16 bytes of payload into correct positions.
const __m256i shuffleIndices = _mm256_setr_epi8(
// Lower half
4, 8, 9, 10, 11, 12,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
// Higher half
-1, -1, -1, -1, -1, -1,
0, 1, 2, 3, 4,
8, 9, 10, 11, 12
);
v = _mm256_shuffle_epi8( v, shuffleIndices );
// Combine and store the final 16 bytes of payload
const __m128i low16 = _mm256_castsi256_si128( v );
const __m128i high16 = _mm256_extracti128_si256( v, 1 );
const __m128i result = _mm_or_si128( low16, high16 );
_mm_storeu_si128( ( __m128i* )( rdi + 4 ), result );
}
根据处理器、编译器和调用该函数的代码,这在总体上可能会稍快或稍慢,但绝对有助于减少代码大小。没有人再关心二进制大小,但 CPU 的 L1I 和 µop 缓存有限。
为了完整起见,这是另一个使用 SSE2 和可选的 SSSE3 而不是 AVX2 的方法,实际上速度稍慢。
// Compute v = ( v & lowMask ) | ( high & ( ~lowMask ) ), for 256 bits of data in two registers
inline void combineHigh( __m128i& v1, __m128i& v2, __m128i h1, __m128i h2, const __m128i lowMask )
{
v1 = _mm_and_si128( v1, lowMask );
v2 = _mm_and_si128( v2, lowMask );
h1 = _mm_andnot_si128( lowMask, h1 );
h2 = _mm_andnot_si128( lowMask, h2 );
v1 = _mm_or_si128( v1, h1 );
v2 = _mm_or_si128( v2, h2 );
}
inline void store_10x16_sse( __m128i v1, __m128i v2, uint8_t* rdi )
{
// Pack pairs of 10 bits into 20, in 32-bit lanes
__m128i h1 = _mm_srli_epi32( v1, 16 - 10 );
__m128i h2 = _mm_srli_epi32( v2, 16 - 10 );
const __m128i low10 = _mm_set1_epi32( ( 1 << 10 ) - 1 );
combineHigh( v1, v2, h1, h2, low10 );
// Pack pairs of 20 bits into 40, in 64-bit lanes
h1 = _mm_srli_epi64( v1, 32 - 20 );
h2 = _mm_srli_epi64( v2, 32 - 20 );
const __m128i low20 = _mm_set1_epi64x( ( 1 << 20 ) - 1 );
combineHigh( v1, v2, h1, h2, low20 );
#if 1
// 40 bits is 5 bytes, for the final shuffle we use pshufb instruction from SSSE3 set
// If you don't have SSSE3, below under `#else` there's SSE2-only workaround.
const __m128i shuffleIndices = _mm_setr_epi8(
0, 1, 2, 3, 4,
8, 9, 10, 11, 12,
-1, -1, -1, -1, -1, -1 );
v1 = _mm_shuffle_epi8( v1, shuffleIndices );
v2 = _mm_shuffle_epi8( v2, shuffleIndices );
#else
// SSE2-only version of the above, uses 8 instructions + 2 constants to emulate 2 instructions + 1 constant
// Need two constants because after this step we want zeros in the unused higher 6 bytes.
h1 = _mm_srli_si128( v1, 3 );
h2 = _mm_srli_si128( v2, 3 );
const __m128i low40 = _mm_setr_epi8( -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 );
const __m128i high40 = _mm_setr_epi8( 0, 0, 0, 0, 0, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0 );
const __m128i l1 = _mm_and_si128( v1, low40 );
const __m128i l2 = _mm_and_si128( v2, low40 );
h1 = _mm_and_si128( h1, high40 );
h2 = _mm_and_si128( h2, high40 );
v1 = _mm_or_si128( h1, l1 );
v2 = _mm_or_si128( h2, l2 );
#endif
// Now v1 and v2 vectors contain densely packed 10 bytes / each.
// Produce final result: 16 bytes in the low part, 4 bytes in the high part
__m128i low16 = _mm_or_si128( v1, _mm_slli_si128( v2, 10 ) );
__m128i high16 = _mm_srli_si128( v2, 6 );
// Store these 20 bytes with 2 instructions
_mm_storeu_si128( ( __m128i* )rdi, low16 );
_mm_storeu_si32( rdi + 16, high16 );
}
在循环中,您可能希望使用部分重叠的存储,这些存储会写入每个源数据向量的 20 字节目标的末尾之后。这节省了跨 16 字节边界混洗数据以设置 16 + 4 字节存储的工作。
(@Soont 的更新答案有一个 vmovd
和一个 vmovdqu
存储非常好,总共只有 2 个洗牌 uops,包括 vpshufb
和 vextracti128
。当我最初写这个的时候,我们还没有想到一个好的方法来避免存储在 20 字节之外而不花费更多的 shuffle uops,这会造成比前端更糟糕的瓶颈。但是 vmovdqu
+ vextracti128 mem, ymm, 1
(2 uops 不是微融合)仍然稍微便宜一些:vpshufb
之后是 3 uops 而不是 4。)
或者展开可能适用于大型数组,LCM(20,16) = 80,因此对于大型展开(以及其中每个位置的不同洗牌控制向量),您可能只对齐 16 字节商店。但这可能需要大量的改组,包括在可能带有 palignr
.
的源块之间
两个重叠的 16 字节存储示例
将其用作循环体,可以覆盖超过 20 个字节。
#include <immintrin.h>
#include <stdint.h>
// Store 10-bit pieces from each of the 16-bit lanes of the AVX2 vector.
// The function writes 20 useful bytes to the pointer
// but actually steps on data out to 26 bytes from dst
void pack10bit_avx2_store26( __m256i v, uint8_t* dst)
{
// clear high garbage if elements aren't already zero-extended
//v = _mm256_and_si256(v, _mm256_set1_epi16( (1<<10)-1) );
... prep data somehow; pmaddwd + a couple shifts is good for throughput
// Now the vector contains 64-bit lanes with 40 payload bits / each; 40 bits = 5 bytes.
// Shuffle these bytes into a very special order.
// Note _mm256_shuffle_epi8 can only move data within 16-byte lanes.
const __m256i shuffleIndices = _mm256_setr_epi8(
// 6 bytes gap with zeros
// Pack the two 5-byte chunks into the bottom of each 16-byte lane
0, 1, 2, 3, 4,
8, 9, 10, 11, 12,
-1, -1, -1, -1, -1, -1,
0, 1, 2, 3, 4,
8, 9, 10, 11, 12,
-1, -1, -1, -1, -1, -1);
v = _mm256_shuffle_epi8(v, shuffleIndices );
// Split the vector into halves
__m128i low16 = _mm256_castsi256_si128( v );
_mm_storeu_si128( ( __m128i* )dst, low16 ); // vmovdqu mem, xmm
__m128i high16 = _mm256_extracti128_si256( v, 1 );
_mm_storeu_si128( ( __m128i* )(dst+10), high16 ); // vextracti128 mem, ymm, 1
// An AVX-512 masked store could avoid writing past the end
}
我们可以通过将其编译为独立函数 (https://godbolt.org/z/8T7KhT) 来了解它如何内联到循环中。
# clang -O3 -march=skylake
pack10bit_avx2(long long __vector(4), unsigned char*):
# vpand commented out
vpmaddwd ymm0, ymm0, ymmword ptr [rip + .LCPI0_0]
... # work in progress, original PMADDWD idea ignored some limitations! See Soonts' answer
vpshufb ymm0, ymm0, ymmword ptr [rip + .LCPI0_1] # ymm0 = ymm0[0,1,2,3,4,8,9,10,11,12],zero,zero,zero,zero,zero,zero,ymm0[16,17,18,19,20,24,25,26,27,28],zero,zero,zero,zero,zero,zero
vmovdqu xmmword ptr [rdi], xmm0
vextracti128 xmmword ptr [rdi + 10], ymm0, 1
vzeroupper # overhead that goes away when inlining into a loop
ret
在循环中,编译器会将这 2 个向量常量加载到寄存器中,希望使用广播加载。
与一些更宽的整数乘法或水平加法不同,vpmaddwd
作为具有 5 个周期延迟的单个 uop 进行有效处理。 https://uops.info/
vextracti128
存储无法在 Intel 上进行微融合,但与 vpextrd
不同的是,它不涉及 shuffle uop。只是存储地址和存储数据。 Zen2 也 运行 将其设置为 2 微指令,不幸的是每 2 个周期吞吐量为 1。 (比Zen1还差)。
在 Ice Lake 之前,Intel 和 AMD 都可以 运行 每个时钟存储 1 个。
如果您确实想要将打包的数据放回寄存器中,您可能需要使用 palignr
进行@Soont 的原始随机播放,或者您可以先执行此操作,然后重新加载。延迟会更高(特别是因为存储转发在重新加载时停滞),但如果您的块是几个寄存器的数据,那么它应该重叠甚至隐藏延迟,也许给存储时间甚至提交到 L1d 而不是导致重新加载时停顿。
BMI2 pext
uint64_t packed = _pext_u64(x, 0x03FF03FF03FF03FF);
可能适用于标量清理或一小块 4 像素或其他内容。这给您带来了进行 5 字节存储(或带有尾随零的 8 字节存储)的问题。如果使用它,请注意严格别名和对齐,例如使用 memcpy
将未对齐的 may-alias 数据放入 uint64_t,或制作 __attribute__((aligned(1),may_alias))
typedef。
pext
在 Intel 上非常有效(1 uop,3c 延迟),但 在 AMD 上非常糟糕 ,比只使用一个 SIMD 的低部分更糟糕步骤。
AVX-512
AVX512VBMI(冰湖)会给你 vpermb
(车道交叉口)而不是 vpshufb
。 (Skylake-X / Cascade Lake 上 vpermw
的 AVX512BW 要求您已经组合成偶数个字节,即使在 vpermb
为 1 的 Ice Lake 上也是 2 微码,所以非常糟糕.) vpermb
可以设置一个未对齐的 32 字节存储(有 20 个有用字节),您可以在循环中重叠它。
AVX-512 存储可以被有效地屏蔽以不实际上覆盖结尾,例如使用双字掩码。 vmovdqu32 [rdi]{k}, ymm0
在 Skylake-X 上是 1 uop。但是 AVX2 vmaskmovd
即使在 Intel 上也是几个微指令,而在 AMD 上非常昂贵,所以你不想那样做。双字掩码只有在您为一个存储准备好所有 20 个字节时才有效,否则您至少需要 16 位粒度。
其他 AVX-512 指令:VBMI vpmultishiftqb
,一种并行位域提取,似乎很有用,但它只能从未对齐但连续的源块写入对齐的 8 位目标块。我不认为这比我们可以用可变移位和旋转做的更好。 vpmultishiftqb
会让我们 解压 这种格式(这个函数的反函数) 可能需要 2 条指令: 1 条随机播放(例如 vpexpandb
或 vpermb
) 将所需的数据放入向量中的每个 qword,并进行一次多移位以获取每个单词底部的正确 10 位字段。
AVX-512 具有可变计数移位和旋转,包括字(16 位)粒度,因此这将是第一步而不是 vpmaddwd
的一个选项。 使用轮班免费忽略高垃圾。它具有较低的延迟,并且直接版本的合并屏蔽可以取代对控制向量的需要。 (但是你需要一个掩码常量)。
使用屏蔽时,延迟为 3 个周期,而没有屏蔽时为 1 个周期,并且 AVX-512 使得从即时广播控制向量与 mov reg,imm
/ kmov kreg, reg
一样高效。例如mov reg,imm
/ vpbroadcastd ymm, reg
(1 微指令)。合并屏蔽还限制优化器覆盖目标寄存器而不是复制和移位,尽管这在这里无关紧要如果优化器很聪明。两种方式都不允许将数据的加载折叠到内存源操作数中进行移位:sllvw
只能从内存中获取计数,而 sllw
需要合并到寄存器中的原始操作数。
Shifts 可以 运行 在 Intel 的端口 0 或 1 上(AMD 不支持 AVX-512)。或者仅用于 512 位微指令的端口 0,在任何 512 位微指令运行时关闭用于任何矢量 ALU 微指令的端口 1。因此,对于此的 __m512i
版本,端口 0 存在潜在的吞吐量瓶颈,但对于 256 位,有足够的其他微指令(洗牌和存储,如果对数据数组执行此操作,可能会产生循环开销),这应该分布相当均匀。
这个移位部分(在_mm256_permutexvar_epi8
之前)只需要AVX-512BW(+VL),并且可以在Skylake-X上工作。它将数据留在与其他方法相同的地方,因此是一个直接替代品,您可以混合搭配各种策略。
// Ice Lake. Could work on __m512i but then shifts could only run on p0, not p0/p1,
// and almost every store would be a cache line split.
inline void store_10x16_avx512vbmi( __m256i v, uint8_t* dst )
{
// no _mm256_and_si256 needed, we safely ignore high bits
// v = [ ?(6) ... B[9:0] | ?(6) ... A[9:0] ] repeated
v = _mm256_sllv_epi16(v, _mm256_set1_epi32((0<<16) | 6)); // alternative: simple repeated-pattern control vector
// v = _mm256_mask_slli_epi16(v, 0x5555, v, 6); // merge-masking, updating only elements 0,2, etc.
// v = [ ?(6) ... B[9:0] | A[9:0] ... 0(6) ] repeated
v = _mm256_rolv_epi32(v, _mm256_set1_epi64x(((32ULL-6)<<32) | 6)); // top half right, bottom half left
// v = [ 0(6) .. ?(6) .. D[9:0] | C[9:0] | B[9:0] | A[9:0] ... 0(12) ] repeated
v = _mm256_srli_epi64(v, 12); // 40 bit chunks at the bottom of each qword
const __m256i permb = _mm256_setr_epi8( 0, 1, 2, 3, 4, 8, 9,10,11,12,
16,17,18,19,20, 24,25,26,27,28,
28,28,28,28,28,28,28,28,28,28,28,28 );
// repeat last byte as filler. vpermb can't zero (except by maskz) but we can do a masked store
v = _mm256_permutexvar_epi8(v, permb); // AVX512_VBMI
_mm256_mask_storeu_epi32( dst, 0x1F, v); // 32-bit masking granularity in case that's cheaper for HW. 20 bytes = 5 dwords.
}
这样编译 (Godbolt):
# clang -O3 -march=icelake-client. GCC is essentially the same.
store_10x16_avx512vbmi(long long __vector(4), unsigned char*):
vpsllvw ymm0, ymm0, ymmword ptr [rip + .LCPI0_0]
vprolvd ymm0, ymm0, ymmword ptr [rip + .LCPI0_1]
vpsrlq ymm0, ymm0, 12
vpermb ymm0, ymm0, ymmword ptr [rip + .LCPI0_2]
mov al, 31 # what the heck, clang? partial register false dependency for no reason!
kmovd k1, eax
vmovdqu32 ymmword ptr [rdi] {k1}, ymm0
# vzeroupper not needed because the caller was using __m256i args. GCC omits it.
ret
即使您两次使用相同的移位常量向量使编译器将其保存在寄存器中(而不是直接从内存源操作数中使用),它仍然选择从内存中加载它而不是 mov eax,6
/ vpbroadcast ymm1, eax
之类的。这以需要 .rodata 中的常量为代价节省了 1 uop。公平地说,我们确实可能需要在同一个缓存行中使用其他常量,但是 GCC 浪费的方式 space 它们并不都适合一个缓存行! clang 注意到该模式并使用 vpbroadcastd
或 q
加载,gcc 浪费地加载了完整的 32 个字节。 (kmov k1, [mem]
是 3 个前端微指令,因此它不会保存一个微指令来从内存中加载掩码常量。)
使用 _mm256_mask_slli_epi16(v, 0x5555, v, 6)
,clang 将其优化回 vpsllvw ymm0, ymm0, ymmword ptr [rip + .LCPI0_0]
,具有相同的 6,0 重复常数。所以我想这是一个好兆头,我做对了。但是 GCC 编译如写:
store_10x16_avx512vbmi(long long __vector(4), unsigned char*):
mov eax, 21845
kmovw k1, eax
vpsllw ymm0{k1}, ymm0, 6
vprolvd ymm0, ymm0, YMMWORD PTR .LC0[rip]
mov eax, 31
kmovb k2, eax
vpsrlq ymm0, ymm0, 12
vpermb ymm0, ymm0, YMMWORD PTR .LC1[rip]
vmovdqu32 YMMWORD PTR [rdi]{k2}, ymm0
ret
_mm256_sllv_epi16
需要 AVX-512BW 和 AVX-512VL。 rolv_epi32 只需要 AVX-512VL。 (或者仅适用于 512 位版本的 AVX-512F。)旋转只有 32 和 64 个元素大小,而不是 16 个,但 AVX-512 确实将可变移位粒度扩展到 16(从 AVX2 中的 32 或 64)。
vpcompressb [rdi]{k1}, ymm0
(AVX512VBMI = Ice Lake 及更高版本)将替代 vpermb + store 以在寄存器底部打包字节(如 BMI2 pext
但用于矢量元素而不是位在标量寄存器中)。但它实际上更昂贵:在 Ice Lake 上 6 uops,每 6c 吞吐量 1。 (vpcompressd
还不错)。
即使 vpcompressb
进入向量寄存器也是 2 微指令,因此对于常量洗牌控制,最好为 vpermb
加载向量常量,除非控制向量的缓存未命中是一个问题,例如如果你只是经常这样做一次,那么让硬件处理一个 k 掩码而不是一个负载。
不带 VBMI 的 AVX-512:2x 16 字节存储,不超过 20 字节范围
... // same setup as usual, leaving 40-bit chunks at the bottom of each qword
const __m256i shuffleIndices = _mm256_setr_epi8(
// 6 bytes gap with zeros
// Pack the two 5-byte chunks into the bottom of each 16-byte lane
0, 1, 2, 3, 4,
8, 9, 10, 11, 12,
-1, -1, -1, -1, -1, -1,
0, 1, 2, 3, 4,
8, 9, 10, 11, 12,
-1, -1, -1, -1, -1, -1);
v = _mm256_shuffle_epi8(v, shuffleIndices );
// Split the vector into halves
__m128i low16 = _mm256_castsi256_si128( v );
_mm_storeu_si128( ( __m128i* )dst, low16 ); // vmovdqu mem, xmm no masking
// An AVX-512BW masked store avoiding writing past the end costs more instructions (and back-end uops), same front-end uops
__m128i high16 = _mm256_extracti128_si256( v, 1 ); // vextracti128 xmm, ymm, 1
_mm_mask_storeu_epi8( dst+10, 0x3FF, high16 ); // vmovdqu8 [mem]{k}, xmm
这需要 vextracti128 xmm, ymm, 1
为 vmovdqu8
设置。与写入 26 个字节不同,我们不能直接提取到内存中。没有 vextracti8x16
,只有 vextracti32x4
和 64x2
(以及 32x8 / 64x4 256 位提取)。我们需要字节粒度掩码,但无法通过直接提取到内存的指令获得它,只能通过洗牌(vextract
到寄存器)然后 vmovdqu8
.
所以我们得到的asm是
# clang
... vpshufb result in YMM0
vmovdqu [rdi], xmm0 # same as before
vextracti128 xmm0, ymm0, 1 # 1 shuffle uop
mov ax, 1023
kmovd k1, eax # will be hoisted
vmovdqu8 [rdi + 10] {k1}, xmm0 # 1 micro-fused uop
因为 vextracti128 [mem], ymm, 1
无论如何都是 2 个前端微指令,这不会影响前端吞吐量。 (由于 shuffle uop,它确实对后端执行端口造成了更大的压力)。
我有 _m256i 向量,在 16 位整数中包含 10 位字(因此 16*16 位仅包含 16*10 有用位)。 best/fastest 仅提取那些 10 位并将它们打包以生成 10 位值的输出比特流的方法是什么?
这是我的尝试。
尚未进行基准测试,但我认为它总体上应该运行得相当快:指令不多,所有指令在现代处理器上都有 1 个周期的延迟。存储也很高效,20 字节数据的 2 条存储指令。
该代码仅使用了 3 个常量。如果你在循环中调用这个函数,好的编译器应该在循环之外加载所有三个并将它们保存在寄存器中。
// bitwise blend according to a mask
inline void combineHigh( __m256i& vec, __m256i high, const __m256i lowMask )
{
vec = _mm256_and_si256( vec, lowMask );
high = _mm256_andnot_si256( lowMask, high );
vec = _mm256_or_si256( vec, high );
}
// Store 10-bit pieces from each of the 16-bit lanes of the AVX2 vector.
// The function writes 20 bytes to the pointer.
inline void store_10x16_avx2( __m256i v, uint8_t* rdi )
{
// Pack pairs of 10 bits into 20, into 32-bit lanes
__m256i high = _mm256_srli_epi32( v, 16 - 10 );
const __m256i low10 = _mm256_set1_epi32( ( 1 << 10 ) - 1 ); // Bitmask of 10 lowest bits in 32-bit lanes
combineHigh( v, high, low10 );
// Now the vector contains 32-bit lanes with 20 payload bits / each
// Pack pairs of 20 bits into 40, into 64-bit lanes
high = _mm256_srli_epi64( v, 32 - 20 );
const __m256i low20 = _mm256_set1_epi64x( ( 1 << 20 ) - 1 ); // Bitmask of 20 lowest bits in 64-bit lanes
combineHigh( v, high, low20 );
// Now the vector contains 64-bit lanes with 40 payload bits / each
// 40 bits = 5 bytes, store initial 4 bytes of the result
_mm_storeu_si32( rdi, _mm256_castsi256_si128( v ) );
// Shuffle the remaining 16 bytes of payload into correct positions.
// The indices of the payload bytes are [ 0 .. 4 ] and [ 8 .. 12 ]
// _mm256_shuffle_epi8 can only move data within 16-byte lanes
const __m256i shuffleIndices = _mm256_setr_epi8(
// 6 remaining payload bytes from the lower half of the vector
4, 8, 9, 10, 11, 12,
// 10 bytes gap, will be zeros
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
// 6 bytes gap, will be zeros
-1, -1, -1, -1, -1, -1,
// 10 payload bytes from the higher half of the vector
0, 1, 2, 3, 4,
8, 9, 10, 11, 12
);
v = _mm256_shuffle_epi8( v, shuffleIndices );
// Combine and store the final 16 bytes of payload
const __m128i low16 = _mm256_castsi256_si128( v );
const __m128i high16 = _mm256_extracti128_si256( v, 1 );
const __m128i result = _mm_or_si128( low16, high16 );
_mm_storeu_si128( ( __m128i* )( rdi + 4 ), result );
}
此代码截断值的未使用的高 6 位。
如果你想饱和,你还需要一条指令,_mm256_min_epu16
。
此外,如果您这样做,函数的第一步可以使用 pmaddwd
。这是使源数字饱和的完整函数,并进行了一些额外的调整。
// Store 10-bit pieces from 16-bit lanes of the AVX2 vector, with saturation.
// The function writes 20 bytes to the pointer.
inline void store_10x16_avx2( __m256i v, uint8_t* rdi )
{
const __m256i low10 = _mm256_set1_epi16( ( 1 << 10 ) - 1 );
#if 0
// Truncate higher 6 bits; pmaddwd won't truncate, it needs zeroes in the unused higher bits.
v = _mm256_and_si256( v, low10 );
#else
// Saturate numbers into the range instead of truncating
v = _mm256_min_epu16( v, low10 );
#endif
// Pack pairs of 10 bits into 20, into 32-bit lanes
// pmaddwd computes a[ 0 ] * b[ 0 ] + a[ 1 ] * b[ 1 ] for pairs of 16-bit lanes, making a single 32-bit number out of two pairs.
// Initializing multiplier with pairs of [ 1, 2^10 ] to implement bit shifts + packing
const __m256i multiplier = _mm256_set1_epi32( 1 | ( 1 << ( 10 + 16 ) ) );
v = _mm256_madd_epi16( v, multiplier );
// Now the vector contains 32-bit lanes with 20 payload bits / each
// Pack pairs of 20 bits into 40 in 64-bit lanes
__m256i low = _mm256_slli_epi32( v, 12 );
v = _mm256_blend_epi32( v, low, 0b01010101 );
v = _mm256_srli_epi64( v, 12 );
// Now the vector contains 64-bit lanes with 40 payload bits / each
// 40 bits = 5 bytes, store initial 4 bytes of the result
_mm_storeu_si32( rdi, _mm256_castsi256_si128( v ) );
// Shuffle the remaining 16 bytes of payload into correct positions.
const __m256i shuffleIndices = _mm256_setr_epi8(
// Lower half
4, 8, 9, 10, 11, 12,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
// Higher half
-1, -1, -1, -1, -1, -1,
0, 1, 2, 3, 4,
8, 9, 10, 11, 12
);
v = _mm256_shuffle_epi8( v, shuffleIndices );
// Combine and store the final 16 bytes of payload
const __m128i low16 = _mm256_castsi256_si128( v );
const __m128i high16 = _mm256_extracti128_si256( v, 1 );
const __m128i result = _mm_or_si128( low16, high16 );
_mm_storeu_si128( ( __m128i* )( rdi + 4 ), result );
}
根据处理器、编译器和调用该函数的代码,这在总体上可能会稍快或稍慢,但绝对有助于减少代码大小。没有人再关心二进制大小,但 CPU 的 L1I 和 µop 缓存有限。
为了完整起见,这是另一个使用 SSE2 和可选的 SSSE3 而不是 AVX2 的方法,实际上速度稍慢。
// Compute v = ( v & lowMask ) | ( high & ( ~lowMask ) ), for 256 bits of data in two registers
inline void combineHigh( __m128i& v1, __m128i& v2, __m128i h1, __m128i h2, const __m128i lowMask )
{
v1 = _mm_and_si128( v1, lowMask );
v2 = _mm_and_si128( v2, lowMask );
h1 = _mm_andnot_si128( lowMask, h1 );
h2 = _mm_andnot_si128( lowMask, h2 );
v1 = _mm_or_si128( v1, h1 );
v2 = _mm_or_si128( v2, h2 );
}
inline void store_10x16_sse( __m128i v1, __m128i v2, uint8_t* rdi )
{
// Pack pairs of 10 bits into 20, in 32-bit lanes
__m128i h1 = _mm_srli_epi32( v1, 16 - 10 );
__m128i h2 = _mm_srli_epi32( v2, 16 - 10 );
const __m128i low10 = _mm_set1_epi32( ( 1 << 10 ) - 1 );
combineHigh( v1, v2, h1, h2, low10 );
// Pack pairs of 20 bits into 40, in 64-bit lanes
h1 = _mm_srli_epi64( v1, 32 - 20 );
h2 = _mm_srli_epi64( v2, 32 - 20 );
const __m128i low20 = _mm_set1_epi64x( ( 1 << 20 ) - 1 );
combineHigh( v1, v2, h1, h2, low20 );
#if 1
// 40 bits is 5 bytes, for the final shuffle we use pshufb instruction from SSSE3 set
// If you don't have SSSE3, below under `#else` there's SSE2-only workaround.
const __m128i shuffleIndices = _mm_setr_epi8(
0, 1, 2, 3, 4,
8, 9, 10, 11, 12,
-1, -1, -1, -1, -1, -1 );
v1 = _mm_shuffle_epi8( v1, shuffleIndices );
v2 = _mm_shuffle_epi8( v2, shuffleIndices );
#else
// SSE2-only version of the above, uses 8 instructions + 2 constants to emulate 2 instructions + 1 constant
// Need two constants because after this step we want zeros in the unused higher 6 bytes.
h1 = _mm_srli_si128( v1, 3 );
h2 = _mm_srli_si128( v2, 3 );
const __m128i low40 = _mm_setr_epi8( -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 );
const __m128i high40 = _mm_setr_epi8( 0, 0, 0, 0, 0, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0 );
const __m128i l1 = _mm_and_si128( v1, low40 );
const __m128i l2 = _mm_and_si128( v2, low40 );
h1 = _mm_and_si128( h1, high40 );
h2 = _mm_and_si128( h2, high40 );
v1 = _mm_or_si128( h1, l1 );
v2 = _mm_or_si128( h2, l2 );
#endif
// Now v1 and v2 vectors contain densely packed 10 bytes / each.
// Produce final result: 16 bytes in the low part, 4 bytes in the high part
__m128i low16 = _mm_or_si128( v1, _mm_slli_si128( v2, 10 ) );
__m128i high16 = _mm_srli_si128( v2, 6 );
// Store these 20 bytes with 2 instructions
_mm_storeu_si128( ( __m128i* )rdi, low16 );
_mm_storeu_si32( rdi + 16, high16 );
}
在循环中,您可能希望使用部分重叠的存储,这些存储会写入每个源数据向量的 20 字节目标的末尾之后。这节省了跨 16 字节边界混洗数据以设置 16 + 4 字节存储的工作。
(@Soont 的更新答案有一个 vmovd
和一个 vmovdqu
存储非常好,总共只有 2 个洗牌 uops,包括 vpshufb
和 vextracti128
。当我最初写这个的时候,我们还没有想到一个好的方法来避免存储在 20 字节之外而不花费更多的 shuffle uops,这会造成比前端更糟糕的瓶颈。但是 vmovdqu
+ vextracti128 mem, ymm, 1
(2 uops 不是微融合)仍然稍微便宜一些:vpshufb
之后是 3 uops 而不是 4。)
或者展开可能适用于大型数组,LCM(20,16) = 80,因此对于大型展开(以及其中每个位置的不同洗牌控制向量),您可能只对齐 16 字节商店。但这可能需要大量的改组,包括在可能带有 palignr
.
两个重叠的 16 字节存储示例
将其用作循环体,可以覆盖超过 20 个字节。
#include <immintrin.h>
#include <stdint.h>
// Store 10-bit pieces from each of the 16-bit lanes of the AVX2 vector.
// The function writes 20 useful bytes to the pointer
// but actually steps on data out to 26 bytes from dst
void pack10bit_avx2_store26( __m256i v, uint8_t* dst)
{
// clear high garbage if elements aren't already zero-extended
//v = _mm256_and_si256(v, _mm256_set1_epi16( (1<<10)-1) );
... prep data somehow; pmaddwd + a couple shifts is good for throughput
// Now the vector contains 64-bit lanes with 40 payload bits / each; 40 bits = 5 bytes.
// Shuffle these bytes into a very special order.
// Note _mm256_shuffle_epi8 can only move data within 16-byte lanes.
const __m256i shuffleIndices = _mm256_setr_epi8(
// 6 bytes gap with zeros
// Pack the two 5-byte chunks into the bottom of each 16-byte lane
0, 1, 2, 3, 4,
8, 9, 10, 11, 12,
-1, -1, -1, -1, -1, -1,
0, 1, 2, 3, 4,
8, 9, 10, 11, 12,
-1, -1, -1, -1, -1, -1);
v = _mm256_shuffle_epi8(v, shuffleIndices );
// Split the vector into halves
__m128i low16 = _mm256_castsi256_si128( v );
_mm_storeu_si128( ( __m128i* )dst, low16 ); // vmovdqu mem, xmm
__m128i high16 = _mm256_extracti128_si256( v, 1 );
_mm_storeu_si128( ( __m128i* )(dst+10), high16 ); // vextracti128 mem, ymm, 1
// An AVX-512 masked store could avoid writing past the end
}
我们可以通过将其编译为独立函数 (https://godbolt.org/z/8T7KhT) 来了解它如何内联到循环中。
# clang -O3 -march=skylake
pack10bit_avx2(long long __vector(4), unsigned char*):
# vpand commented out
vpmaddwd ymm0, ymm0, ymmword ptr [rip + .LCPI0_0]
... # work in progress, original PMADDWD idea ignored some limitations! See Soonts' answer
vpshufb ymm0, ymm0, ymmword ptr [rip + .LCPI0_1] # ymm0 = ymm0[0,1,2,3,4,8,9,10,11,12],zero,zero,zero,zero,zero,zero,ymm0[16,17,18,19,20,24,25,26,27,28],zero,zero,zero,zero,zero,zero
vmovdqu xmmword ptr [rdi], xmm0
vextracti128 xmmword ptr [rdi + 10], ymm0, 1
vzeroupper # overhead that goes away when inlining into a loop
ret
在循环中,编译器会将这 2 个向量常量加载到寄存器中,希望使用广播加载。
与一些更宽的整数乘法或水平加法不同,vpmaddwd
作为具有 5 个周期延迟的单个 uop 进行有效处理。 https://uops.info/
vextracti128
存储无法在 Intel 上进行微融合,但与 vpextrd
不同的是,它不涉及 shuffle uop。只是存储地址和存储数据。 Zen2 也 运行 将其设置为 2 微指令,不幸的是每 2 个周期吞吐量为 1。 (比Zen1还差)。
在 Ice Lake 之前,Intel 和 AMD 都可以 运行 每个时钟存储 1 个。
如果您确实想要将打包的数据放回寄存器中,您可能需要使用 palignr
进行@Soont 的原始随机播放,或者您可以先执行此操作,然后重新加载。延迟会更高(特别是因为存储转发在重新加载时停滞),但如果您的块是几个寄存器的数据,那么它应该重叠甚至隐藏延迟,也许给存储时间甚至提交到 L1d 而不是导致重新加载时停顿。
BMI2 pext
uint64_t packed = _pext_u64(x, 0x03FF03FF03FF03FF);
可能适用于标量清理或一小块 4 像素或其他内容。这给您带来了进行 5 字节存储(或带有尾随零的 8 字节存储)的问题。如果使用它,请注意严格别名和对齐,例如使用 memcpy
将未对齐的 may-alias 数据放入 uint64_t,或制作 __attribute__((aligned(1),may_alias))
typedef。
pext
在 Intel 上非常有效(1 uop,3c 延迟),但 在 AMD 上非常糟糕 ,比只使用一个 SIMD 的低部分更糟糕步骤。
AVX-512
AVX512VBMI(冰湖)会给你 vpermb
(车道交叉口)而不是 vpshufb
。 (Skylake-X / Cascade Lake 上 vpermw
的 AVX512BW 要求您已经组合成偶数个字节,即使在 vpermb
为 1 的 Ice Lake 上也是 2 微码,所以非常糟糕.) vpermb
可以设置一个未对齐的 32 字节存储(有 20 个有用字节),您可以在循环中重叠它。
AVX-512 存储可以被有效地屏蔽以不实际上覆盖结尾,例如使用双字掩码。 vmovdqu32 [rdi]{k}, ymm0
在 Skylake-X 上是 1 uop。但是 AVX2 vmaskmovd
即使在 Intel 上也是几个微指令,而在 AMD 上非常昂贵,所以你不想那样做。双字掩码只有在您为一个存储准备好所有 20 个字节时才有效,否则您至少需要 16 位粒度。
其他 AVX-512 指令:VBMI vpmultishiftqb
,一种并行位域提取,似乎很有用,但它只能从未对齐但连续的源块写入对齐的 8 位目标块。我不认为这比我们可以用可变移位和旋转做的更好。 vpmultishiftqb
会让我们 解压 这种格式(这个函数的反函数) 可能需要 2 条指令: 1 条随机播放(例如 vpexpandb
或 vpermb
) 将所需的数据放入向量中的每个 qword,并进行一次多移位以获取每个单词底部的正确 10 位字段。
AVX-512 具有可变计数移位和旋转,包括字(16 位)粒度,因此这将是第一步而不是 vpmaddwd
的一个选项。 使用轮班免费忽略高垃圾。它具有较低的延迟,并且直接版本的合并屏蔽可以取代对控制向量的需要。 (但是你需要一个掩码常量)。
使用屏蔽时,延迟为 3 个周期,而没有屏蔽时为 1 个周期,并且 AVX-512 使得从即时广播控制向量与 mov reg,imm
/ kmov kreg, reg
一样高效。例如mov reg,imm
/ vpbroadcastd ymm, reg
(1 微指令)。合并屏蔽还限制优化器覆盖目标寄存器而不是复制和移位,尽管这在这里无关紧要如果优化器很聪明。两种方式都不允许将数据的加载折叠到内存源操作数中进行移位:sllvw
只能从内存中获取计数,而 sllw
需要合并到寄存器中的原始操作数。
Shifts 可以 运行 在 Intel 的端口 0 或 1 上(AMD 不支持 AVX-512)。或者仅用于 512 位微指令的端口 0,在任何 512 位微指令运行时关闭用于任何矢量 ALU 微指令的端口 1。因此,对于此的 __m512i
版本,端口 0 存在潜在的吞吐量瓶颈,但对于 256 位,有足够的其他微指令(洗牌和存储,如果对数据数组执行此操作,可能会产生循环开销),这应该分布相当均匀。
这个移位部分(在_mm256_permutexvar_epi8
之前)只需要AVX-512BW(+VL),并且可以在Skylake-X上工作。它将数据留在与其他方法相同的地方,因此是一个直接替代品,您可以混合搭配各种策略。
// Ice Lake. Could work on __m512i but then shifts could only run on p0, not p0/p1,
// and almost every store would be a cache line split.
inline void store_10x16_avx512vbmi( __m256i v, uint8_t* dst )
{
// no _mm256_and_si256 needed, we safely ignore high bits
// v = [ ?(6) ... B[9:0] | ?(6) ... A[9:0] ] repeated
v = _mm256_sllv_epi16(v, _mm256_set1_epi32((0<<16) | 6)); // alternative: simple repeated-pattern control vector
// v = _mm256_mask_slli_epi16(v, 0x5555, v, 6); // merge-masking, updating only elements 0,2, etc.
// v = [ ?(6) ... B[9:0] | A[9:0] ... 0(6) ] repeated
v = _mm256_rolv_epi32(v, _mm256_set1_epi64x(((32ULL-6)<<32) | 6)); // top half right, bottom half left
// v = [ 0(6) .. ?(6) .. D[9:0] | C[9:0] | B[9:0] | A[9:0] ... 0(12) ] repeated
v = _mm256_srli_epi64(v, 12); // 40 bit chunks at the bottom of each qword
const __m256i permb = _mm256_setr_epi8( 0, 1, 2, 3, 4, 8, 9,10,11,12,
16,17,18,19,20, 24,25,26,27,28,
28,28,28,28,28,28,28,28,28,28,28,28 );
// repeat last byte as filler. vpermb can't zero (except by maskz) but we can do a masked store
v = _mm256_permutexvar_epi8(v, permb); // AVX512_VBMI
_mm256_mask_storeu_epi32( dst, 0x1F, v); // 32-bit masking granularity in case that's cheaper for HW. 20 bytes = 5 dwords.
}
这样编译 (Godbolt):
# clang -O3 -march=icelake-client. GCC is essentially the same.
store_10x16_avx512vbmi(long long __vector(4), unsigned char*):
vpsllvw ymm0, ymm0, ymmword ptr [rip + .LCPI0_0]
vprolvd ymm0, ymm0, ymmword ptr [rip + .LCPI0_1]
vpsrlq ymm0, ymm0, 12
vpermb ymm0, ymm0, ymmword ptr [rip + .LCPI0_2]
mov al, 31 # what the heck, clang? partial register false dependency for no reason!
kmovd k1, eax
vmovdqu32 ymmword ptr [rdi] {k1}, ymm0
# vzeroupper not needed because the caller was using __m256i args. GCC omits it.
ret
即使您两次使用相同的移位常量向量使编译器将其保存在寄存器中(而不是直接从内存源操作数中使用),它仍然选择从内存中加载它而不是 mov eax,6
/ vpbroadcast ymm1, eax
之类的。这以需要 .rodata 中的常量为代价节省了 1 uop。公平地说,我们确实可能需要在同一个缓存行中使用其他常量,但是 GCC 浪费的方式 space 它们并不都适合一个缓存行! clang 注意到该模式并使用 vpbroadcastd
或 q
加载,gcc 浪费地加载了完整的 32 个字节。 (kmov k1, [mem]
是 3 个前端微指令,因此它不会保存一个微指令来从内存中加载掩码常量。)
使用 _mm256_mask_slli_epi16(v, 0x5555, v, 6)
,clang 将其优化回 vpsllvw ymm0, ymm0, ymmword ptr [rip + .LCPI0_0]
,具有相同的 6,0 重复常数。所以我想这是一个好兆头,我做对了。但是 GCC 编译如写:
store_10x16_avx512vbmi(long long __vector(4), unsigned char*):
mov eax, 21845
kmovw k1, eax
vpsllw ymm0{k1}, ymm0, 6
vprolvd ymm0, ymm0, YMMWORD PTR .LC0[rip]
mov eax, 31
kmovb k2, eax
vpsrlq ymm0, ymm0, 12
vpermb ymm0, ymm0, YMMWORD PTR .LC1[rip]
vmovdqu32 YMMWORD PTR [rdi]{k2}, ymm0
ret
_mm256_sllv_epi16
需要 AVX-512BW 和 AVX-512VL。 rolv_epi32 只需要 AVX-512VL。 (或者仅适用于 512 位版本的 AVX-512F。)旋转只有 32 和 64 个元素大小,而不是 16 个,但 AVX-512 确实将可变移位粒度扩展到 16(从 AVX2 中的 32 或 64)。
vpcompressb [rdi]{k1}, ymm0
(AVX512VBMI = Ice Lake 及更高版本)将替代 vpermb + store 以在寄存器底部打包字节(如 BMI2 pext
但用于矢量元素而不是位在标量寄存器中)。但它实际上更昂贵:在 Ice Lake 上 6 uops,每 6c 吞吐量 1。 (vpcompressd
还不错)。
即使 vpcompressb
进入向量寄存器也是 2 微指令,因此对于常量洗牌控制,最好为 vpermb
加载向量常量,除非控制向量的缓存未命中是一个问题,例如如果你只是经常这样做一次,那么让硬件处理一个 k 掩码而不是一个负载。
不带 VBMI 的 AVX-512:2x 16 字节存储,不超过 20 字节范围
... // same setup as usual, leaving 40-bit chunks at the bottom of each qword
const __m256i shuffleIndices = _mm256_setr_epi8(
// 6 bytes gap with zeros
// Pack the two 5-byte chunks into the bottom of each 16-byte lane
0, 1, 2, 3, 4,
8, 9, 10, 11, 12,
-1, -1, -1, -1, -1, -1,
0, 1, 2, 3, 4,
8, 9, 10, 11, 12,
-1, -1, -1, -1, -1, -1);
v = _mm256_shuffle_epi8(v, shuffleIndices );
// Split the vector into halves
__m128i low16 = _mm256_castsi256_si128( v );
_mm_storeu_si128( ( __m128i* )dst, low16 ); // vmovdqu mem, xmm no masking
// An AVX-512BW masked store avoiding writing past the end costs more instructions (and back-end uops), same front-end uops
__m128i high16 = _mm256_extracti128_si256( v, 1 ); // vextracti128 xmm, ymm, 1
_mm_mask_storeu_epi8( dst+10, 0x3FF, high16 ); // vmovdqu8 [mem]{k}, xmm
这需要 vextracti128 xmm, ymm, 1
为 vmovdqu8
设置。与写入 26 个字节不同,我们不能直接提取到内存中。没有 vextracti8x16
,只有 vextracti32x4
和 64x2
(以及 32x8 / 64x4 256 位提取)。我们需要字节粒度掩码,但无法通过直接提取到内存的指令获得它,只能通过洗牌(vextract
到寄存器)然后 vmovdqu8
.
所以我们得到的asm是
# clang
... vpshufb result in YMM0
vmovdqu [rdi], xmm0 # same as before
vextracti128 xmm0, ymm0, 1 # 1 shuffle uop
mov ax, 1023
kmovd k1, eax # will be hoisted
vmovdqu8 [rdi + 10] {k1}, xmm0 # 1 micro-fused uop
因为 vextracti128 [mem], ymm, 1
无论如何都是 2 个前端微指令,这不会影响前端吞吐量。 (由于 shuffle uop,它确实对后端执行端口造成了更大的压力)。