SSE与AVX指令基础介绍与使用

SSE/AVX指令属于Intrinsics函数,由编译器在编译时直接在调用处插入代码,避免了函数调用的额外开销。但又与inline函数不同,Intrinsics函数的代码由编译器提供,能够更高效地使用机器指令进行优化调整。

在开始之前可以先在CPU-Z或者Intel产品规范查看自己CPU的指令集支持情况。

关于SSE和AVX内部函数的相关信息也都可以在Intel Intrinsics Guide查看。


1.头文件

SSE和AVX指令集有多个不同版本,其函数也包含在对应版本的头文件里。

若不关心具体版本则可以使用 <intrin.h> 包含所有版本的头文件内容。

#include <intrin.h>

以下是头文件对照表

File 描述 VS VisualStudio
intrin.h All Architectures 8.0 2005
mmintrin.h MMX intrinsics 6.0 6.0 SP5+PP5
xmmintrin.h Streaming SIMD Extensions intrinsics 6.0 6.0 SP5+PP5
emmintrin.h Willamette New Instruction intrinsics (SSE2) 6.0 6.0 SP5+PP5
pmmintrin.h SSE3 intrinsics 9.0 2008
tmmintrin.h SSSE3 intrinsics 9.0 2008
smmintrin.h SSE4.1 intrinsics 9.0 2008
nmmintrin.h SSE4.2 intrinsics. 9.0 2008
wmmintrin.h AES and PCLMULQDQ intrinsics. 10.0 2010
immintrin.h Intel-specific intrinsics(AVX) 10.0 2010 SP1
ammintrin.h AMD-specific intrinsics (FMA4, LWP, XOP) 10.0 2010 SP1
mm3dnow.h AMD 3DNow! intrinsics 6.0 6.0 SP5+PP5

来源Intrinsics头文件与SIMD指令集、Visual Studio版本对应表 - zyl910

另外,在Intel Intrinsics Guide也可以查询到每个函数所属的指令集和对应的头文件信息。


2.编译选项

除了头文件以外,我们还需要添加额外的编译选项,才能保证代码被编译成功。

各版本的SSE和AVX都有单独的编译选项,比如-msseN, -mavxN(N表示版本编号)。

经过简单测试后发现,此类编译选项支持向下兼容,比如-msse4可以编译SSE2的函数,-mavx也可以兼容各版本的SSE。

本文中的内容最多涉及到AVX2主要是我的CPU最多支持到这(悲),所以只需要一个-mavx2就能正常运行文中的所有代码。


3.数据类型

Intel目前主要的SIMD指令集有MMX, SSE, AVX, AVX-512,其对处理的数据位宽分别是:

  • 64位 MMX
  • 128位 SSE
  • 256位 AVX
  • 512位 AVX-512

每种位宽对应一个数据类型,名称包括三个部分:

  1. 前缀 __m两个下划线加m。
  2. 中间是数据位宽
  3. 最后加上的字母表示数据类型i为整数,d为双精度浮点数,不加字母则是单精度浮点数

比如SSE指令集的 __m128, __m128i, __m128d

AVX则包括 __m256, __m256i, __m256d

这里的位宽指的是SIMD寄存器的位宽,CPU需要先将数据加载进专门的寄存器之后再并行计算。


4.Intrinsic函数命名

同样,Intrinsic函数的命名通常也是由3个部分构成:

  1. 第一部分为前缀_mm,MMX和SSE都为_mm开头,AVX和AVX-512则会额外加上256和512的位宽标识。
  2. 第二部分表示执行的操作,比如_add,_mul,_load等,操作本身也会有一些修饰,比如_loadu表示以无需内存对齐的方式加载数据。
  3. 第三部分为操作选择的数据范围和数据类型,比如_ps的p(packed)表示所有数据,s(single)表示单精度浮点。_ss则表示s(single)第一个,s(single)单精度浮点。_epixx(xx为位宽)操作所有的xx位的有符号整数,_epuxx则是操作所有的xx位的无符号整数。

例如_mm256_load_ps表示将浮点数加载进整个256位寄存器中。

绝大部分Intrinsic函数都是按照这样的格式构成,每个函数也都能在Intel Intrinsics Guide找到更为完整的描述。

