我需要按照下面的模式加载并重新排列12个字节到16个(或24到32个):
ABC DEF GHI JKL
变为
ABBC DEEF GHHI JKKL
您能否建议使用SSE(2)和/或AVX(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。)
有四种选择来处理车道交叉(32B负载将为低源通道中的高结果通道提供4B数据):
vpermd
可以作为加载和随机播放,节省了uops。) 使用对齐的负载,以便不需要跨站数据移动。 (在缓冲区的开头需要特殊情况)。见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节,参见x86标签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个字节 - 除非缓存线交叉错位访问对你造成太大伤害。