c++ 如何实现四个i8元素的组的高效_mm256_madd_epi8点积?

pn9klfpd  于 2023-06-07  发布在  其他
关注(0)|答案(1)|浏览(207)

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。**每个32位块中四个int8_t元素的点积。
我可以在一个标量循环中做到这一点:

alignas(32) uint32_t res[8] = {0};
  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返回有符号的__int8。不要介意总数是uint32_t而不是int32_t;它不能溢出,因为只有四个8x8 => 16位数字要添加。
它看起来非常丑陋,并且不能有效地运行,除非编译器使用SIMD进行一些魔法,而不是像编写标量提取那样进行编译。
脚注1:_mm256_extract_epi8不是一个内在的。vpextrb只适用于256位向量的低通道,并且此helper函数可能允许不是编译时常量的索引。

7bsow1i6

7bsow1i61#

pmaddubsw:如果至少有一个输入是非负的(因此可以被视为无符号),则可用

如果已知其中一个输入总是非负的,则可以将其用作pmaddubsw的无符号输入; pmaddwd的8->16位等价物。它添加了u8*i8 -> i16乘积对,带符号饱和度为16位。但饱和是不可能的一个输入是最多127而不是255。(127*-128 = -0x3f80,所以两倍仍然适合i16。
pmaddubsw之后,使用pmaddwd_mm256_set1_epi16(1)对元素对进行求和,并正确处理符号。(这通常比手动将16位元素符号扩展到32位以添加它们更有效。

__m256i sum16 = _mm256_maddubs_epi16(a, b);   // pmaddubsw
__m256i sum32 = _mm256_madd_epi16(sum16, _mm256_set1_epi16(1)); // pmaddwd

pmaddwd对于4字节元素内的对的水平16=>32位和,在某些CPU上的延迟比移位/和/加法更高,但对于符号扩展到32位,将两个输入都视为有符号。而且它只有一个uop,所以这对吞吐量很有好处,特别是如果周围的代码在相同的执行端口上没有瓶颈。

一般情况(两个输入都可以为负)

最近对_mm512_dpbusd_epi32 AVX-512 VNNI指令的AVX-512 BW仿真的回答提出了一个很好的技巧,将一个输入分为MSB和低7位,因此可以使用vpmaddubsw_mm256_maddubs_epi16)而不会溢出。我们可以借用这个技巧,在hsumming时取反,因为MSB的位值是-2^7,而不是vpmaddubsw的无符号输入所处理的2^7

// Untested.  __m128i version would need SSSE3
__m256i dotprod_i8_to_i32(__m256i v1, __m256i v2)
{
    const __m256i highest_bit = _mm256_set1_epi8(0x80);

    __m256i msb = _mm256_maddubs_epi16(_mm256_and_si256(v1, highest_bit), v2);     // 0 or 2^7
    __m256i low7 = _mm256_maddubs_epi16(_mm256_andnot_si256(highest_bit, v1), v2);

    low7 = _mm256_madd_epi16(low7, _mm256_set1_epi16(1));  // hsum i16 pairs to i32
    msb  = _mm256_madd_epi16(msb,  _mm256_set1_epi16(1));
    return _mm256_sub_epi32(low7, msb);  // place value of the MSB was negative

   // equivalent to the below, but that needs an extra constant
//    msb = _mm256_madd_epi16(msb,  _mm256_set1_epi16(-1));   // the place-value was actually - 2^7
//    return _mm256_add_epi32(low7, msb);

   // also equivalent to vpmaddwd with -1 for both parts
   // return sub(msb, low7)
   // which is cheaper because set1(-1) is just vpcmpeqd not a load.
}

这避免了有符号饱和:一侧的最大乘数是128(MSB被设置并被视为无符号)。128 * -128 =-16384,两倍即-32768 = -0x8000 =位模式0x 8000。或者128 * 127 * 2 = 0x 7 f00作为最高阳性结果。
这是7个uop(乘法单元为4个)与以下版本的9个uop(4个移位+ 2个乘法)。
AVX-512 VNNI _mm256_dpbusd_epi32(或512),或AVX_VNNI _mm256_dpbusd_avx_epi32VPDPBUSD)类似于vpmaddubswu8*i8产品),但添加到现有的总和,并在单个指令的一个字节内对4个产品求和。(i32 += four u8 * i8)。同样的拆分技巧也可以,_mm256_sub_epi32(low7_prods, msb_prods),但我们可以跳过madd_epi16vpmaddwd)i16到i32的水平求和步骤。
(其他VNNI指令包括vpdpbusds(与vpdpbusd相同,但具有带符号饱和而不是环绕)。无论哪种方式,饱和度都是i32,而不是像vpmaddubsw那样的i16,因此只有当累加器输入为非零时才会饱和。如果一个输入是非负的,因此可以被视为无符号的,则这在一个指令中完成整个作业而不拆分。和vpdpwssd[s],有符号字的MAC,有或没有饱和,像vpmaddwd,但有累加器操作数。)