以上介绍内容参照自SSE指令集学习:Compiler Intrinsic - Brook@CV


5.SSE基础应用

进入正题,现在我们有一个程序需要对A、B数组求和,并将结果写入数组C。

#include <stdio.h>

#define SIZE 100000
int main()
{
float A[SIZE], B[SIZE], C[SIZE]; for(int i = 0; i < SIZE; i++)
C[i] = A[i] + B[i];
}

现在我们来一步步地使用SSE修改这个程序,进行数据并行优化。

导入头文件。

#include <intrin.h>

创建3个__m128寄存器分别存储三个数组的float值

__m128 ra, rb, rc;

到了循环体内,我们需要把A、B的值写入寄存器之中,这就需要用到_mm_loadu_ps函数,他会把从指针位置开始的后128位的数据写入寄存器。

至于为什么用loadu而不是load,这个稍后会进行解释。

ra = _mm_loadu_ps(A + i);
rb = _mm_loadu_ps(B + i);

括号里的A+i等价于&A[i]。

之后就是用_mm_add_ps函数计算ra、rb相加,然后把结果返回到rc之中。

rc = _mm_add_ps(ra, rb);

当然还没结束,rc的值还得写回到C数组,这就要用到_mm_storeu_ps函数。

依旧是u结尾的storeu而不是store。

_mm_storeu_ps(C + i, rc);

因为128位寄存器一次可以写入4个(128/32)float值,等于一次循环计算4个float的加法,循环的跨度也应该由1变为4,这样循环次数就只需要原来的1/4。

for (int i = 0; i < SIZE; i += 4)

这样就基本的雏形就完成了,不过在运行之前,别忘了加上编译选项

#include <stdio.h>
#include <intrin.h> #define SIZE 100000
int main()
{
float A[SIZE], B[SIZE], C[SIZE]; for (int i = 0; i < SIZE; i += 4) // 一次计算4个数据,所以要改成+4
{
__m128 ra = _mm_loadu_ps(A + i); // ra = {A[i], A[i+1], A[i+2], A[i+3]}
__m128 rb = _mm_loadu_ps(B + i); // rb = {B[i], B[i+1], B[i+2], B[i+3]}
__m128 rc = _mm_add_ps(ra, rb); // rc = ra + rb
_mm_storeu_ps(C + i, rc); // C[i~i+3] <= rc
}
}

现在对比一下加速的效果:

看到这个结果你可能还是有些失望,明明只循环了原来的1/4次,速度却只有1.7倍。

但还没完,前面为了清晰地展示逻辑,导致代码中用了非常多不必要的中间变量(ra、rb、rc都是),作为一个压行选手自然不能容忍这种事情发生,再进行简化一下:

#include <stdio.h>
#include <intrin.h> #define SIZE 100000
int main()
{
float A[SIZE], B[SIZE], C[SIZE]; for (int i = 0; i < SIZE; i += 4)
{
_mm_storeu_ps(C + i, _mm_add_ps(_mm_loadu_ps(A + i), _mm_loadu_ps(B + i))); // 压行好耶!
}
}

现在再来看看加速情况:

简化后达到了2.2倍的速度,相较简化前已经有了大幅度的提升,但即使如此,距离理论上的4倍也还有很大的优化空间,让我们接着看还有什么优化的方法。


6.内存对齐

6.1为什么用loadu

我们刚刚用load和store进行加载和存储的时候,在二者后面加了个‘u’作为修饰,这个u就表示着无需内存对齐。

二者的区别体现在于Intel Intrinsics Guide中的这一句话。

_mm_store_ps : mem_addr must be aligned on a 16-byte boundary or a general-protection exception may be generated.

_mm_storeu_ps : mem_addr does not need to be aligned on any particular boundary.

也就是说不加u的版本需要原数据有16字节内存对齐,否则在读取的时候就会触发边界保护产生异常。

xx字节对齐的意思是要求数据的地址是xx字节的整数倍,128位宽的SSE要求16字节内存对齐,而256位宽的AVX函数则是要求32字节内存对齐。

可以明显地看出,内存对齐要求的字节数就是指令需要处理的字节数,而要求内存对齐也是为了能够一次访问就完整地读到数据,从而提升效率。

