C/C++指令集介绍以及优化(主要针对SSE优化)

您所在的位置:网站首页 sse的计算 C/C++指令集介绍以及优化(主要针对SSE优化)

C/C++指令集介绍以及优化(主要针对SSE优化)

2023-09-06 20:22| 来源: 网络整理| 查看: 265

前言:最近在做一些OpenCV的优化相关的东西,发现OpenCV现在的执行效率很高的原因一部分是来自于底层的优化,比如指令集优化,但是一直没找到比较系统性的关于CPU指令集优化的文章或者是书籍,于是自己打算做一个总结,鉴于水平有限,有不正确的地方还望有大佬指正。

一、CPU指令集优化的概述

1.1 数据并行的两种实现

在计算机体系中,数据并行有两种实现路径:

MIMD(Multiple Instruction Multiple Data,多指令流多数据流)SIMD(Single Instruction Multiple Data,单指令流多数据流)。

其中MIMD的表现形式主要有多发射、多线程、多核心,在当代设计的以处理能力为目标驱动的处理器中,均能看到它们的身影。

同时,随着多媒体、大数据、人工智能等应用的兴起,为处理器赋予SIMD处理能力变得愈发重要,因为这些应用存在大量细粒度、同质、独立的数据操作,而SIMD天生就适合处理这些操作。

SIMD结构有三种变体:向量体系结构、多媒体SIMD指令集扩展和图形处理单元。

注意:SIMD本身并不是一种指令集,而是一种处理思想哦,现在的一些指令集都支持SIMD。

 

1.2 各个CPU指令集的发展简介

(1)MMX指令——Multi Media eXtension,多媒体扩展指令集

1996年,MMX指令集率先在Pentium处理器中使用,MMX指令集支持算数、比较、移位等运算,MMX指令集的向量寄存器是64bit。

(2)SSE指令集系列——Streaming SIMD Extensions,单指令多数据流扩展

SSE在1999年率先在Pentium3中出现,向量寄存器由MMX的64bit拓展到128bit;SSE2在2002年出现,包括了SIMD的浮点和整型运算的指令以及整型和浮点数据之间的转换;SSE3在2004年出现,支持不对其访问,处理虚数运算的复杂指令以及水平加减操作运算指令;SSE4.1在2006年出现,加入了处理字符串文本和面向应用的优化指令;SSE4.2指令

总结:所有的SSE系列指令的向量寄存器都是128bit哦。

(3)AVX指令集系列——Advanced Vector Extensions

AVX指令集是Sandy Bridge和Larrabee架构下的新指令集,AVX是在之前的SSE128位扩展到和256位的单指令多数据流。

AVX出现在2008年,由128bit拓展到256bit,增强了数据重排和灵活的不对齐地址访问;AVX2出现在2011年,增加了256bit的整数向量操作,融合乘加,跨通道数据重排等等;AVX-512出现在2014年,由256bit拓展到512bit;

(4)Intel IMCI指令集

IMCI出现在2010年,向量寄存器长度拓展到512bit。

(5)其他指令集

AES、FMA3、EM64-T、VT-x等等

这里有一张指令集大致的发展过程表格

1.3 关于CPU指令的说明

上面的一些指令集,都是针对Intel的CPU指令的,各个芯片厂商都有相应的指令集,只不过是名称不一样,如AMD的也同样包含很多指令集,这里就不介绍了。

其他 SIMD 扩展部件还包括

摩托罗拉 PowerPC 处理器的 AltiVec、Sun 公司 SPARC 处理器中的 VIS、HP 公司 PA-RISC 处理器中的 MAX、DEC 公司 Alpha 处理器中的 MVI-2、MIPS公司 V 处理器中的 MDMX、AMD处理器中的3DNow!、ARM内核中的NEON、CEVA公司的VCU 等。

另外,SIMD 扩展部件最初仅用于多媒体领域和数字信号处理器中,后来,研究人员将SIMD 扩展部件应用到高性能计算机中,如,IBM 的超级计算机 BlueGene/L 和国产的神威蓝光超级计算机中都集成短向量扩展部件。国产处理器中,龙芯、迈创以及魂芯一号都含有 SIMD 扩展部件。

