Intel指令集及SIMD数据加速

发布时间 2023-07-27 20:53:32作者: 牛犁heart

查看CPU相关信息

image
执行结果举例:
image

查看电脑CPU支持的指令集:

cat /proc/cpuinfo | grep "processor" | wc -l

支持的指令集:
image

向量指令集

Flynn分类法根据指令数据进入CPU的方式,将计算机 架构分为四种不同的类型。

  • 1.单指令流单数据流(SISD,Single Instruction stream Single Data stream)
  • 2.单指令流多数据流(SIMD,Single Instruction stream Multiple Data stream)
  • 3.多指令流单数据流(MISD,Multiple Instruction stream Single Data stream)
  • 4.多指令流多数据流(MIMD,Multiple Instruction stream Multiple Data stream)

举例:
SISD指令:需要多条指令完成相关操作
image

SIMD指令:只需要单条指令就可完成相关操作
image

API 介绍

官方参考文档见: Intel® Intrinsics Guide
需要什么功能,可对应该文档查看对应的API
image

因API太多,整个左侧区域是用来筛选,左侧上方的选择区域使用来按指令集来筛选,左测下方的选择区域是用来按功能筛选。

数据类型

可简单理解为,MMX对应64位,SSE对应128位,AVX对应256位
intel 指令集的变量命名规则位__mm<bit_width><data_type>,其中bit_width即数据长度(64|128|256),data_type即存储的数据是什么类型(i|d),i对应int,d对应double,要是空,则对应float

数据类型 描述 大小
__m64 包含2个单精度(float)浮点数的64位向量 2 x 32 bit
__m128 包含4个单精度浮点数的128位向量 4 x 32 bit
__m128i 包含数个整型数值的128位向量 128 bit
__m128d 包含2个双精度浮点数的128位向量 2 x 64 bit
__m256 包含8个单精度浮点数的256位向量 8 x 32 bit
__m256i 包含数个整型数值的256位向量 256 bit
__m256d 包含4个双精度浮点数的256位向量 4 x 256 bit

SSE Data Type(16 XMM Registers)
image

AVX Data Types(16 YMM Registers)
image

注:
AVX的256位寄存器和SSE的128位寄存器存在着相互重叠的关系(XMM寄存器位YMM寄存器的低位),所以最好不要混用AVX与SSE指令集,否则会导致transition penalty(过度处罚),慢50~80时钟周期
image

函数类型:
SSE/AVX intrinsic functions的命名格式位_mm<bit_width>_<name>_<data_type>

  • <bit_width>:表明向量的位长度,对于128位的向量,这个参数为空;256位的向量,这个参数位256
  • <name>:描述内联函数的算术操作
  • <data_type>:标识函数主参数的数据类型。
  • ps 包含float 类型的向量,把32 bits当作一个数
  • pd包含double类型的向量,把64bits当作一个数
  • epi8/epi16/epi32/epi64 :向量中每个数都是整型,包含8位/16位/32位/64位的有符号整数
  • epu8/epu16/epu32/epu64: 向量中每个数都是无符号整型(unsigned),包含8位/16位/32位/64位的无符号整数
  • m128/m128i/m128d/m256/m256i/m256d:输入值与返回类型不同时,标识输入向量类型
    例如:__m256i _mm256_setr_m128i(__m128i lo,__m128i hi) 输入两个__m128i向量,把他们拼在一起,变成一个__m256i返回,另外这种结尾只见于load
  • si128/si256:未指定的128或者256位向量,不关心向量里到底是什么类型,反正是128bit/256bit
    例如:__m256i _mm_broadcastsi128_si256 (__m128i a)
函数 描述
load 用于从内存加载到寄存器
set 用于从内存加载到寄存器
add、sub、mul、div 加减乘除
store 用于从寄存器写入到内存
cast 数据类型转换
compare 数据比较
logical(and|or|test) 逻辑运算
mask 条件判断

一个小demo

#include <iostream>
#include <immintrin.h>
int main(int argc, char* agv[])
{
    __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);

    float d[8];
    _mm256_storeu_ps(d, c);

    std::cout << "result equals:" << d[0] <<","<<d[1]<<","<<d[2]<<","
                <<d[4]<<","<<d[5]<<","<<d[6]<<","<<d[7]<<std::endl;
    return 0;
}

编译时,需要增加编译参数-msse -mavx -mavx2

g++ simple_demo.cc -mavx2 -mavx -msse