6.2如何进行内存对齐

既然内存对齐后能达到更好的性能,那么我们应该怎么做呢?

创建变量时设置N字节对齐可以用:

  1. __declspec(align(N)),MSVC专用关键字
  2. __attribute__((__aligned__(N))),GCC专用关键字
  3. alignas(N),C++11关键字,不过我这里测试只能指定到16,否则就会warning并且无法生效。

只需要在创建变量时在类型名前加上这几个关键字,就像下面这样:

alignas(16)                      float A[SIZE]; // C++11
__declspec(align(16)) float B[SIZE]; // MSVC
__attribute__((__aligned__(16))) float C[SIZE]; // GCC

对于new或malloc这种申请的内存也有相应的设置方法:

  1. _aligned_malloc(size, N),包含在<stdlib.h>头文件中,与malloc相比多了一个参数N用于指定内存对齐。注意!用此方法申请的内存需要用 _aligned_free() 进行释放
  2. new((std::align_val_t) N),C++17新特性,需要在GCC7及以上版本使用 -std=c++17 编译选项开启。

具体使用方式如下:

float *A = new ((std::align_val_t)32) float[SIZE];             // C++17
float *B = (float *)_aligned_malloc(sizeof(float) * SIZE, 32); // <stdlib.h>
_aligned_free(B); // 用于释放_aligned_malloc申请的内存

使用关键字把数组进行16字节内存对齐后,就可以放心地把loadu和storeu替换成load和store。

#include <stdio.h>
#include <intrin.h> #define SIZE 100000
int main()
{
__attribute__((__aligned__(16))) float A[SIZE], B[SIZE], C[SIZE]; // GCC的内存对齐 for (int i = 0; i < SIZE; i += 4)
{
_mm_store_ps(C + i, _mm_add_ps(_mm_load_ps(A + i), _mm_load_ps(B + i))); // 用store和load替换storeu和loadu
}
}

在我的测试平台,编译器会自动对数组和申请的内存空间进行16字节对齐,所以这次修改对SSE指令其实影响不大,但到后面使用要求32字节对齐的AVX指令就很有必要了。

不过我们可以手动将其修改为8字节对齐,来对比一下与16字节之间的性能差距:

可以看到对于load和loadu,内存对齐之后速度只有略微的提升。

但你肯定也注意到了,这个所谓的“类型转换”居然直接霸榜了,内存对齐之后更是从3.6提升到了3.8倍,这已经非常接近理论上的4倍提升了。

那么接下来我们就来了解一下这个“类型转换”究竟是什么。


7.类型转换

对代码进行单步调试,观察一下几个函数在头文件中的实现方式。

在程序运行到_mm_load_ps时点击单步进入,到达函数内部。

/* Load four SPFP values from P.  The address must be 16-byte aligned.  */
extern __inline __m128 __attribute__((__gnu_inline__, __always_inline__, __artificial__))
_mm_load_ps (float const *__P)
{
return *(__m128 *)__P;
}

_mm_loadu_ps对应的类型是*(__m128_u *)__P

忽视上面的几个声明,可以发现这个函数只是对传入的指针进行了一次类型转换,这个转换看着可能有点绕,但拆分后其实很简单,将*(__m128 *)__P分成两个部分:

  1. (__m128 *)__P:将__P从float *类型转换为__m128 *
  2. *:访问__m128 *指针指向的__m128对象

而_mm_store_ps也是类似的操作:

/* Store four SPFP values.  The address must be 16-byte aligned.  */
extern __inline void __attribute__((__gnu_inline__, __always_inline__, __artificial__))
_mm_store_ps (float *__P, __m128 __A)
{
*(__m128 *)__P = __A;
}

既然这么简单,那我们完全可以自己手动来实现这一步:

#include <stdio.h>
#include <intrin.h> #define SIZE 100000
int main()
{
__attribute__((__aligned__(16))) float A[SIZE], B[SIZE], C[SIZE]; for (int i = 0; i < SIZE; i += 4)
{
*(__m128 *)(C + i) = _mm_add_ps(*(__m128 *)(A + i), *(__m128 *)(B + i)); // 使用类型转换
}
}