1.4 如何查看自己的CPU支持哪一些指令集

推荐一款小工具 ,名字叫做 CPU-Z,

查看相应的GPU的工具叫做 GPU-Z,

我的CPU信息如下:

在Linux下可以使用cat /proc/cpuinfo来查看CPU支持哪些指令集。

 

二、关于指令集的一些问题集中回答

2.1 几个问题

(1)浮点计算 vs 整数计算 为什么要分开讲呢?因为在指令集中也是分开的,另外,由于浮点数占4个字节或者8个字节,而整数却可以分别占1,2,4个字节按照应用场合不同使用的不同,因此向量化加速也不同。因此一个指令最多完成4个浮点数计算。而可以完成16个int8_t数据的计算。

(2)优化技巧 注意指令的顺序,为什么呢,因为CPU是流水线工作的,因此相邻的指令开始的执行的时间并非一个指令执行完毕之后才会开始,但是一旦遇到数据联系,这时候会发生阻塞,如果我们很好的安排指令的顺序,使得数据相关尽量少发生,或者发生的时候上一个指令已经执行完了。因此注意稍微修改指令的执行顺序就会使得代码变快。  

2.2 指令集的一些问题

(1)没有统一的移植标准。

就以SSE指令而言。SSE的指令集是X86架构CPU特有的,对于ARM架构、MIPS架构等CPU是不支持的,所以使用了SSE指令集的程序,是不具备可移植标准的。

不仅如此,前面说过Intel和AMD对于同样的128bit向量的指令语法是不一样的,所以,在Intel之下所写的代码并不能一直到AMD的机器上进行指令集加速,其它的也一样,也就是说,写的某一种指令加速代码,不具备完全的可移植性。

SIMD指令,可以一次性装载多个元素到寄存器。如果是128位宽度,则可以一次装载4个单精度浮点数。这4个float可以一次性地参与乘法计算,理论上可提速4倍。不同的平台有不同的SIMD指令集,如Intel平台的指令集有MMX、SSE、AVX2、AVX512等(后者是对前者的扩展,本质一样),ARM平台是128位的NEON指令集。如果你希望用SIMD给算法加速,你首先需要学习不同平台的SIMD指令集,并为不同的平台写不同的代码,最后逐个测试准确性。这样无法实现write once, run anywhere的目标。

(2)针对指令集没办法转移的解决方案

OpenCV 4.x中提供了强大的统一向量指令(universal intrinsics),使用这些指令可以方便地为算法提速。所有的计算密集型任务皆可使用这套指令加速,并不是专门针对计算机视觉算法。目前OpenCV的代码加速实现基本上都基于这套指令。OpenCV设计了一套统一的向量指令universal intrinsics,可以让你写一份代码,在不同平台上都可以实现向量加速

2.3 指令集优化代码的一般步骤

(1)第一步:即所谓的load步骤

指的是需要将数据从内存加载(load)到CPU的内存储里面;

(2)第二步:即所谓的运算

将加载进来的数据进行加减乘除等等运算;

(3)第三步:即所谓的store步骤

将运算的结果需要重新存储到内存里面;

 

三、SSE指令集的使用说明

SSE本质上类似于一个向量处理器,所谓的向量处理器实际上就是进行向量的运算,

包括了4个主要部分:单精确度浮点数运算指令、整数运算指令(为MMX的延伸,并与MMX使用同样的暂存器)、Cache控制指令、状态控制指令。

 

3.1 如何使用SSE指令

       使用SSE指令有两种方式:

一是直接在C/C++中嵌入(汇编)指令;二是使用Intel C++ Compiler或是Microsoft Visual C++中提供的支持SSE指令集的intrinsics内联函数。

从代码可读和维护角度讲,推荐使用intrinsics内联函数的形式。intrinsics是对MMX、SSE等指令集的一种封装,以函数的形式提供,使得程序员更容易编写和使用这些高级指令,在编译的时候,这些函数会被内联为汇编,不会产生函数调用的开销。想要使用SSE指令,则需要包含对应的头文件:

