不同的平台、CPU 支持的 SIMD 指令不同,这里简单整理使用过程中常用的技巧。
简介
为了适配 SIMD 特有的寄存器,内部根据寄存器长度、适用类型定义了常见的数据类型,例如 __m128
__m128d
__m128i
等,分别可以保存 4 个单精度浮点数 (4B)、2 个双精度浮点数 (8B)以及整数,整数可以是 8x2B 4x4B 2x8B 等,这几个类型编译器会默认 16 字节对齐。
如下是一个简单示例,用来实现简单浮点数计算。
#include <iostream>
#include <immintrin.h>
int main(int argc, char **argv)
{
float d[8];
__m256 a = _mm256_set_ps( 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0);
__m256 b = _mm256_set_ps(18.0, 17.0, 16.0, 15.0, 14.0, 13.0, 12.0, 11.0);
__m256 c = _mm256_add_ps(a, b);
_mm256_storeu_ps(d, c);
for (int i = 7; i >= 0; i--) {
std::cout << "result[" << i << "]: " << d[i] << std::endl;
}
return 0;
}
编译时添加 -mavx2
编译参数即可,也可以使用如下头文件。
// gcc-compatible compiler, targeting x86/x86-64
#if defined(__GNUC__) && (defined(__x86_64__) || defined(__i386__))
#include <x86intrin.h>
#endif
函数命名规范
在使用 Intrinsics 函数来操作 SIMD 指令集 (MMX SSE AVX等) 时,会遇到各种不同的函数调用方式,不过实际上是有些约定俗成的命名方式。
通常按照 _<mm/mm256/mm512>_<intrin_op>_<suffix>
命名格式,中间 intrin_op
表示指令名称,后面 suffix
表示操作数类型分成了两部分:
p/ep/s
是一系列的简写,packed
紧凑类型数据,extended packed
同样是紧凑类型,支持从 MMX(64) 到 SSE2(128) 之后的扩展,scalar
只操作最低位的数据。s/d
i/u
分别表示单精度(默认)/双精度浮点数,有符号/无符号整数,前者长度固定所以无需指定,而后者则需要指定长度,例如i64
位有符号 64bits 整数,u32
无符号 32bits 整数。
实际上 p/ep
的意思相同,只是开始 MMX 指令集已经存在对应的函数,例如 __m64 __mm_add_pi8(__m64 a, __m64 b)
,而后续新增了 SSE2 这类指令之后,类似 C 是不支持重载的,只能命名为 __m128i _mm_add_epi8 (__m128i a, __m128i b)
这种。
而到了 AVX 之后,采用了不同的命名方式,直接在 _mm
后增加位数,例如 __m256i _mm256_add_epi8(__m256i a, __m256i b)
以及 __m512i _mm512_add_epi8(__m512i a, __m512i b)
这种。
基本概念
这里介绍一些常用的基本概念。
Packed VS. Scalar
Packed 是最常用的场景,将多个数值合并为一次运算,从而完成计算加速,其中浮点数支持单精度、双精度两种类型,整形包括了 Byte、Word、DoubleWord、QuadWord 四种类型,两类的运算方式是一致的,会将两个操作数对应的 Packed 数据运算后存入目标操作数中。
而 Scalar 只将低位相加保存,剩余的高位保持不变,而且只支持浮点数计算,整形不支持 Scalar 运算。
Horizontal VS. Vertical
上述的 Packed 和 Scalar 运算都是 Vertical 操作,同时也提供了水平方向,也就是将寄存器中的值合并。
Shuffle
使用 SIMD 寄存器进行处理时,某些算法需要将不同的数据位置进行交换,如果没有该指令,那么就需要将寄存器的数据保存在内存中,完成交换后再进行加载,从而导致效率较低。
另外,还有 Blend
与 Shuffle
操作类似。
指令类型
SIMD 操作对象包括浮点和整形两类,支持如下两类操作:
- 运算类,包括基础的 add(加) subtract(减) multiply(乘) divide(除) 操作,还有一些复杂的 sqrt(开平方根)、max/min(最大最小值) 等。运算形式上,支持浮点数的 packed、scalar 以及整形的 packed 运算。
- 非运算类,多种 load/store 操作,以及对数据的处理,包括 shuffle(交错混乱)、unpack(解压)、blend(混乱)、insert(插入)、extract(提取)等操作。
这里简单介绍。
Load/Store
其中 Load 用来从内存中加载数据到寄存器中,而 Store 用于将寄存器数据保存到内存中。
// 加载数据,会转换为汇编指令
__m256i _mm256_loadu_si256(__m256i const* mem); // 从内存中加载数据
__m256i _mm256_lddqu_si256(__m256i const* mem); // 同上,但是跨CacheLine时性能要更好一些
// 保存数据,会转换为汇编指令
void _mm256_storeu_si256(__m256i *mem, __m256i a); // 将寄存器中的数据保存到内存
其中加载包括了 load
以及 loadu
操作,前者要求内存对齐,后者不要求。
另外,有时 Store 操作的数据不会立即读取,常规的 Cache Coherency 反而有些多余,尤其是对于一些大批量的向量计算,可以通过特定指令优化,详见 Bypass The Cache 介绍。
Gather/Scatter
正常使用 SIMD 时通过 load
和 store
访问连续内存,而 gather
和 scatter
则用于访问任意内存地址,通常尽量避免后者,实现并非并行执行,因为 L1 Cache
在每个周期内只允许 1~2 次不同的访问操作,其效率要低很多。
在 When vectorization hits the memory wall 中介绍了如何评估 Gather 所带来的性能开销。
// 根据Mask选择对应的数值(a或b),每个元素是32bits浮点 Blend(融合)
__m256 _mm256_blendv_ps(__m256 a, __m256 b, __m256 mask);
其它
// 类型转换,只在编译期使用,不会生成汇编指令
__m256i _mm256_set_epi8(char e31, ..., char e0); // 通过Char合并为__m256i 32*8=256
__m256 _mm256_castsi256_ps(__m256i a);
// 逻辑运算
__m512i _mm512_xor_si512(__m512i a, __m512i b); // 异或
// 数据移动
__m128i _mm_cvtsi32_si128(int a); // 将32bit放到128bit的低位,同时清零高位
int _mm_cvtsi128_si32(__m128i a); // 反向操作,获取128bit低位数据
其它
注意事项
- 使用 SIMD 时需要注意内存对齐,否则有些场景可能会导致性能下降。
_mm256_mul_epi32()
会把 32 位整数扩展为 64 位再相乘,最后结果保存为 64 位,如果要保存为 32 位,则需要使用_mm256_mullo_epi32()
这个函数。_mm256_set_epi32()
参数顺序相反,而且性能比较差,从汇编看,是调用了多轮 insertf 指令一点点构建向量。
参考
- Intel Intrinsics Guide 可以快速查询向量指令。
- SIMD in the GPU world 介绍在 GPU 中的使用方式。