转换成__m128*同样是有内存对齐要求的,若是低于16字节对齐就会在访问指针时出错,非对齐的情况应该使用__m128_u*指针。


8.AVX

AVX的用法与SSE相同,只需要根据命名规律修改一下数据类型和函数的名称就可以了。

AVX的数据处理位宽为256位,是SSE的两倍,因此内存对齐要求也提升到了 32字节

#include <stdio.h>
#include <intrin.h> #define SIZE 100000
int main()
{
__attribute__((__aligned__(32))) float A[SIZE], B[SIZE], C[SIZE]; // 32字节对齐 for (int i = 0; i < SIZE; i += 8) // 循环跨度修改为8
{
*(__m256 *)(C + i) = _mm256_add_ps(*(__m256 *)(A + i), *(__m256 *)(B + i)); // 使用256位宽的数据与函数
}
}

来测试一下速度提升:

可以看到内存对齐方式对AVX的性能也是有一定影响的,在内存对齐之后AVX也能达到7倍的提升。

9.整数操作

最后是对SSE/AVX整数操作的一些简单补充,文章开头介绍时提到过,整数计算使用的数据类型是__m128i/__m256i,二者都是以i结尾。

还有基本的算数函数,比如SSE的加法add,epi表示整数,后面的数字就是单个整数的数据位宽。比如epi8就是1字节char加法,4字节int加法就是epi32。

__m128i _mm_add_epi8 (__m128i a, __m128i b)
__m128i _mm_add_epi16 (__m128i a, __m128i b)
__m128i _mm_add_epi32 (__m128i a, __m128i b)
__m128i _mm_add_epi64 (__m128i a, __m128i b)

最坑的一个就是整数乘法,还是以SSE为例:

// mul
__m128i _mm_mul_epi32 (__m128i a, __m128i b)
__m128i _mm_mul_epu32 (__m128i a, __m128i b)
// mullo
__m128i _mm_mullo_epi16 (__m128i a, __m128i b)
__m128i _mm_mullo_epi32 (__m128i a, __m128i b)
__m128i _mm_mullo_epi64 (__m128i a, __m128i b)
// mulhi
__m128i _mm_mulhi_epi16 (__m128i a, __m128i b)
__m128i _mm_mulhi_epu16 (__m128i a, __m128i b)

可以看到有三种不同功能的乘法,如果没有事先了解过的话很极其容易用错版本,强烈建议使用之前到Intel Intrinsics Guide查看一下功能描述。

参考文献

SSE指令集学习:Compiler Intrinsic

Intel Intrinsics Guide

AVX / AVX2 指令编程

第4篇:C/C++ 结构体及其数组的内存对齐

在C/C++代码中使用SSE等指令集的指令(3)SSE指令集基础

__declspec(align())内存对齐

C++11 内存对齐 alignof alignas

GCC平台C++17 新特性aligned_new 的使用


本文发布于2022年12月7日

最后修改于2022年12月7日

