SIMD Intrinsic 指令详细介绍

2023-12-24 language

不同的平台、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 寄存器进行处理时,某些算法需要将不同的数据位置进行交换,如果没有该指令,那么就需要将寄存器的数据保存在内存中,完成交换后再进行加载,从而导致效率较低。

另外,还有 BlendShuffle 操作类似。

指令类型

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 时通过 loadstore 访问连续内存,而 gatherscatter 则用于访问任意内存地址,通常尽量避免后者,实现并非并行执行,因为 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 指令一点点构建向量。

参考