#include   //mmx #include  //sse #include  //sse2 #include  //sse3

备注:本文所介绍的是在VS平台中VC++所提供的intrinstic内联函数的使用说明。这样使用起来就很简单了,主要是包含两部分,数据类型和数据操作指令(加载load、运算、存储store),另外,虽然现在SSE已经有了很多个版本,SSE、SSE2、SSE3、SSE4.1、SSSE4.2等等,它们之间有所差别,但是大致的使用以及思想原理是一致的。  

3.2 SSE的数据类型

SSE指令中intrinsics函数的数据类型为:

(1)__m128(单精度浮点数),如果使用sizeof(__m128)计算该类型大小,结果为16,即等于四个浮点数长度。__declspec(align(16))做为数组定义的修释符,表示该数组是以16字节为边界对齐的,因为SSE指令大部分支持这种格式的内存数据。他的定义如下:

typedef struct __declspec(intrin_type) __declspec(align(16)) __m128 {    float m128_f32[4]; } __m128;

除__m128外、还包括

(2)__m128d(双精度浮点数)

(3)__m128i(整型)。其中__m128i是一个共用体类型(union),其定义如下 :

typedef union __declspec(intrin_type) __declspec(align(16)) __m128i { __int8 m128i_i8[16]; __int16 m128i_i16[8]; __int32 m128i_i32[4]; __int64 m128i_i64[2]; unsigned __int8 m128i_u8[16]; unsigned __int16 m128i_u16[8]; unsigned __int32 m128i_u32[4]; unsigned __int64 m128i_u64[2]; } __m128i;

注意数据类型前面是两个短的下划线哦!!!

3.3 数据操作指令的一般格式(包括了数据加载load、数据运算、数据存储store)

SSE指令通常由三部分构成:

(1)第一部分为前缀_mm(多媒体扩展指令集),表示该函数属于SSE指令集(前面只有一个短下划线)

(2)第二部分为指令的操作类型,

如加载数据一般是_load以及它的变种如_add、_mul等以及这些运算的变种(一个短下划线)存储数据_store以及它的一些变种

(3)第三部分通常由一个短下划线加上两个字母组成。

第一个字母表示对结果变量的影响方式,为p或s。                                                                        

p(packed:包裹指令) :该指令对xmm寄存器中的每个元素进行运算,即一次对四个浮点数(data0~data3)均进行计算;s(scalar:标量指令):该指令对寄存器中的第一个元素进行运算,即一次只对xmm寄存器中的data0进行计算。

如上图所示,如果针对SSE的四个数所组成的向量,如果是packed模式,则进行向量运算,如果是scalar模式,只会对第一组数据进行运算。

第二个字母表示参与运算的数据类型,

s表示32位浮点数,d表示64位浮点数,i32表示带符号32位整型,i64表示带符号64位整型,u32表示无符号32位整型,

以此类推。由于SSE只支持32位浮点数的运算,所以你可能会在这些指令封装函数中找不到包含非s修饰符的,但你可以在MMX和SSE2的指令集中去认识它们。

比如下图给出了指令集一一般的组成三部分。

第三部分补充,第三部分还可以是  _pi**格式或者是_*pi**格式。

_pi**(**为长度,可以是8,16,32,64)packed操作所有的**位有符号整数,使用的寄存器长度为64位;_epi**(**为长度)packed操作所有的**位的有符号整数,使用的寄存器长度为128位;_epu** 同样的道理 packed操作所有的**位的无符号整数;  

 

3.4 使用SSE指令注意的问题

(1)SSE指令的内存对齐要求

SSE中大部分指令要求地址是16bytes对齐的,要理解这个问题,以_mm_load_ps函数来解释,这个函数对应于loadps的SSE指令。其原型为:

extern __m128 _mm_load_ps(float const*_A);

可以看到,它的输入是一个指向float的指针,返回的就是一个__m128类型的数据,从函数的角度理解,就是把一个float数组的四个元素依次读取,返回一个组合的__m128类型的SSE数据类型,从而可以使用这个返回的结果传递给其它的SSE指令进行运算,比如加法等;从汇编的角度理解,它对应的就是读取内存中连续四个地址的float数据,将其放入SSE新的暂存器中,从而给其他的指令准备好数据进行计算。其使用示例如下:

