SIMD初学

SIMD初学

在学习SIMD之前,我们首先需要了解两个概念。
浮点运算指令分为两大类:Packed(矢量) 和Scalar(标量)。
Packed指令是一次对寄存器中的四个浮点数(即DATA0 ~ DATA3)均进行计算,而Scalar一次则只对寄存器中的DATA0进行计算。如下图所示:
SIMD初学_第1张图片

1.SIMD的历史与指令集分类

SIMD(Single Instruction Multiple Data)即单指令流多数据流,是一种采用一个控制器来控制多个处理器,同时对一组数据(又称“数据向量”)中的每一个分别执行相同的操作从而实现空间上的并行性的技术。简单来说就是一个指令能够同时处理多个数据。
SIMD于20世纪70年代首次引用于ILLIAC IV大规模并行计算机上。而大规模应用到消费级计算机则是在20实际90年代末。

1996年Intel推出了X86的MMX(MultiMedia eXtension)指令集扩展,MMX定义了8个寄存器,称为MM0到MM7,以及对这些寄存器进行操作的指令。每个寄存器为64位宽,可用于以“压缩”格式保存64位整数或多个较小整数,然后可以将单个指令一次应用于两个32位整数,四个16位整数或8个8位整数。

intel在1999年又推出了全面覆盖MMX的SSE(Streaming SIMD Extensions, 流式SIMD扩展)指令集,并将其应用到Pentium III系列处理器上,SSE添加了八个新的128位寄存器(XMM0至XMM7),而后来的X86-64扩展又在原来的基础上添加了8个寄存器(XMM8至XMM15)。SSE支持单个寄存器存储4个32位单精度浮点数,之后的SSE2则支持单个寄存器存储2个64位双精度浮点数,2个64位整数或4个32位整数或8个16位短整形。SSE2之后还有SSE3,SSE4以及AVX,AVX2等扩展指令集。

AVX引入了16个256位寄存器(YMM0至YMM15),AVX的256位寄存器和SSE的128位寄存器存在着相互重叠的关系(XMM寄存器为YMM寄存器的低位),所以最好不要混用AVX与SSE指令集,否在会导致transition penalty(过渡处罚)。

这里主要讲AVX和SSE这两种指令集。
AVX与SSE支持的数据类型如下:
SIMD初学_第2张图片

2.如何使用SIMD

使用SIMD总共有下面五种方法,但是这里主要学习使用**内置函数(intrinsics)**的方法。
1.最简单的方法是使用Intel开发的跨平台函数库(IPP,Intel Integrated Performance Primitives ),里面的函数实现都使用了SIMD指令进行优化。
2.借助于Auto-vectorization(自动矢量化),借助编译器将标量操作转化为矢量操作。
3.使用编译器指示符(compiler directive),如Cilk里的#pragma simd和OpenMP里的#pragma omp simd。