运行结果:
result equals:12,14,16,20,22,24,26

PS:

编译参数 -mavx 是用于启用 AVX(Advanced Vector Extensions)指令集的选项。
AVX 是 Intel 推出的一种 SIMD(Single Instruction, Multiple Data)扩展指令集,它可以提高向量化计算的性能。
在使用 g++ 编译器时,可以使用 -mavx 参数来启用 AVX 指令集的支持
需要注意的是,使用 -mavx 参数编译的程序只能在支持 AVX 指令集的 CPU 上运行,否则可能会导致程序崩溃或产生错误。因此,在使用 -mavx 参数之前,建议先检查目标机器是否支持 AVX 指令集。

支持的函数操作:
存取操作(load/store/set)

set操作类型 描述
_mm256_setzero_ps/pd 返回一个全为0的float类型的向量
_mm256_setzero_si256 返回一个全为0的整型向量
_mm256_set1_ps/pd 返回一个float类型的数填充向量
_mm256_set1_epi8/epi16/epi32/epi64x 用整型数填充向量
_mm256_set_ps/pd 用8个float或4个double类型数字初始化向量
_mm256_set_epi8/epi16/epi32/epi64x 用一个整型数初始化向量
_mm256_set_m128/m128d/m128i 用2个128位的向量初始化一个256位向量
_mm256_setr_ps/pd 用8个float或4个double的转置顺序初始化向量
_mm256_setr_epi8/epi16/epi32/epi64x 用若干个整型的转置顺序初始化向量
load操作类型 描述
_mm256_load_ps/pd 从对齐的内存地址加载浮点向量
_mm256_load_si256 从对齐的内存地址加载整型向量
_mm256_loadu_ps/pd 从未对齐的内存地址加载浮点向量
_mm256_loadu_si256 从未对齐的内存地址加载整型向量
_mm_maskload_ps/pd 根据掩码加载128位浮点向量的部分
_mm256_mask_ps/pd 根据掩码加载256位浮点向量的部分
(2)_mm_maskload_epi32/64 根据掩码加载128位整型向量的部分
(2)_mm256_maskload_epi32/64 根据掩码加载256位整型向量的部分

PS:
最后两个函数前面有个(2),代表只在AVX2中支持

store函数:
image

算术运算

  • 加减法
数据类型 描述
_mm256_add_ps/pd 对两个浮点向量做加法
_mm256_sub_ps/pd 对两个浮点向量做减法
(2)_mm256_add_epi8/16/32/64 对两个整形向量做加法
(2)_mm256_sub_epi8/16/32/64 对两个整形向量做减法
(2)_mm256_adds_epi8/16 (2)_mm256_adds_epu8/16 两个整数向量相加且考虑内存饱和问题
(2)_mm256_subs_epi8/16 (2)_mm256_subs_epu8/16 两个整数向量相减且考虑内存饱和问题
_mm256_hadd_ps/pd 水平方向上对两个float类型向量做加法
_mm256_hsub_ps/pd 垂直方向上最两个float类型向量做减法
(2)_mm256_hadd_epi16/32 水平方向上对两个整形向量做加法
(2)_mm256_hsub_epi16/32 水平方向上最两个整形向量做减法
(2)_mm256_hadds_epi16 对两个包含short类型的向量做加法且考虑内存饱和的问题
(2)_mm256_hsubs_epi16 对两个包含short类型的向量做减法且考虑内存饱和的问题
_mm256_addsub_ps/pd 加上和减去两个float类型的向量

将饱和度考虑在内的函数将结果钳制到可以存储的最小/最大值。没有饱和的函数在饱和发生时忽略内存问题。
而在水平方向上做减法的意思如下图:
image
_mm256_hadd_pd接口描述

Operation
dst[63:0] := a[127:64] + a[63:0]
dst[127:64] := b[127:64] + b[63:0]
dst[191:128] := a[255:192] + a[191:128]
dst[255:192] := b[255:192] + b[191:128]
dst[MAX:256] := 0

最后一个指令:_mm256_addsub_ps/pd 在偶数位置减去,奇数位置加上,获最后的目标向量

  • 乘除法
数据类型 描述
_mm256_mul_ps/pd 对两个float类型的向量进行相乘
(2)_mm256_mul_epi32 (2)_mm256_mul_epu32 将包含32位整数的向量的最低四个元素相乘
(2)_mm256_mullo_epi16/32 Multiply integers and store low halves
(2)_mm256_mulhi_epi16 (2)_mm256_mulhi_epu16 Multiply integers and store high halves
(2)_mm256_mulhrs_epi16 Multiply 16-bit elements to form 32-bit elements
_mm256_div_ps/pd 对两个float类型的向量进行想除

