Intel提供了一个名为_mm256_madd_epi16的C样式函数,基本上
__ m256i _mm256_madd_epi16(__m256i a,__m256i b)
在a和b中乘以压缩的带符号的16位整数,产生中间的带符号的32位整数。水平相加相邻的中间32位整数对,并将结果打包在dst中。
现在我有两个__m256i变量,每个变量中都有32个8位int。
我想实现与_mm256_madd_epi16
相同的功能,但是结果__m256i中的每个int32_t元素是四个带符号char产品的和,而不是两对带符号int16_t
。
我可以在标量循环中做到这一点:
alignas(32) uint32_t res[8];
for (int i = 0; i < 32; ++i)
res[i / 4] += _mm256_extract_epi8(a, i) * _mm256_extract_epi8(b, i);
return _mm256_load_si256((__m256i*)res);
请注意,相加结果在添加之前先经过 sign 扩展至int
,并且_mm256_extract_epi8
辅助函数 1 {{3} }。请不要忘记总数是uint32_t
而不是int32_t
;只需添加四个8x8 => 16位数字就不会溢出。
它看起来很丑陋,并且除非编译器做了一些魔术来用SIMD而不是像按标量提取编写那样进行编译,否则运行效率不高。
脚注1:_mm256_extract_epi8
不是内在的。 vpextrb
仅适用于256位向量的低通道,并且此辅助函数可能允许一个不是编译时常数的索引。
答案 0 :(得分:5)
如果您知道输入之一总是非负的,则可以使用pmaddubsw
;与pmaddwd
等效的8-> 16位。如果总和溢出,它会将有符号饱和度设置为16位,这是可能的,因此,如果这对您的情况来说是个问题,则可能需要避免这种情况。
但是,否则,您可以pmaddubsw
,然后将16位元素手动符号扩展为32,然后添加它们。或对pmaddwd
使用_mm256_set1_epi16(1)
来对元素进行正确的符号加和运算。
显而易见的解决方案是将输入字节解压缩为零或符号扩展的16位元素。然后,您可以使用pmaddwd
两次,并添加结果。
如果您的输入来自内存,则可以用vpmovsxbw
加载它们。例如
__m256i a = _mm256_cvtepi8_epi16(_mm_loadu_si128((const __m128i*)&arr1[i]);
__m256i b = _mm256_cvtepi8_epi16(_mm_loadu_si128((const __m128i*)&arr2[i]);
但是现在您有4个字节要分布在两个双字中,因此您必须将一个_mm256_madd_epi16(a,b)
的结果改组。您也许可以使用vphaddd
进行混洗,然后将两个256位乘积向量添加到所需的一个256位结果向量中,但这很麻烦。
因此,我想我们想从每个256位输入向量中生成两个256位向量:一个在每个单词中将高字节符号扩展为16,另一个在扩展低字节符号中进行。我们可以通过3个班次(每个输入)来做到这一点
__m256i a = _mm256_loadu_si256(const __m256i*)&arr1[i]);
__m256i b = _mm256_loadu_si256(const __m256i*)&arr2[i]);
__m256i a_high = _mm256_srai_epi16(a, 8); // arithmetic right shift sign extends
// some compilers may only know the less-descriptive _mm256_slli_si256 name for vpslldq
__m256i a_low = _mm256_bslli_epi128(a, 1); // left 1 byte = low to high in each 16-bit element
a_low = _mm256_srai_epi16(a_low, 8); // arithmetic right shift sign extends
// then same for b_low / b_high
__m256i prod_hi = _mm256_madd_epi16(a_high, b_high);
__m256i prod_lo = _mm256_madd_epi16(a_low, b_low);
__m256i quadsum = _m256_add_epi32(prod_lo, prod_hi);
作为vplldq
乘1字节的替代方法,vpsllw
乘8位__m256i a_low = _mm256_slli_epi16(a, 8);
是在每个字内从低到高移位的更“明显”的方法,如果洗牌周围的代码瓶颈。但是通常情况会更糟,因为 this 会在shift + vec-int乘法时产生严重的瓶颈。
在KNL上,您可以使用AVX512 vprold z,z,i
(Agner Fog不会显示AVX512 vpslld z,z,i
的时序),因为将每个字的低字节移入或移入都无所谓;这只是算术右移的设置。
Haswell仅在端口0上运行向量移位和向量整数乘法,因此这是瓶颈。 (Skylake更好:p0 / p1)。 http://agner.org/optimize/。
我们可以使用随机播放(端口5)代替左移作为算术右移的设置。这样可以减少资源冲突,从而提高吞吐量甚至减少延迟。
但是我们可以使用vpslldq
进行向量字节移位来避免随机控制向量。它仍然是车道内的混洗(在每个通道的末尾移为零),因此它仍然具有单周期延迟。 (我的第一个想法是vpshufb
,它的控制向量是14,14, 12,12, 10,10, ...
,然后是vpalignr
,然后我记得简单的旧pslldq
有一个AVX2版本。相同的指令。
我喜欢_mm256_bslli_epi128
,因为字节移位的b
将其区分为混洗,这与元素内的移位不同。我没有检查哪个编译器支持内在的128位或256位版本的名称。)
这在AMD Ryzen上也有帮助。向量移位只能在一个执行单元(P2)上运行,但改组可以在P1或P2上运行。
我没有看过AMD Ryzen执行端口冲突,但是我很确定这在任何CPU上都不会更糟(KNL Xeon Phi除外,其中小于双字的元素上的AVX2 ops都非常慢) 。 Shift和车道内混洗具有相同的操作次数和相同的延迟。
零扩展比手动符号扩展便宜,并且避免了端口瓶颈。可以使用a_low
创建 b_low
和/或_mm256_and_si256(a, _mm256_set1_epi16(0x00ff))
。
a_high
和/或b_high
可以通过随机播放而不是平移来创建。 (pshufb
在随机控制矢量的高位置位时将元素清零。)
const _mm256i pshufb_emulate_srl8 = _mm256_set_epi8(
0x80,15, 0x80,13, 0x80,11, ...,
0x80,15, 0x80,13, 0x80,11, ...);
__m256i a_high = _mm256_shuffle_epi8(a, pshufb_emulate_srl8); // zero-extend
在主流Intel上,混洗吞吐量也限制为每个时钟1个,因此如果选择过多,可能会造成混洗的瓶颈。但是至少它与乘法端口不同。如果仅已知高字节为非负数,则将vpsra/lw
替换为vpshufb
可能会有所帮助。未对齐的加载(使那些高字节为低字节)可能会更有用,将vpand
和/或a_low
设置为b_low
。
pmaddubsw
:如果至少一个输入为非负值(因此可以视为无符号),我认为这是可用的它将一个输入视为有符号输入,将另一个输入视为无符号输入,然后执行i8 x u8 => i16,然后添加水平对以形成16位整数(具有饱和符号,因为总和可能会溢出。这也可能将其排除)您的用例)。
但是可能只是使用它,然后将pmaddwd
的水平对添加到常量1
上:
__m256i sum16 = _mm256_maddubs_epi16(a, b);
__m256i sum32 = _mm256_madd_epi16(sum16, _mm256_set1(1));
({pmaddwd
对于水平16 => 32位总和,可能比shift /和/加法有更高的延迟,但确实将所有内容都视为带符号。而且它只有一个uop,因此对吞吐量非常有用。)