void add_floats(float * a,float * b,float * c,float * d,float * e,int n)
{
    int i;
#pragma simd
    for(i = 0; i <n; i ++{
        a [i] = a [i] + b [i] + c [i] + d [i] + e [i];
    }
}

4.使用内置函数(intrinsics)的方式,如下所示,使用SSE _mm_add_ps 内置函数,一次执行8个单精度浮点数的加法:

int  main()
{
	__m128 v0 = _mm_set_ps(1.0f, 2.0f, 3.0f, 4.0f);
	__m128 v1 = _mm_set_ps(1.0f, 2.0f, 3.0f, 4.0f);

	__m128 result = _mm_add_ps(v0, v1);
}

5.是使用汇编直接操作寄存器,直接使用汇编又难又麻烦。

3.SSE/AVX Intrinsics简介

1.头文件
SSE/AVX指令主要定义于以下一些头文件中:
: SSE, 支持同时对4个32位单精度浮点数的操作。
: SSE 2, 支持同时对2个64位双精度浮点数的操作。
: SSE 3, 支持对SIMD寄存器的水平操作(horizontal operation),如hadd, hsub等…。
: SSSE 3, 增加了额外的instructions。
: SSE 4.1, 支持点乘以及更多的整形操作。
: SSE 4.2, 增加了额外的instructions。(这个支持之前所有版本的SEE)
: AVX, 支持同时操作8个单精度浮点数或4个双精度浮点数。

2.命名规则(很重要)
SSE/AVX提供的数据类型和函数的命名规则如下:
a.数据类型通常以_mxxx(T)的方式进行命名,其中xxx代表数据的位数,如SSE提供的__m128为128位,AVX提供的__m256为256位。T为类型,若为单精度浮点型则省略,若为整形则为i,如__m128i,若为双精度浮点型则为d,如__m256d。
b.操作浮点数的内置函数命名方式为:_mm(xxx)_name_PT。 xxx为SIMD寄存器的位数,若为128m则省略,如_mm_addsub_ps,若为_256m则为256,如_mm256_add_ps。 name为函数执行的操作的名字,如加法为_mm_add_ps,减法为_mm_sub_ps。 P代表的是对矢量(packed data vector)还是对标量(scalar)进行操作,如_mm_add_ss是只对最低位的32位浮点数执行加法,而_mm_add_ps则是对4个32位浮点数执行加法操作。 T代表浮点数的类型,若为s则为单精度浮点型,若为d则为双精度浮点,如_mm_add_pd和_mm_add_ps。
c.操作整形的内置函数命名方式为:_mm(xxx)_name_epUY。xxx为SIMD寄存器的位数,若为128位则省略。 name为函数的名字。U为整数的类型,若为无符号类型则为u,否在为i,如_mm_adds_epu16和_mm_adds_epi16。Y为操作的数据类型的位数,如_mm_cvtpd_pi32。

3.内置函数(instructions)
1).存取操作(load/store/set)

    __attribute__((aligned(32))) int d1[8] = {-1,-2,-3,-4,-5,-6,-7,-8};
    __m256i d = _mm256_load_si256((__m256i*)d1);//装在int可以使用指针类型转换 必须32位对齐
这里说明一下,使用load函数要保证数组的起始地址32位字节对齐。在linux下就需要__attribute__((aligned(32))),Windows下要用__declspec(align(32))

这里有没有疑问,为什么要字节对齐呢?
现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。
对齐的作用和原因:各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些架构的CPU在访问一个没有进行对齐的变量的时候会发生错误,那么在这种架构下编程必须保证字节对齐.其他平台可能没有这种情况,但是最常见的是如果不按照适合其平台要求对数据存放进行对齐,会在存取效率上带来损失。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为32位系统)如果存放在偶地址开始的地方,那么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数据。显然在读取效率上下降很多。

上面是从手册查询到的load系列的函数。其中,
_mm_load_ss用于scalar的加载,所以,加载一个单精度浮点数到暂存器的低字节,其它三个字节清0,(r0 := *p, r1 := r2 := r3 := 0.0)。
_mm_load_ps用于packed的加载(下面的都是用于packed的),要求p的地址是16字节对齐,否则读取的结果会出错,(r0 := p[0], r1 := p[1], r2 := p[2], r3 := p[3])。
_mm_load1_ps表示将p地址的值,加载到暂存器的四个字节,需要多条指令完成,所以,从性能考虑,在内层循环不要使用这类指令。(r0 := r1 := r2 := r3 := *p)。
_mm_loadh_pi和_mm_loadl_pi分别用于从两个参数高底字节等组合加载。具体参考手册。
_mm_loadr_ps表示以_mm_load_ps反向的顺序加载,需要多条指令完成,当然,也要求地址是16字节对齐。(r0 := p[3], r1 := p[2], r2 := p[1], r3 := p[0])。
_mm_loadu_ps和_mm_load_ps一样的加载,但是不要求地址是16字节对齐,对应指令为movups。