float input[4] = { 1.0f, 2.0f, 3.0f, 4.0f };   __m128 a = _mm_load_ps(input);  

这里加载正确的前提是:input这个浮点数阵列都是对齐在16 bytes的边上。否则加载的结果和预期的不一样。如果没有对齐,就需要使用_mm_loadu_ps函数,这个函数用于处理没有对齐在16bytes上的数据,但是其速度会比较慢。

备注:内存对齐

关于内存对齐的问题,这里就不详细讨论什么是内存对齐了,以及如何指定内存对齐方式。这里主要提一下,SSE的intrinsics函数中的扩展的方式:

对于上面的例子,如果要将input指定为16bytes对齐,可以采用的方式是:__declspec(align(16)) float input[4];

那么,为了简化,在xmmintrin.h中定义了一个宏_MM_ALIGN16来表示上面的含义,即:_MM_ALIGN16 float input[4];

(2)大小端问题:

这个只是使用SSE指令的时候要注意一下,我们知道,x86的little-endian特性,位址较低的byte会放在暂存器的右边。也就是说,若以上面的input为例,即

float input[4] = { 1.0f, 2.0f, 3.0f, 4.0f };   __m128 a = _mm_load_ps(input);  

在载入到XMM暂存器后,暂存器中的 DATA0会是1.0,而DATA1是2.0,DATA2是3.0,DATA3是4.0。如下:

如果需要以相反的顺序载入的话,可以用_mm_loadr_ps 这个intrinsic,根据需要进行选择。

(3)SSE指令的定址/寻址方式

这一块儿内容涉及到很多微机原理的底层部分,还不是很懂,望有大佬告知!!!

 

3.5 常用的一些SSE指令简介

(1)load系列,用于加载数据(从内存到暂存器),大部分需要16字节对齐

__m128  _mm_load_ss(float *p) //将一个单精度浮点数加载到寄存器的第一个字节,其它三个字节清零(r0 := *p, r1 := r2 := r3 := 0.0)   __m128  _mm_load_ps(float *p) //将四个单精度浮点数加载到寄存器(r0 := p[0], r1 := p[1], r2 := p[2], r3 := p[3])   __m128  _mm_load1_ps(float *p)//将p地址的值加载到暂存器的四个字节,需要多条指令完成。从性能考虑,在内层循环不要使用这类指令(r0 := r1 := r2 := r3 := *p)   __m128  _mm_loadh_pi(__m128 a, __m64 *p)// __m128  _mm_loadl_pi(__m128 a, __m64 *p)//   __m128  _mm_loadr_ps(float *p)//以_mm_load_ps反向的顺序加载,需要多条指令完成。(r0 := p[3], r1 := p[2], r2 := p[1], r3 := p[0])   __m128  _mm_loadu_ps(float *p)//_mm_load_ps一样的加载,但是不要求地址是16字节对齐

(2)set系列,用于加载数据,类似于load操作,但是大部分需要多条指令完成,可能不需要16字节对齐

__m128 _mm_set_ss(float w)//对应于_mm_load_ss的功能,不需要字节对齐,需要多条指令(r0 = w, r1 = r2 = r3 = 0.0)   __m128 _mm_set_ps(float z, float y, float x, float w)//对应于_mm_load_ps的功能,参数是四个单独的单精度浮点数,所以也不需要字节对齐,需要多条指令。(r0=w, r1 = x, r2 = y, r3 = z,注意顺序)   __m128 _mm_set1_ps(float w)//对应于_mm_load1_ps的功能,不需要字节对齐,需要多条指令。(r0 = r1 = r2 = r3 = w)   __m128 _mm_setr_ps(float z, float y, float x, float w)//对应于_mm_loadr_ps功能,不需要字节对齐,需要多条指令。(r0=z, r1 = y, r2 = x, r3 = w,注意顺序)   __m128 _mm_setzero_ps()//清0操作,只需要一条指令。(r0 = r1 = r2 = r3 = 0.0)

