如何实现四个 i8 元素组的高效_mm256_madd_epi8点积
How to implement an efficient _mm256_madd_epi8 dot-products of groups of four i8 elements?
英特尔提供了一个名为_mm256_madd_epi16的C风格函数,它基本上
__m256i _mm256_madd_epi16 (__m256i a, __m256i b)
将打包的有符号 16 位整数相乘 a 和 b,生成中间有符号 32 位整数。水平添加相邻的中间 32 位整数对,并将结果打包为 dst。
现在我有两个__m256i变量,每个变量都有 32 个 8 位 int。
我想实现与_mm256_madd_epi16
相同的功能,但是结果__m256i中的每个int32_t元素都是有符号字符的四个乘积的总和,而不是两对有符号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);
请注意,乘法结果在相加之前符号扩展到int
,并且_mm256_extract_epi8
帮助程序函数1返回有符号的__int8
。 没关系,总数是uint32_t
而不是int32_t
;无论如何,它不能溢出,只有四个 8x8 => 16 位数字要添加。
它看起来非常丑陋,并且无法高效运行,除非编译器使用 SIMD 执行一些魔术,而不是按照写入标量提取的方式进行编译。
脚注1:_mm256_extract_epi8
不是内在的。vpextrb
仅适用于 256 位矢量的低通道,并且此帮助程序函数可能允许不是编译时常量的索引。
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
( 对于水平 16=>32 位 4 字节元素中的对和,pmaddwd
在某些 CPU 上的延迟高于 shift/和/add,但确实将两个输入都视为有符号以符号扩展到 32 位。 而且它只是一个 uop,因此它有利于吞吐量,特别是如果周围的代码在相同的执行端口上没有瓶颈。
一般情况(两个输入都可能是负数)
最近关于AVX-512BW仿真的AVX-512VNNI指令_mm512_dpbusd_epi32答案提出了一个很好的技巧,将一个输入拆分为MSB,低7位,以便可以使用vpmaddubsw
(_mm256_maddubs_epi16
)而不会溢出。 我们可以借用这个技巧并在求和时否定,因为 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 = 位模式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
) 类似于vpmaddubsw
(u8*i8
个产品),但添加到现有总和中,并在单个指令中将 4 个产品相加在一个字节内。 (i32 += four u8 * i8
)。_mm256_sub_epi32(low7_prods, msb_prods)
,同样的拆分技巧有效,但我们可以跳过madd_epi16
(vpmaddwd
)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-512VNNI的AVX-512可以使用AVX2版本不变,或者扩大到512。 或者可以通过将其转换为掩码(vptestmb
)并将输入的某些字节(零屏蔽vpmovdqu8
)归零来应用符号位,将4字节块的水平和转换为32位元素(使用恒等式洗牌控制vdbpsadbw
对零)。 但是不,这不会在添加 8 位输入之前对其进行签名扩展,因为它是无符号差异。 也许首先将范围偏移到无符号(例如,带有0x80
的零掩蔽异或),然后添加4*128
? 无论如何,msb = _mm256_slli_epi32(dword_hsums_of_input_b, 7)
上面的代码使用其msb
变量的方式相同。 如果这甚至有效,IDK 如果它节省了 uops。 欢迎反馈,或发布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 个字节,所以你必须打乱一个_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
x 1 字节的替代方案,__m256i a_low = _mm256_slli_epi16(a, 8);
vpsllw
8 位是在每个单词中从低到高的更"明显"的方法,如果周围的代码瓶颈在随机播放时可能会更好。 但通常情况下情况更糟,因为这段代码严重阻碍了 shift + vec-int 乘法。
在KNL上,你可以使用AVX512vprold z,z,i
(Agner Fog没有显示AVX512vpslld z,z,i
的时间),因为你在每个单词的低字节中移位或随机播放什么并不重要;这只是算术右移的设置。
执行端口瓶颈:
Haswell 仅在端口 0 上运行向量移位和向量整数乘法,因此这严重瓶颈。 (Skylake更好:p0/p1)。 http://agner.org/optimize/。
我们可以使用随机播放(端口 5)而不是左移作为算术右移的设置。 这提高了吞吐量,甚至通过减少资源冲突来减少延迟。
但是我们可以通过使用vpslldq
进行向量字节偏移来避免随机控制向量。 它仍然是车道内洗牌(在每个车道末端以零换档),因此它仍然具有单周期延迟。 (我的第一个想法是用像14,14, 12,12, 10,10, ...
这样的控制向量vpshufb
,然后vpalignr
,然后我想起了简单的旧pslldq
有一个AVX2版本。同一指令有两个名称。 我喜欢_mm256_bslli_epi128
因为字节移位b
将其区分为洗牌,与元素内位移不同。 我没有检查哪个编译器支持 128 位或 256 位版本的内部函数的名称。
这也有助于AMD Zen 1。 向量移位只能在一个执行单元 (P2) 上运行,但随机播放可以在 P1 或 P2 上运行。
我没有看过AMD Ryzen执行端口冲突,但我很确定这在任何CPU上都不会更糟(除了KNL Xeon Phi,其中AVX2在小于dword的元素上的操作都非常慢)。 班次和车道内随机播放是相同数量的 uop 和相同的延迟。
如果任何元素已知为非负,则符号扩展 = 零扩展
(或者更好的是,使用第一部分中所示的pmaddubsw
。
零扩展比手动签名扩展更便宜,并且避免了端口瓶颈。可以使用_mm256_and_si256(a, _mm256_set1_epi16(0x00ff))
创建a_low
和/或b_low
。
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
在主流英特尔上,随机播放吞吐量也限制为每个时钟 1 个,因此如果您过火,您可能会在随机播放时遇到瓶颈。 但至少它与乘法不是同一个端口。 如果只有高字节是已知的非负字节,那么用vpshufb
替换vpsra/lw
可能会有所帮助。 未对齐的加载,因此这些高字节是低字节可能会更有帮助,为a_low
和/或b_low
设置vpand
。
- C++中高效的大型稀疏块压缩线性方程
- C++中的高效循环缓冲区,它将被传递给C样式数组函数参数
- 如何在C++中高效地构造随机骰子
- 如何实现高效的算法来计算大型数据集的多个不同值?
- 更高效地在微控制器上对C++进行基准测试
- 从C++无序集合中高效提取元素
- 高效的字符串截断算法,按顺序删除相等的前缀和后缀
- C++中特征对角矩阵类型的高效存储
- 高效简单的结构比较运算符
- 使用 Rcpp 的高效矩阵子集
- C++ 包含特征矩阵的类的高效算术运算符重载
- CUDA 高效的 nd-array(张量)切片
- 大多数基本类型的高效二进制序列化
- RAM高效C++属性
- 如何为球形物体和三角形地形提供高效的碰撞检测和响应
- 如何实现四个 i8 元素组的高效_mm256_madd_epi8点积
- 如何在嵌套映射(C++)中高效地查找密钥
- 在C++中高效地保存许多连续记录的图像
- C++ - 将函数链接到触发器的有效和高效方法
- C/C++ 中的高效 pcap 解析器