SSE与AVX指令基础介绍与使用的更多相关文章

  1. SIMD指令集——一条指令操作多个数,SSE,AVX都是,例如:乘累加,Shuffle等

    SIMD指令集 from:https://zhuanlan.zhihu.com/p/31271788 SIMD,即Single Instruction, Multiple Data,一条指令操作多个数 ...

  2. [转]SIMD、MMX、SSE、AVX、3D Now!、NEON

    转载来源<[整理]SIMD.MMX.SSE.AVX.3D Now!.neon> 本文摘取部分内容,详细请看原文. SIMD NEON是通用的SIMD(单指令多数据)引擎. 对于SISD,每 ...

  3. php数据结构课程---1、数据结构基础介绍(程序是什么)

    php数据结构课程---1.数据结构基础介绍(程序是什么) 一.总结 一句话总结: 程序=数据结构+算法 设计好数据结构,程序就等于成功了一半. 数据结构是程序设计的基石. 1.数据的逻辑结构和物理结 ...

  4. DeepFaceLab: SSE,AVX, OpenCL 等版本说明!

    Deep Fake Lab早期只有两个版本,一个是专门正对NVIDIA显卡的CUDA9的版本,另一个是支持CPU的版本. 三月初该项目作者对tenserFlow,Cuda的版本进行了升级,预编译的软件 ...

  5. XML基础介绍【一】

    XML基础介绍[一] 1.XML简介(Extensible Markup Language)[可扩展标记语言] XML全称为Extensible Markup Language, 意思是可扩展的标记语 ...

  6. Web3D编程入门总结——WebGL与Three.js基础介绍

    /*在这里对这段时间学习的3D编程知识做个总结,以备再次出发.计划分成“webgl与three.js基础介绍”.“面向对象的基础3D场景框架编写”.“模型导入与简单3D游戏编写”三个部分,其他零散知识 ...

  7. C++ 迭代器 基础介绍

    C++ 迭代器 基础介绍 迭代器提供对一个容器中的对象的访问方法,并且定义了容器中对象的范围.迭代器就如同一个指针.事实上,C++的指针也是一种迭代器.但是,迭代器不仅仅是指针,因此你不能认为他们一定 ...

  8. Node.js学习笔记(一)基础介绍

    什么是Node.js 官网介绍: Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine. Node.js us ...

  9. Node.js 基础介绍

    什么是Node.js 官网介绍: Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine. Node.js us ...

  10. AngularJS指令基础(一)

    AngularJS指令基础(一) 1.什么是指令:粗暴的理解就是,自定义HTML标签.专业理解是指,angularJS扩展具有自定义功能的HTML元素的途径. 2.什么时候用到指令:需求是变化的.多样 ...

随机推荐

  1. dllimport 和 dllexport

    Dll 在需要暴露接口的头文件里添加 dllexport 声明,比如, #define DllExport __declspec( dllexport ) class DllExport C { in ...

  2. C++ 多线程的错误和如何避免(7)

    要以相同顺序获取多个锁 多线程在加锁解锁时,可能会出现死锁问题,比如, 线程 1 在加锁 mutex A 后,继续尝试获取 mutex B,而 mutex B 已经被线程 2 获取,而线程 2 在等待 ...

  3. queryset高级用法:select_related

    在提取某个模型的数据的同时,也提前将相关联的数据提取出来.比如提取文章数据,可以使用select_related将author信息提取出来,以后再次使用article.author的时候就不需要再次去 ...

  4. 03-Redis系列之-高级用法详解

    慢查询 生命周期 我们配置一个时间,如果查询时间超过了我们设置的时间,我们就认为这是一个慢查询. 慢查询发生在第三阶段 客户端超时不一定慢查询,但慢查询是客户端超时的一个可能因素 两个配置 slowl ...

  5. 【Azure 应用程序见解】通过Azure Funciton的门户启用Application Insights后,Application Insights无法收到监控数据

    问题描述 比较早期创建的Azure Funciton服务,近期发现在门户中已经启用了Application Insights功能,但是正确配置Applicaiton Insights后,却无法获取关联 ...

  6. Nebula Graph 特性讲解——RocksDB 统计信息的收集和展示

    由于 Nebula Graph 的底层存储使用了 RocksDB,出于运维管理需要,我们的社区用户 @chenxu14 在 pr#2243 为 Nebula Graph 贡献了 RocksDB 统计信 ...

  7. 基于centos7 创建一个jdk8的镜像

    前言: 直接使用docker拉取jdk8镜像因有时区问题,设置后也不生效,所以干脆自己做一个 以下是Dockerfile文件 FROM centos:7 RUN ln -snf /usr/share/ ...

  8. 【技术积累】MySQL优化及进阶

    MySql优化及进阶 一.MySQL体系结构 连接层:是一些客户端和链接服务,包含本地sock 通信和大多数基于客户端/服务端工具实现的类似于 TCP/IP的通信 服务层:大多数的核心服务功能,如SQ ...

  9. Istio中的核心资源及定义

    Istio 的核心资源主要包括以下几种: 1. Gateway 用于建模边缘网关,可以为进入或离开网格的流量提供专用的入口和出口点.Gateway 定义了在网格边缘运行的负载均衡器,用于接收传入或传出 ...

  10. ARM的无线ble IP Cordio-B50 stack and profiles简析

    一 简介 人家英文写的很清楚,我就不蹩脚额翻译了. Cordio-B50 stack is designed specifically for Bluetooth low energy single- ...