image

image

  • 融合乘法和加法
数据类型 描述
(2)_mm_fmadd_ps/pd/ (2)_mm256_fmadd_ps/pd 将两个向量相乘,再将积加上第三个。(res=a*b+c)
(2)_mm_fmsub_ps/pd/ (2)_mm256_fmsub_ps/pd 将两个向量相乘,然后从乘积中减去一个向量。(res=a*b-c)
(2)_mm_fmadd_ss/sd 将向量中最低的元素相乘并相加(res[0]=a[0]*b[0]+c[0])
(2)_mm_fmsub_ss/sd 将向量中最低的元素相乘并相减(res[0]=a[0]*b[0]-c[0])
(2)_mm_fnmadd_ps/pd (2)_mm256_fnmadd_ps/pd 将两个向量相乘,并将负积加到第三个。(res = -(a * b) + c)
(2)_mm_fnmsub_ps/pd/ (2)_mm256_fnmsub_ps/pd 将两个向量相乘,并将负积加到第三个 (res = -(a * b) - c)
(2)_mm_fnmadd_ss/sd 将两个向量的低位相乘,并将负积加到第三个向量的低位。(res[0] = -(a[0] * b[0]) + c[0])
(2)_mm_fnmsub_ss/sd 将最低的元素相乘,并从求反的积中减去第三个向量的最低元素。(res[0] = -(a[0] * b[0]) - c[0])
(2)_mm_fmaddsub_ps/pd/ (2)_mm256_fmaddsub_ps/pd 将两个矢量相乘,然后从乘积中交替加上和减去(res=a*b+/-c)
(2)_mm_fmsubadd_ps/pd/ (2)_mmf256_fmsubadd_ps/pd 将两个向量相乘,然后从乘积中交替地进行减法和加法(res=a*b-/+c)(奇数次方,偶数次方)
  • 排列和洗牌(Permuting and Shuffling)
    排列 Permuting
数据类型 描述
_mm_permute_ps/pd _mm256_permute_ps/pd 根据8位控制值从输入向量中选择元素
(2)_mm256_permute4x64_pd/ (2)_mm256_permute4x64_epi64 根据8位控制值从输入向量中选择64位元素
_mm256_permute2f128_ps/pd 基于8位控制值从两个输入向量中选择128位块
_mm256_permute2f128_si256 基于8位控制值从两个输入向量中选择128位块
_mm_permutevar_ps/pd _mm256_permutevar_ps/pd 根据整数向量中的位从输入向量中选择元素
(2)_mm256_permutevar8x32_ps (2)_mm256_permutevar8x32_epi32 使用整数向量中的索引选择32位元素(浮点和整数)

image

洗牌Shuffling

数据类型 描述
_mm256_shuffle_ps/pd 根据8位值选择浮点元素
_mm256_shuffle_epi8/ _mm256_shuffle_epi32 根据8位值选择整数元素
(2)_mm256_shufflelo_epi16/ (2)_mm256_shufflehi_epi16 基于8位控制值从两个输入向量中选择128位块

对于_mm256_shuffle_pd,只使用控制值的高4位。如果输入向量包含int或float,则使用所有控制位。对于_mm256_shuffle_ps,前两对位从第一个矢量中选择元素,第二对位从第二个矢量中选择元素。
image

举例:

#include <iostream>
#include <immintrin.h>

void print_value(__m128 value)
{
    float *v = (float *)&value;
    std::cout<< v[0] << "," << v[1] << ","<< v[2] << ","<<v[3] << std::endl;
}

// load,用于从内存加载到寄存器中
__m128 function_load(float const*mem_addr)
{
    return _mm_load_ps(mem_addr);
}

// set,用于从内存加载到寄存器中
__m128 function_set(float const *mem_addr)
{
    return _mm_set_ps(mem_addr[3], mem_addr[2], mem_addr[1], mem_addr[0]);
}

// 直接初始化
__m128 direct_init(float const *mem_addr)
{
    __m128 value = {mem_addr[0], mem_addr[1], mem_addr[2], mem_addr[3] };
    return value;
}