store系列可以将SSE/AVX提供的类型中的数据存储到内存中,如:

void test() 
{
	__declspec(align(16)) float p[] = { 1.0f, 2.0f, 3.0f, 4.0f };
	__m128 v = _mm_load_ps(p);

	__declspec(align(16)) float a[] = { 1.0f, 2.0f, 3.0f, 4.0f };
	_mm_store_ps(a, v);
}

_mm_store_ps可以__m128中的数据存储到16字节对齐的内存。
_mm_storeu_ps不要求存储的内存对齐。
_mm_store_ps1则是把__m128中最低位的浮点数存储为4个相同的连续的浮点数,即:p[0] = m[0], p[1] = m[0], p[2] = m[0], p[3] = m[0]。
_mm_store_ss是存储__m128中最低位的位浮点数到内存中。
_mm_storer_ps是按相反顺序存储__m128中的4个浮点数。

set系列可以直接设置SSE/AVX提供的类型中的数据,如:

__m128 v = _mm_set_ps(0.5f, 0.2f, 0.3f, 0.4f);

_mm_set_ps可以将4个32位浮点数按相反顺序赋值给__m128中的4个浮点数,即:_mm_set_ps(a, b, c, d) : m[0] = d, m[1] = c, m[2] = b, m[3] = a。
_mm_set_ps1则是将一个浮点数赋值给__m128中的四个浮点数。
_mm_set_ss是将给定的浮点数设置到__m128中的最低位浮点数中,并将高三位的浮点数设置为0.
_mm_setzero_ps是将__m128中的四个浮点数全部设置为0.

2). 算术运算
SSE/AVX提供的算术运算操作包括:
_mm_add_ps,_mm_add_ss 等加法系列
_mm_sub_ps,_mm_sub_pd 等减法系列
_mm_mul_ps,_mm_mul_epi32 等乘法系列
_mm_div_ps,_mm_div_ss 等除法系列
_mm_sqrt_pd,_mm_rsqrt_ps 等开平方系列
_mm_rcp_ps,_mm_rcp_ss 等求倒数系列
_mm_dp_pd,_mm_dp_ps 计算点乘
此外还有向下取整,向上取整等运算,这里只列出了浮点数支持的算术运算类型,还有一些整形的算术运算类型未列出。

3).比较运算
SSE/AVX提供的比较运算操作包括:
_mm_max_ps逐分量对比两个数据,并将较大的分量存储到返回类型的对应位置中。
_mm_min_ps逐分量对比两个数据,并将较小的分量存储到返回类型的对应位置中。
_mm_cmpeq_ps逐分量对比两个数据是否相等。
_mm_cmpge_ps逐分量对比一个数据是否大于等于另一个是否相等。
_mm_cmpgt_ps逐分量对比一个数据是否大于另一个是否相等。
_mm_cmple_ps逐分量对比一个数据是否小于等于另一个是否相等。
_mm_cmplt_ps逐分量对比一个数据是否小于另一个是否相等。
_mm_cmpneq_ps逐分量对比一个数据是否不等于另一个是否相等。
_mm_cmpnge_ps逐分量对比一个数据是否不大于等于另一个是否相等。
_mm_cmpngt_ps逐分量对比一个数据是否不大于另一个是否相等。
_mm_cmpnle_ps逐分量对比一个数据是否不小于等于另一个是否相等。
_mm_cmpnlt_ps逐分量对比一个数据是否不小于另一个是否相等。

4).逻辑运算
SSE/AVX提供的逻辑运算操作包括:
_mm_and_pd对两个数据逐分量and
_mm_andnot_ps先对第一个数进行not,然后再对两个数据进行逐分量and
_mm_or_pd对两个数据逐分量or
_mm_xor_ps对两个数据逐分量xor

详情可查Intel的Intrinsics Guide

你可能感兴趣的:(SIMD,并行计算)