pmaddubsw http://felixcloutier.com/x86/PMADDUBSW.html:如果至少一个输入为非负(因此可以被视为无符号),则可用
如果已知您的输入之一始终为非负数,则可以将其用作无符号输入pmaddubsw
; 8->16 位等价于pmaddwd
。它添加了对u8*i8 -> i16
产品,带符号饱和度为 16 位。但如果一次输入最多为 127 而不是 255,饱和是不可能的。(127*-128 = -0x3f80
,所以两倍仍然适合 i16。)
After pmaddubsw
, use pmaddwd
反对_mm256_set1_epi16(1)
对元素对进行 hsum 并正确处理符号。 (这通常比手动将 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 位。而且它只是一个微指令,因此对吞吐量有好处,特别是如果周围的代码不会在相同的执行端口上出现瓶颈。)
一般情况(两个输入都可能为负)
最近的一个回答AVX-512BW _mm512_dpbusd_epi32 AVX-512VNNI 指令仿真 https://stackoverflow.com/questions/67999580/avx-512bw-emulation-of-mm512-dpbusd-epi32-avx-512vnni-instruction/76327936#76327936想出了一个好技巧,将一个输入分成 MSB 和低 7 位,这样vpmaddubsw
(_mm256_maddubs_epi16
)可以在没有溢出的情况下使用。我们可以借用这个技巧并在求和时求反,因为 MSB 的位值为-2^7
而不是2^7
的无符号输入vpmaddubsw
将其视为。
// 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 = 位模式 0x8000。或者128 * 127 * 2
= 0x7f00 作为最高的正结果。
对于下面的版本,这是 7 uops(4 个乘法单元)与 9 uops(4 个移位 + 2 个乘法)。
AVX-512VNNI_mm256_dpbusd_epi32
(或 512),或 AVX_VNNI_mm256_dpbusd_avx_epi32
(VPDPBUSD https://www.felixcloutier.com/x86/vpdpbusd) 就好像vpmaddubsw
(u8*i8
产品),但添加到现有总和,并在单个指令中对一个字节内的 4 个产品求和。 (i32 += four u8 * i8
)。同样的分割技巧也有效,_mm256_sub_epi32(low7_prods, msb_prods)
但我们可以跳过madd_epi16
(vpmaddwd
) i16 到 i32 水平总和步长。
(Other VNNI https://en.wikipedia.org/wiki/AVX-512#VNNI说明包括vpdpbusds
(与...一样vpdpbusd
但用有符号饱和而不是换行)。不管怎样,饱和度是i32,而不是i16vpmaddubsw
,因此仅当累加器输入非零时才会饱和。如果一个输入为非负数,那么可以将其视为无符号,这将在一条指令中完成整个工作,而无需拆分。和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-512VNNI 的 AVX-512 可以不加更改地使用 AVX2 版本,或扩大到 512。或者可以通过移位将其转换为掩码来应用符号位(vptestmb
)并将输入的一些字节归零(零掩码vpmovdqu8
) 将 4 字节块水平求和为 32 位元素 (vdbpsadbw https://www.felixcloutier.com/x86/vdbpsadbw与身份洗牌控制的零)。但不,在添加 8 位输入之前不会对其进行符号扩展,因为它是无符号差异。也许首先将范围转移到无符号(例如,零掩码异或0x80
)然后添加4*128
?无论如何,那么msb = _mm256_slli_epi32(dword_hsums_of_input_b, 7)
使用方式与上面的代码使用它的方式相同msb
多变的。如果这有效的话,我不知道它是否可以节省微指令。欢迎反馈,或发布 AVX-512BW 答案。
另一种方式:解包并符号扩展为 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 个字节two双字,所以你必须打乱其中一个的结果_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 上,您可以使用 AVX512vprold z,z,i
(Agner Fog 没有显示 AVX512 的时间vpslld z,z,i
)因为你将什么移位或洗牌到每个单词的低字节并不重要;这只是算术右移的设置。
执行端口瓶颈:
Haswell 仅在端口 0 上运行向量移位和向量整数乘法,因此这会造成严重瓶颈。 (Skylake 更好:p0/p1)。http://agner.org/optimize/ http://agner.org/optimize/.
我们可以使用随机播放(端口 5)代替左移作为算术右移的设置。这可以提高吞吐量,甚至通过减少资源冲突来减少延迟。
But 我们可以通过使用来避免洗牌控制向量vpslldq
进行向量字节移位。它仍然是通道内洗牌(在每个通道末尾移入零),因此它仍然具有单周期延迟。 (我的第一个想法是vpshufb
与控制向量类似14,14, 12,12, 10,10, ...
, then vpalignr
,然后我想起了那个简单的老pslldq
有AVX2版本。同一条指令有两个名称。
我喜欢因为b
与元素内位移不同,字节移位将其区分为随机播放。我没有检查哪个编译器支持 128 位或 256 位版本的内在函数的名称。)
这对 AMD Zen 1 也有帮助。向量移位仅在一个执行单元 (P2) 上运行,但洗牌可以在 P1 或 P2 上运行。
我没有研究过 AMD Ryzen 执行端口冲突,但我很确定这在任何 CPU 上都不会更糟(KNL Xeon Phi 除外,其中对小于双字的元素的 AVX2 操作都非常慢)。移位和通道内洗牌具有相同的微指令数和相同的延迟。
如果任何元素已知为非负,则符号扩展 = 零扩展
(或者更好的是,使用pmaddubsw
如第一节所示。)
零扩展比手动符号扩展更便宜,并且避免了端口瓶颈。a_low
and/or b_low
可以创建为_mm256_and_si256(a, _mm256_set1_epi16(0x00ff))
.
a_high
and/or 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
with vpshufb
有帮助。未对齐的负载,因此那些高字节是低字节可能会更有帮助,设置为vpand
for a_low
and/or b_low
.