int main()
{
    float *mem_addr = (float*)malloc(sizeof(float)*4);
    mem_addr[0] = 1.0f;
    mem_addr[1] = 2.0f;
    mem_addr[2] = 3.0f;
    mem_addr[3] = 4.0f;
    print_value(function_load(mem_addr));
    print_value(function_set(mem_addr));
    print_value(direct_init(mem_addr));

    return 0;
}

运行结果:
image

一个简单的加法验证SIMD效率上的提升

// 写一个简单的程序来验证SIMD在效率方面的提升
#include <iostream>
#include <nmmintrin.h>
#include <immintrin.h>
#include <string>
#include <time.h>
#include <stdlib.h>

void print_time_result(std::string func_name, clock_t start, clock_t finish, int res)
{
    double time = (double(finish - start) / double(CLOCKS_PER_SEC)) * 1000; // ms
    std::cout << func_name << ": " << time << "ms, result = " << res << std::endl;
}

// 普通的加法
void normal_add(int *nums, size_t n)
{
    clock_t start = clock();
    int sum = 0;
    for (size_t i = 0; i < n; i++)
    {
        sum += nums[i];
    }
    clock_t finish = clock();
    print_time_result("normal_add", start, finish, sum);
}

// 使用128bit的SIMD
void SSE_add(int *nums, size_t n)
{
    clock_t start = clock();

    __m128i simd_sum = _mm_setzero_si128();// 累加和
    int normal_sum = 0;

    size_t loop = n / 4;
    size_t reserve = n % 4;

    __m128i *p = (__m128i *)nums;
    for (size_t i = 0; i < loop; i++)
    {
        simd_sum = _mm_add_epi32(simd_sum, *p);
        p++;
    }

    int *q = (int *)p;
    for (size_t i = 0; i < reserve; i++)
    {
        normal_sum += q[i];
    }
    normal_sum += (((int *)&simd_sum)[0] + ((int *)&simd_sum)[1] + ((int *)&simd_sum)[2] + ((int *)&simd_sum)[3]);

    clock_t finish = clock();
    print_time_result("SSE_add", start, finish, normal_sum);
}

// 使用256bit的SIMD
void AVX2_add(int *nums, size_t n)
{
    clock_t start = clock();

    __m256i simd_sum = _mm256_setzero_si256();
    __m256i simd_load;
    int normal_sum = 0;

    size_t loop = n / 8;
    size_t reserve = n % 8;

    __m256i *p = (__m256i *)nums;
    for (size_t i = 0; i < loop; i++)
    {
        simd_load = _mm256_load_si256((const __m256i *)p);
        simd_sum = _mm256_add_epi32(simd_sum, simd_load);
        p++;
    }

    int *q = (int *)p;
    for (size_t i = 0; i < reserve; i++)
    {
        normal_sum += q[i];
    }
    normal_sum += (((int *)&simd_sum)[0] + ((int *)&simd_sum)[1] +
                   ((int *)&simd_sum)[2] + ((int *)&simd_sum)[3] +
                   ((int *)&simd_sum)[4] + ((int *)&simd_sum)[5] +
                   ((int *)&simd_sum)[6] + ((int *)&simd_sum)[7]);

    clock_t finish = clock();
    print_time_result("AVX2_add", start, finish, normal_sum);
}

int main()
{
    size_t n = 10000000;
    int *nums = NULL;
    // nums = (int *)malloc(sizeof(int) * n);
    posix_memalign((void **)&nums, 32, sizeof(int) * n); // 注意要32字节对齐,否则会段错误

    for (size_t i = 0; i < n; i++)
    {
        nums[i] = 5;
    }
    normal_add(nums, n);
    SSE_add(nums, n);
    AVX2_add(nums, n);
    return 0;
}

上述代码其实就是在做10000000个5相加,normal_add()是按照一般思路,循环1千万次加法,SSE_add()是4个一组(128bit)相加,AVX2_add()是8个一组(256bit)相加。

注意:对于SSE,我们申请的空间要16字节对齐,对于AVX要32字节对齐,否则会出现段错误。

// 方法一:
__attribute__((aligned(16))) float A[10000000];// Linux下
// 方法二:
posix_memalign((void **)&B, 32, sizeof(int) * 10000000); // #include <stdlib.h>

运行结果:
image
也许和硬件不一样,在自己电脑上还是有提升的。

参考博客
https://blog.triplez.cn/avx-avx2-learning-notes/
https://blog.csdn.net/just_sort/article/details/94393506
https://zhuanlan.zhihu.com/p/457505686