如何实现高效的_mm256_madd_epi8?

时间:2018-07-17 13:11:31

标签: c++ x86 simd intrinsics avx2

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位向量的低通道,并且此辅助函数可能允许一个不是编译时常数的索引。

1 个答案:

答案 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,因此对吞吐量非常有用。)