(3)store系列,将计算结果等SSE暂存器的数据保存到内存中,与load系列函数的功能对应,基本上都是一个反向的过程。

void _mm_store_ss(float *p, __m128 a)  //一条指令,*p = a0 void _mm_store_ps(float *p, __m128 a)  //一条指令,p[i] = a[i] void _mm_store1_ps(float *p, __m128 a) //多条指令,p[i] = a0 void _mm_storeh_pi(__m64 *p, __m128 a) // void _mm_storel_pi(__m64 *p, __m128 a) // void _mm_storer_ps(float *p, __m128 a) //反向,多条指令 void _mm_storeu_ps(float *p, __m128 a) //一条指令,p[i] = a[i],不要求16字节对齐 void _mm_stream_ps(float *p, __m128 a) //直接写入内存,不改变cache的数据

(4)算数指令系列,SSE提供了大量的浮点运算指令,包括加法、减法、乘法、除法、开方、最大值、最小值等等

__m128 _mm_add_ss (__m128 a, __m128 b)   __m128 _mm_add_ps (__m128 a, __m128 b)

当然算数指令有很多,这里只列举了两个,应该说主要是算术运算指令。(5)数据类型转换系列

__mm_cvtss_si32   //单精度浮点数转换为有符号32位整数 __mm_cvttss_si32  //单精度浮点数转换为有符号32位整数(带截断操作) __mm_cvtpi16_ps   //16位有符号整数转换为单精度浮点数

对第一个指令进行详细说明,如下图所示:

3.6 SSE指令的加速效果

(1)对于scalar模式的SSE加速

是不是只要采用SSE进行加速就一定会加快运行速度呢?当然不是了,SSE包含packed和scalar两种方式,我们采用scalar运算由于每一次只计算一个值,通过实验对比,使用SSE的scalar加速反而还没有原始的C代码速度快,

(2)对于packed模式的加速

使用packed模式加速,虽然每一次运算4个单精度浮点数,使用SSE优化之后,我们的代码不一定会得到4倍速的提升,因为编译器可能已经自动对某些代码进行SSE优化了。

 

四、SSE优化的具体实例

案例说明,比如我要经过两个矩阵的逐元素乘积,我分别通过三种方式来对比

方式一:原生的C/C++代码方式二:使用SSE的scalar进行优化方式三:使用OpenCV自带的mul函数

方式一:原生的C/C++代码

//将Mat1和Mat2矩阵元素乘积之后更新到Mat2 void mat_multi(Mat m1, Mat m2) { for (int i = 0; i < m1.rows; i++) { float * pixel_1 = (float *)m1.data + i * m1.step / 4; //32f float * pixel_2 = (float *)m2.data + i * m2.step / 4; //32f for (int j = 0; j < m1.cols; j++) { *pixel_2 = (*pixel_1) * (*pixel_2); pixel_1 += 1; pixel_2 += 1; } } }

方式二:使用SSE的scalar进行优化

void sse_mat_multi(Mat m1, Mat m2) { for (int i = 0; i < m1.rows; i++) { float * pixel_1 = (float *)m1.data + i * m1.step / 4; //32f float * pixel_2 = (float *)m2.data + i * m2.step / 4; //32f for (int j = 0; j < m1.cols; j++) { __m128 sse_1 = _mm_load_ps(pixel_1); //将a地址指向的值复制给SSEA __m128 sse_2 = _mm_load_ps(pixel_2); //将b地址指向的值复制给SSEB __m128 h = _mm_mul_ss(sse_1, sse_2); //声明了变量并赋值(1.0f) _mm_storer_ps(pixel_2, h); pixel_1 += 1; pixel_2 += 1; } } }

结果测试

int main(int argv, char *args[]) { clock_t start, end; Mat m1 = Mat(Size(10000, 10000), CV_32FC1); m1.setTo(1); Mat m2 = Mat(Size(10000, 10000), CV_32FC1); m1.setTo(2); start = clock(); mat_multi(m1, m2); end = clock(); std::cout


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3