// Ice Lake (AVX-512 version only) or Alder Lake (AVX_VNNI), or Zen 4
__m256i dotprod_i8_to_i32_vnni(__m256i v1, __m256i v2)
{
    const __m256i highest_bit = _mm256_set1_epi8(0x80);
    __m256i msb = _mm256_and_si256(v1, highest_bit);
    __m256i low7 = _mm256_andnot_si256(highest_bit, v1);

   // or just _mm256_dpbusd_epi32 for the EVEX version
    msb = _mm256_dpbusd_avx_epi32(_mm256_setzero_si256(), msb, v2);     // 0 or 2^7
    low7 = _mm256_dpbusd_avx_epi32(_mm256_setzero_si256(), low7, v2);

    return _mm256_sub_epi32(low7, msb);  // place value of the MSB was negative
}

没有AVX-512 VNNI的AVX-512可以使用AVX 2版本不变,或扩大到512。或者可以通过移位将符号位转换为掩码(vptestmb)并将4字节块的水平和的输入(零掩码vpmovdqu8)的一些字节归零为32位元素(vdbpsadbw对零,具有标识混洗控制)来应用符号位。但是不,这不会在添加它们之前对8位输入进行符号扩展,因为它是无符号差异。也许首先将范围移位到无符号(例如与0x80的零掩码异或),然后添加4*128?无论如何,msb = _mm256_slli_epi32(dword_hsums_of_input_b, 7)将以与上面的代码使用其msb变量相同的方式使用。如果这甚至工作,IDK如果它节省uops。欢迎反馈,或发布AVX-512 BW答案。

另一种方式:解包和符号扩展到16位

显而易见的解决方案是将输入字节解压缩为带有零或符号扩展的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个字节,你想分散在 * 两个 * 双字,所以你必须 Shuffle 的结果一个_mm256_madd_epi16(a,b)。您可以使用vphaddd来进行 Shuffle ,并将两个256位的乘积向量添加到一个256位的结果向量中,但这需要大量的 Shuffle 。
因此,我认为我们应该从每个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的一个替代方案,vpsllw的8位__m256i a_low = _mm256_slli_epi16(a, 8);是更“明显”的方式,在每个字内从低到高移位,如果周围的代码在shuffles上出现瓶颈,可能会更好。但通常情况下情况更糟,因为 this 代码在shift + vec-int乘法上严重瓶颈。
在KNL上,你可以使用AVX 512 vprold z,z,i(Agner Fog没有显示AVX 512 vpslld z,z,i的时序),因为它与你在每个字的低字节中移位或 Shuffle 无关;这只是为算术右移而设置的。

执行端口瓶颈:

Haswell只在端口0上运行向量移位和向量整数乘法,因此这严重阻碍了这一点。(Skylake更好:p0/p1). http://agner.org/optimize/ .

我们可以使用shuffle(端口5)代替左移作为算术右移的设置。这提高了吞吐量,甚至通过减少资源冲突来减少延迟。

但是我们可以通过使用vpslldq进行向量字节移位来避免shuffle控制向量。它仍然是一个通道内的 Shuffle (在每个通道的末尾移位零),因此它仍然具有单周期延迟。(我的第一个想法是vpshufb和一个像14,14, 12,12, 10,10, ...这样的控制向量,然后是vpalignr,然后我记得简单的旧pslldq有一个AVX 2版本。同一条指令有两个名称。我喜欢_mm256_bslli_epi128,因为字节移位的b将其区分为shuffle,不像元素内的位移位。我没有检查哪个编译器支持128位或256位版本的intrinsic的名称。

这也有助于AMD Zen 1。向量移位只能在一个执行单元(P2)上运行,但混洗可以在P1或P2上运行。

我还没有看过AMD Ryzen执行端口冲突,但我很确定这不会在任何CPU上更糟(除了KNL Xeon Phi,其中AVX 2在小于dword的元素上的操作都非常慢)。移位和通道内混洗是相同数量的uop和相同的延迟。

如果任何元素已知为非负,则sign-extend = zero-extend

(Or最好使用pmaddubsw,如第一部分所示。)
零扩展比手动扩展符号更便宜,并且避免了端口瓶颈。a_low和/或b_low可以用_mm256_and_si256(a, _mm256_set1_epi16(0x00ff))创建
a_high和/或b_high可以用shuffle而不是shift创建。(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上,Shuffle吞吐量也被限制为每时钟1次,因此如果您过度使用,shuffle可能会成为瓶颈。但至少它和multiply不是同一个端口。如果只有高字节是已知的非负,用vpshufb替换vpsra/lw可能会有帮助。不对齐的加载,使那些高字节是低字节可能会更有帮助,为a_low和/或b_low设置vpand

相关问题