SIMD简介

2023-11-09

SIMD简介 - 知乎本篇文章包含的内容有SIMD指令集简介以及简短的practice环节。 1.SIMD的历史与分类SIMD( Single Instruction Multiple Data)即单指令流多数据流,是一种采用一个控制器来控制多个处理器,同时对一组数据(又称“数…https://zhuanlan.zhihu.com/p/55327037

本篇文章包含的内容有SIMD指令集简介以及简短的practice环节。

1.SIMD的历史与分类

SIMD(Single Instruction Multiple Data)即单指令流多数据流,是一种采用一个控制器来控制多个处理器,同时对一组数据(又称“数据向量”)中的每一个分别执行相同的操作从而实现空间上的并行性的技术。简单来说就是一个指令能够同时处理多个数据。

标量运算与SIMD运算对比

如上图所示,使用标量运算一次只能对一对数据执行乘法操作,而采用SIMD乘法指令,则一次可以对四对数据同时执行乘法操作。

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(过渡处罚),两种寄存器的关系如下图:

AVX256位寄存器与SSE128位寄存器的关系

AVX与SSE支持的数据类型如下

AVX与SSE支持的数据类型

不同处理器对于SIMD指令集的支持如下图:

不同处理器对于SIMD指令集的支持

如果想知道CPU的SIMD支持等级可以使用cpuid指令 ,或者直接使用cpuz软件查看。

2.如何使用SIMD

下图给出了使用SIMD的不同方法:

使用SIMD的不同方法

首先是最简单的方法是使用Intel开发的跨平台函数库(IPP,Intel Integrated Performance Primitives ),里面的函数实现都使用了SIMD指令进行优化。

其次是借助于Auto-vectorization(自动矢量化),借助编译器将标量操作转化为矢量操作。

第三种方法是使用编译器指示符(compiler directive),如Cilk里的#pragma simd和OpenMP里的#pragma omp simd。如下所示,使用#pragma 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];
    }
}

第四种方法则是使用内置函数(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);
}

最后一种方法则是使用汇编直接操作寄存器,当然直接使用汇编有点太麻烦了,所以本篇文章主要介绍的方法是使用intrinsics的方式使用SIMD指令。

3.SSE/AVX Intrinsics介绍

a.头文件

SSE/AVX指令主要定义于以下一些头文件中:

  • <xmmintrin.h> : SSE, 支持同时对4个32位单精度浮点数的操作。
  • <emmintrin.h> : SSE 2, 支持同时对2个64位双精度浮点数的操作。
  • <pmmintrin.h> : SSE 3, 支持对SIMD寄存器的水平操作(horizontal operation),如hadd, hsub等...。
  • <tmmintrin.h> : SSSE 3, 增加了额外的instructions。
  • <smmintrin.h> : SSE 4.1, 支持点乘以及更多的整形操作。
  • <nmmintrin.h> : SSE 4.2, 增加了额外的instructions。
  • <immintrin.h> : AVX, 支持同时操作8个单精度浮点数或4个双精度浮点数。

每一个头文件都包含了之前的所有头文件,所以如果你想要使用SSE4.2以及之前SSE3, SSE2, SSE中的所有函数就只需要包含<nmmintrin.h>头文件。

b.命名规则

SSE/AVX提供的数据类型和函数的命名规则如下:

  1. 数据类型通常以_mxxx(T)的方式进行命名,其中xxx代表数据的位数,如SSE提供的__m128为128位,AVX提供的__m256为256位。T为类型,若为单精度浮点型则省略,若为整形则为i,如__m128i,若为双精度浮点型则为d,如__m256d。
  2. 操作浮点数的内置函数命名方式为:_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。
  3. 操作整形的内置函数命名方式为:_mm(xxx)_name_epUY。xxx为SIMD寄存器的位数,若为128位则省略。 name为函数的名字。U为整数的类型,若为无符号类型则为u,否在为i,如_mm_adds_epu16和_mm_adds_epi16。Y为操作的数据类型的位数,如_mm_cvtpd_pi32。

c.instructions的分类

instructions按执行操作类别的不同主要分为以下几类:

1).存取操作(load/store/set)

load系列可以用来从内存中载入数据到SSE/AVX提供的类型中,如:

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

_mm_load_ps可以从16字节对齐的连续内存中加载4个32位单精度浮点数到__m128数据类型中(若不对齐则加载会出错)。

_mm_loadu_ps同_mm_load_ps的作用相同,但不要求提供的内存地址对齐。

_mm_load_ps1是从内存中载入一个32位浮点数,并重复存储到__m128中的4个浮点数中,即:m[0] = p[0], m[1] = p[0], m[2] = p[0], m[3] = p[0]。

_mm_load_ss则是从内存中载入一个32位浮点数,并将其赋值给__m128中的最低位的浮点数,并将高位的3个浮点数设置为0,即:m[0] = p[0], m[1] = 0, m[2] = 0, m[3] = 0。

_mm_loadr_ps位载入4个32位浮点数并将其反向赋值给__m128中的4个浮点数,即:m[0] = p[3], m[1] = p[2], m[2] = p[1], m[3] = p[0]。

除此之外还有_mm_loadh_pd,_mm_loadl_pi等...

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

5).Swizzle运算

包含_mm_shuffle_ps,_mm_blend_ps, _mm_movelh_ps等。

这里主要介绍以下_mm_shuffle_ps:

void test() 
{
	__m128 a = _mm_set_ps(1, 2, 3, 4);
	__m128 b = _mm_set_ps(5, 6, 7, 8);

	__m128 v = _mm_shuffle_ps(a, b, _MM_SHUFFLE(1, 0, 3, 2)); // 2, 1, 8, 7
}

_mm_shuffle_ps读取两个__m128类型的数据a和b,并按照_MM_SHUFFLE提供的索引将返回的__m128类型数据的低两位设置为a中按索引值取得到的对应值,将高两位设置为按索引值从b中取得到的对应值。索引值在0到3之间,分别以相反的顺序对应__m128中的四个浮点数。

SSE/AVX还提供了类型转换等操作,这里就不做介绍了。

PS:补充一个操作指令:_mm_cvtss_f32,可以获取__m128中的最低位浮点数并返回。

4. Practice环节

现在可以使用之前所介绍的instructions来实现一个简单的数学运算库。

首先需要定义一些宏:

#ifndef SIMD_LEVEL
	#if defined(__AVX__) || defined(__AVX2__)
		#define SIMD_LEVEL 2	//Support SSE4, AVX, AVX2
		#include <immintrin.h>  
	#elif defined(_M_IX86_FP) && (_M_IX86_FP == 2)
		#define SIMD_LEVEL 1	//Support SSE2
		#include <emmintrin.h> 	
	#else
		#define SIMD_LEVEL 0	
	#endif
#endif // !SIMD_LEVEL

#ifndef AMVector
	#if SIMD_LEVEL == 0
		typedef Vector4<float> AMVector;
		typedef const Vector4<float>& CRAMVector;
	#else
		typedef __m128 AMVector;
		typedef const AMVector CRAMVector;
	#endif
#endif // !AMVector

#define SHUFFLE4(V, X,Y,Z,W) (_mm_shuffle_ps(V, V, _MM_SHUFFLE(W,Z,Y,X)))
#define SHUFFLE3(V, X,Y,Z) (_mm_shuffle_ps(V, V, _MM_SHUFFLE(3,Z,Y,X)))
#define SHUFFLE2(V, X,Y) (_mm_shuffle_ps(V, V, _MM_SHUFFLE(3,2,Y,X)))

#define AM_INLINEF  __forceinline
#define AM_CALLCONV __vectorcall

visual studio上可以通过__AVX__,__AVX2__等宏检测是否支持AVX指令集,_M_IX86_FP为2则表示支持SSE2,为1则表示支持SSE,否则不支持SSE。

这里将__m128类型定义为AMVector,并且定义了SHUFFLE4等宏方便对__m128类型进行Swizzle运算。

因为我们定义的函数都比较短,所以有必要把所有函数都定义为_forceinline来减少函数调用开销。x64上的默认函数调用约定为__fastcall,前两个参数通过寄存器传递,其他参数通过堆栈传递,而__vectorcall能使用比__fastcall更多的寄存器传递参数,并且支持__m128矢量类型,在可能的情况下可以通过寄存器返回函数返回值。(关于__vectorcall的更多信息可以查看Introducing ‘Vector Calling Convention’

首先需要定义加,减,乘,除等算术运算与赋值操作:

AM_INLINEF AMVector AM_CALLCONV operator + (CRAMVector lhs, CRAMVector rhs)
{
	return _mm_add_ps(lhs, rhs);
}

AM_INLINEF AMVector& AM_CALLCONV operator += (AMVector &lhs, CRAMVector rhs)
{
	lhs = _mm_add_ps(lhs, rhs);
	return lhs;
}

AM_INLINEF AMVector AM_CALLCONV operator - (CRAMVector lhs, CRAMVector rhs)
{
	return _mm_sub_ps(lhs, rhs);
}

AM_INLINEF AMVector& AM_CALLCONV operator -= (AMVector &lhs, CRAMVector rhs)
{
	lhs = mm_sub_ps(lhs, rhs);
	return lhs;
}

AM_INLINEF AMVector AM_CALLCONV operator * (CRAMVector lhs, CRAMVector rhs)
{
	return _mm_mul_ps(lhs, rhs);
}

AM_INLINEF AMVector AM_CALLCONV operator * (CRAMVector lhs, float rhs)
{
	return _mm_mul_ps(lhs, _mm_set1_ps(rhs));
}

AM_INLINEF AMVector AM_CALLCONV operator * (float lhs, CRAMVector rhs)
{
	return _mm_mul_ps(_mm_set1_ps(lhs), rhs);
}

AM_INLINEF AMVector& AM_CALLCONV operator *= (AMVector& lhs, float rhs)
{
	lhs = _mm_mul_ps(lhs, _mm_set1_ps(rhs));
	return lhs;
}

AM_INLINEF AMVector& AM_CALLCONV operator *= (AMVector& lhs, CRAMVector rhs)
{
	lhs = _mm_mul_ps(lhs, rhs);
	return lhs
}

AM_INLINEF AMVector AM_CALLCONV operator / (CRAMVector lhs, CRAMVector rhs)
{
	return _mm_div_ps(lhs, rhs);
}

AM_INLINEF AMVector AM_CALLCONV operator / (CRAMVector lhs, float rhs)
{
	return _mm_div_ps(lhs, _mm_set1_ps(rhs
}

AM_INLINEF AMVector AM_CALLCONV operator / (float lhs, CRAMVector rhs)
{
	return _mm_div_ps(_mm_set1_ps(lhs), rhs);
}

AM_INLINEF AMVector& AM_CALLCONV operator /= (AMVector& lhs, float rhs)
{
	lhs = _mm_div_ps(lhs, _mm_set1_ps(rhs));
	return lhs;
}

AM_INLINEF AMVector& AM_CALLCONV operator /= (AMVector& lhs, CRAMVector rhs)
{
	lhs = _mm_div_ps(lhs, rhs);
	return lh
}

然后可以定义一些Set,get 操作方便我们存储和读取__m128中的值:

AM_INLINEF AMVector AM_CALLCONV am_vector_set(float x, float y, float z, float w)
{
	return _mm_set_ps(w, z, y, x);
}

AM_INLINEF float AM_CALLCONV am_vector_get_y(CRAMVector v)
{
	return _mm_cvtss_f32(_mm_shuffle_ps(v, v, _MM_SHUFFLE(1, 1, 1, 1)));
}

之后就是必不可少的点乘和叉乘操作了:

AM_INLINEF AMVector AM_CALLCONV am_vector3_dot(CRAMVector lhs, CRAMVector rhs)
{
#if SIMD_LEVEL == 0
	float dot = lhs.x() * rhs.x() + lhs.y() * rhs.y() + lhs.z() * rhs.z();
	return	Vector4<float>{dot};
#elif SIMD_LEVEL == 1
	AMVector dot = _mm_mul_ps(lhs, rhs);
	AMVector temp = SHUFFLE4(dot, 1, 1, 2, 2);
	dot = _mm_add_ps(dot, temp);
	temp = SHUFFLE4(dot, 3, 3, 3, 3);
	dot = _mm_add_ps(dot, temp);
	return SHUFFLE4(dot, 0, 0, 0, 0);
#else
	return _mm_dp_ps(lhs, rhs, 0x7f);
#endif // SIMD_LEVEL == 0
}

如果支持SSE4则可以直接采用_mm_dp_ps实现点乘操作,否则若支持SSE则可以将lhs和rhs相乘,然后通过shuffle操作打乱乘法操作的结果,并将对应的的三个分量加起来。

AM_INLINEF AMVector AM_CALLCONV am_vector3_cross(CRAMVector lhs, CRAMVector rhs)
{
#if SIMD_LEVEL == 0
	return	vector4_cross3(lhs, rhs);
#else
	//http://threadlocalmutex.com/?p=8  Investigating SSE Cross Product Performance
	//return _mm_sub_ps(_mm_mul_ps(SHUFFLE3(lhs, 1, 2, 0), SHUFFLE3(rhs, 2, 0, 1)), _mm_mul_ps(SHUFFLE3(lhs, 2, 0, 1), SHUFFLE3(rhs, 1, 2, 0)));
	AMVector result = _mm_sub_ps(_mm_mul_ps(lhs, SHUFFLE3(rhs, 1, 2, 0)), _mm_mul_ps(SHUFFLE3(lhs, 1, 2, 0), rhs));
	return SHUFFLE3(result, 1, 2, 0);
#endif // SIMD_LEVEL == 0
}

三维向量的叉乘操作定义为:
cross(a, b) = a.yzx * b.zxy - a.zxy * b.yzx;

可以表示为:

  • cross(a, b).x = a.y * b.z - a.z * b.y;
  • cross(a, b).y = a.z * b.x - a.x * b.z;
  • cross(a, b).z = a.x * b.y - a.y * b.x;

可以将上面的式子重新排序如下:

  • cross(a, b).z = a.x * b.y - a.y * b.x;
  • cross(a, b).x = a.y * b.z - a.z * b.y;
  • cross(a, b).y = a.z * b.x - a.x * b.z;


然后我们就可以得到:

cross(a, b).zxy = a * b.yzx - a.yzx * b;

即:

cross(a, b) = (a * b.yzx - a.yzx * b).yzx;

这样以来叉乘的实现就只需要3个shuffle操作即可完成,比起代码中我原来实现的方法少了一个shuffle指令,效率更高(上面介绍的叉乘操作方法来自于Investigating SSE Cross Product Performance)。

随后可以定义向量的取模和归一化操作:

AM_INLINEF AMVector AM_CALLCONV am_vector3_length_sq(CRAMVector v)
{
	return am_vector3_dot(v, v);
}

AM_INLINEF AMVector AM_CALLCONV am_vector3_length(CRAMVector v)
{
#if SIMD_LEVEL == 0
	float dot = v.x() * v.x() + v.y() * v.y() + v.z() * v.z();
	return	Vector4<float>{ std::sqrt(dot) };
#elif SIMD_LEVEL == 1
	AMVector dot = _mm_mul_ps(v, v);
	AMVector temp = SHUFFLE4(dot, 1, 1, 2, 2);
	dot = _mm_add_ps(dot, temp);
	temp = SHUFFLE4(dot, 3, 3, 3, 3);
	dot = _mm_sqrt_ss(_mm_add_ps(dot, temp));
	return SHUFFLE4(dot, 0, 0, 0, 0);
#else
	return _mm_sqrt_ps(_mm_dp_ps(v, v, 0x7f));
#endif // SIMD_LEVEL == 0
}

计算向量的模可以先通过对向量和它本身做点击计算出模的平方,然后再对其开平方求得。

AM_INLINEF AMVector AM_CALLCONV am_vector3_normalize_est(CRAMVector v)
{
#if SIMD_LEVEL == 0
	float dot = v.x() * v.x() + v.y() * v.y() + v.z() * v.z();
	return	vector4_divide(v, std::sqrt(dot));
#elif SIMD_LEVEL == 1
	AMVector dot = _mm_mul_ps(v, v);
	AMVector temp = SHUFFLE4(dot, 1, 1, 2, 2);
	dot = _mm_add_ps(dot, temp);
	temp = SHUFFLE4(dot, 3, 3, 3, 3);
	dot = _mm_rsqrt_ss(_mm_add_ps(dot, temp));
	return _mm_mul_ps(v, SHUFFLE4(dot, 0, 0, 0, 0));
#else
	return _mm_mul_ps(v, _mm_rsqrt_ps(_mm_dp_ps(v, v, 0x7f)));
#endif // SIMD_LEVEL == 0
}

对向量进行归一化即 v/v.length,需要求向量模的倒数,可以通过_mm_rsqrt_ps取倒数,但是会有一些误差,也可以通过_mm_div_ps(1, length)取得精确的倒数值。

此外还定义了sqrt, lerp, clamp, saturate等函数。

AM_INLINEF AMVector AM_CALLCONV am_vector_sqrt_est(CRAMVector v)
{
	return _mm_rsqrt_ps(v);
}

AM_INLINEF AMVector AM_CALLCONV am_vector_lerp(float t, CRAMVector lhs, CRAMVector rhs)
{
	return _mm_add_ps(lhs, am_vector_scale(_mm_sub_ps(rhs, lhs), t));
}

AM_INLINEF AMVector AM_CALLCONV am_vector_clamp(CRAMVector v, CRAMVector min, CRAMVector max)
{
	return _mm_max_ps(_mm_min_ps(v, max), min);
}

AM_INLINEF AMVector AM_CALLCONV am_vector_saturate(CRAMVector v)
{
	return _mm_max_ps(_mm_min_ps(v, am_const_one), am_const_zero);
}

还有一些算术运算如log,exp,pow等函数可以借助标准库std::log, std::exp, std::pow实现

(pow实现整数指数计算比较简单,但是要支持浮点数就有些麻烦了...,exp虽然可以通过泰勒展开式计算,但是要计算到比较精确的值需要计算100项以上,速度比较慢,而且误差也比加大...)

三角函数可以通过泰勒展开式计算,只需要计算5项左右就可以得到比较好的精度了:

AM_INLINEF AMVector AM_CALLCONV am_vector_acos(CRAMVector v)
{
	AMVector v2 = _mm_mul_ps(v, v);
	AMVector v3 = _mm_mul_ps(v2, v);
	AMVector v5 = _mm_mul_ps(v3, v2);
	AMVector v7 = _mm_mul_ps(v5, v2);
	AMVector v9 = _mm_mul_ps(v7, v2);

	AMVector result = _mm_sub_ps(_mm_sub_ps(_mm_sub_ps(_mm_sub_ps(_mm_sub_ps(am_const_half_pi, v), _mm_mul_ps(v3, _mm_set1_ps(0.16666666666667f))),
		_mm_mul_ps(v5, _mm_set1_ps(0.075f))), _mm_mul_ps(v7, _mm_set1_ps(0.04464285714f))), _mm_mul_ps(v9, _mm_set1_ps(0.030381944444444f)));

	return result;
}

常见的三角函数泰勒展开式都可以在wiki上找到:

常见三角函数的泰勒展开式

除了这些算术运算外还定义了floor, ceil等函数直接借助于SSE的_mm_floor_ps和_mm_ceil_ps函数进行计算。

然后就是比较运算:

typedef int AM_MASK;

AM_INLINEF AM_MASK AM_CALLCONV am_vector_less(CRAMVector lhs, CRAMVector rhs)
{
	AMVector temp = _mm_cmplt_ps(lhs, rhs);
	return _mm_movemask_ps(temp);
}

AM_INLINEF bool am_mask_all3(AM_MASK m)
{
	return (m & 7) == 7;
}

AM_INLINEF bool am_mask_any3(AM_MASK m)
{
	return (m & 7) != 0;
}

_mm_cmplt_ps函数返回的是__m128类型的数据,其中存储了逐分量比较的结果,如果条件成立则为-nan,否则为0:

void test() 
{
	__m128 a = am_vector_set(5, 6, 7, 4);
	__m128 b = am_vector_set(5, 6, 7, 8);

	__m128 v = _mm_cmpeq_ps(a, b); // -nan, -nan, -nan, 0
	AM_MASK mask = _mm_movemask_ps(v);	// 7
	bool all = am_mask_all3(mask);	//true
}

可以使用_mm_movemask_ps将比较结果转化为int类型的值,此int值从低到高的4位分别对应两个__m128从低到高逐分量比较的结果,所以这个int类型的值的范围在0-15之间。可以通过检查这个int类型的值判断条件是否都成立,或其中一个是否成立。

AM_INLINEF AMVector AM_CALLCONV am_vector_select(CRAMVector a, CRAMVector b, CRAMVector condition)
{
//.....
#elif SIMD_LEVEL == 1
	return _mm_or_ps(_mm_andnot_ps(condition, a), _mm_and_ps(b, condition));
#else
	return _mm_blendv_ps(a, b, condition);
#endif // SIMD_LEVEL == 0
}

可以定义一个select函数,根据比较结果,选择a或b中的对应分量作为返回值的对应分量(如果为true则选择b,否则选择a),如果支持sse则select函数通过位操作实现,若支持sse4则可以通过_mm_blendv_ps指令实现。select函数的效果如下:

void test() 
{
	__m128 a = am_vector_set(1, 9, 3, 10);
	__m128 b = am_vector_set(5, 6, 7, 8);

	__m128 v = _mm_cmplt_ps(a, b); //  -nan, 0, -nan, 0

	__m128 result = am_vector_select(a, b, v);	// 5, 9, 7, 10
}

以上差不多介绍了所有向量支持的操作。接下来可以实现下简单的矩阵操作。

矩阵的定义如下(这里矩阵采用的是行主序(Row Major)):

struct alignas(16) SIMDMatrix
{
	AMVector m_rows[4];

	SIMDMatrix()= default;
	SIMDMatrix(const SIMDMatrix &m)
	{
		m_rows[0] = m.m_rows[0];
		m_rows[1] = m.m_rows[1];
		m_rows[2] = m.m_rows[2];
		m_rows[3] = m.m_rows[3];
	}

	SIMDMatrix(float a00, float a01, float a02, float a03,
		float a10, float a11, float a12, float a13,
		float a20, float a21, float a22, float a23,
		float a30, float a31, float a32, float a33)
	{
		m_rows[0] = am_vector_set(a00, a01, a02, a03);
		m_rows[1] = am_vector_set(a10, a11, a12, a13);

		m_rows[2] = am_vector_set(a20, a21, a22, a23);
		m_rows[3] = am_vector_set(a30, a31, a32, a33);
	}
//......
}

#if SIMD_LEVEL == 0
typedef Matrix4x4<float> AMMatrix;
typedef const Matrix4x4<float>& CRAMMatrix;
#else
typedef SIMDMatrix AMMatrix;
typedef const SIMDMatrix CRAMMatrix;
#endif

矩阵也定义了一些set函数,和AMVector定义的set函数差不多,这里就不再给出了。

接下来定义矩阵与矩阵的相乘操作,考虑两个矩阵a和b相乘得到c:

矩阵相乘

根据矩阵相乘的定义(a的行乘以b的列),可以得到c的第一行为:

对上面的式子重新排列后可以得到:

即c的第0行为a的第零行第零列的元素乘以b的第0行元素加上a的第零行第一列的元素乘以b的第2行元素加上a的第零行第二列的元素乘以b的第3行元素最后再加上a的第零行第三列的元素乘以b的第3行元素。c的其他行与第一行类似,代码实现如下:

AM_INLINEF AMMatrix AM_CALLCONV am_matrix_multiply(CRAMMatrix lhs, CRAMMatrix rhs)
{
	AMMatrix result;

	//Row0
	AMVector vec = lhs.m_rows[0];
	AMVector vec_x = SHUFFLE4(vec, 0, 0, 0, 0);
	AMVector vec_y = SHUFFLE4(vec, 1, 1, 1, 1);
	AMVector vec_z = SHUFFLE4(vec, 2, 2, 2, 2);
	AMVector vec_w = SHUFFLE4(vec, 3, 3, 3, 3);

	vec_x = _mm_mul_ps(vec_x, rhs.m_rows[0]);
	vec_y = _mm_mul_ps(vec_y, rhs.m_rows[1]);
	vec_z = _mm_mul_ps(vec_z, rhs.m_rows[2]);
	vec_w = _mm_mul_ps(vec_w, rhs.m_rows[3]);

	vec_x = _mm_add_ps(vec_x, vec_y);
	vec_z = _mm_add_ps(vec_z, vec_w);

	result.m_rows[0] = _mm_add_ps(vec_x, vec_z);

	//Row1
	vec = lhs.m_rows[1];
	vec_x = SHUFFLE4(vec, 0, 0, 0, 0);
	vec_y = SHUFFLE4(vec, 1, 1, 1, 1);
	vec_z = SHUFFLE4(vec, 2, 2, 2, 2);
	vec_w = SHUFFLE4(vec, 3, 3, 3, 3);

	vec_x = _mm_mul_ps(vec_x, rhs.m_rows[0]);
	vec_y = _mm_mul_ps(vec_y, rhs.m_rows[1]);
	vec_z = _mm_mul_ps(vec_z, rhs.m_rows[2]);
	vec_w = _mm_mul_ps(vec_w, rhs.m_rows[3]);

	vec_x = _mm_add_ps(vec_x, vec_y);
	vec_z = _mm_add_ps(vec_z, vec_w);

	result.m_rows[1] = _mm_add_ps(vec_x, vec_z);

	//Row2
	vec = lhs.m_rows[2];
	vec_x = SHUFFLE4(vec, 0, 0, 0, 0);
	vec_y = SHUFFLE4(vec, 1, 1, 1, 1);
	vec_z = SHUFFLE4(vec, 2, 2, 2, 2);
	vec_w = SHUFFLE4(vec, 3, 3, 3, 3);

	vec_x = _mm_mul_ps(vec_x, rhs.m_rows[0]);
	vec_y = _mm_mul_ps(vec_y, rhs.m_rows[1]);
	vec_z = _mm_mul_ps(vec_z, rhs.m_rows[2]);
	vec_w = _mm_mul_ps(vec_w, rhs.m_rows[3]);

	vec_x = _mm_add_ps(vec_x, vec_y);
	vec_z = _mm_add_ps(vec_z, vec_w);

	result.m_rows[2] = _mm_add_ps(vec_x, vec_z);

	//Row3
	vec = lhs.m_rows[3];
	vec_x = SHUFFLE4(vec, 0, 0, 0, 0);
	vec_y = SHUFFLE4(vec, 1, 1, 1, 1);
	vec_z = SHUFFLE4(vec, 2, 2, 2, 2);
	vec_w = SHUFFLE4(vec, 3, 3, 3, 3);

	vec_x = _mm_mul_ps(vec_x, rhs.m_rows[0]);
	vec_y = _mm_mul_ps(vec_y, rhs.m_rows[1]);
	vec_z = _mm_mul_ps(vec_z, rhs.m_rows[2]);
	vec_w = _mm_mul_ps(vec_w, rhs.m_rows[3]);

	vec_x = _mm_add_ps(vec_x, vec_y);
	vec_z = _mm_add_ps(vec_z, vec_w);

	result.m_rows[3] = _mm_add_ps(vec_x, vec_z);

	return result;
}

向量与矩阵相乘采用的是左乘,计算方式与上面计算c的第一行的方式完全相同。

接下来是定义矩阵转置操作,转置操作用到了_mm_unpacklo_ps,_mm_unpackhi_ps,_mm_movelh_ps,_mm_movehl_ps等操作。这里简单介绍下_mm_unpacklo_ps和_mm_movelh_ps。

void test() 
{
	__m128 a = am_vector_set(1, 2, 3, 4);
	__m128 b = am_vector_set(5, 6, 7, 8);

	__m128 v = _mm_unpacklo_ps(a, b); //  1, 5, 2, 6
	//v[0] = a[0]
	//v[1] = b[0]
	//v[2] = a[1]
	//v[3] = b[1]

	v = _mm_movelh_ps(a, b); // 1, 2, 5, 6
	//v[0] = a[0]
	//v[1] = a[1]
	//v[2] = b[0]
	//v[3] = b[1]
}

对矩阵A做转置操作可以先对第0行和第1行做_mm_unpacklo_ps操作得到temp1 : {a00, a10, a01, a11}, 再对第2行和第3行做_mm_unpacklo_ps操作得到temp2 : {a20, a30, a21, a31},再对temp1和temp2做_mm_movelh_ps操作就可以得到转置后矩阵的第一行元素{a00, a10, a20, a30}了。其他行的操作与第一行类似。

转置操作的完整实现如下:

AM_INLINEF AMMatrix AM_CALLCONV am_matrix_transpose(CRAMMatrix m)
{
	AMVector t0 = _mm_unpacklo_ps(m.m_rows[0], m.m_rows[1]);

	AMVector t1 = _mm_unpacklo_ps(m.m_rows[2], m.m_rows[3]);

	AMVector t2 = _mm_unpackhi_ps(m.m_rows[0], m.m_rows[1]);

	AMVector t3 = _mm_unpackhi_ps(m.m_rows[2], m.m_rows[3]);

	return AMMatrix{ _mm_movelh_ps(t0, t1), _mm_movehl_ps(t1, t0), _mm_movelh_ps(t2, t3), _mm_movehl_ps(t3, t2) };
}

至于矩阵的求逆操作,采用的是分块求逆:

矩阵分块求逆,A代表左上角2x2的submatrix,B为右上角2x2submatrix,C,D同理

分块求逆的原理和实现可以参考:Fast 4x4 Matrix Inverse with SSE SIMD, Explained

到这里整个数学运算库差不多介绍完毕了。

当然使用SSE/AVX的方法也不止使用数学运算库这一种方式,下面给出了一个10000个数字求和的例子,使用SSE指令的求和运算,其速度为标量求和运算的3倍多:

void test()
{
	constexpr size_t num = 10000;
	float *vars = static_cast<float*>(_aligned_malloc(sizeof(float) * num, 16));

	for (size_t i = 0; i < num; i++)
	{
		vars[i] = 1;
	}

	float sum = 0.0f;

	auto t0 = std::chrono::steady_clock::now();

	//Scalar
	for (size_t i = 0; i < 10000; i++)
	{
		sum += vars[i];
	}

	auto t1 = std::chrono::steady_clock::now();
	std::cout << "Scalar sum!" << std::endl;
	std::chrono::duration<double> time_span = std::chrono::duration_cast<std::chrono::duration<double>>(t1 - t0);
	std::cout << "Time Span: " << time_span.count() << std::endl;

	std::cout << sum << std::endl;

	AMVector vector_sum = am_vector_set(0, 0, 0, 0);


	t0 = std::chrono::steady_clock::now();

	//SSE
	for (size_t i = 0; i < 10000; i += 4)
	{
		vector_sum += am_vector_load_aligned(vars + i);
	}

	sum = am_vector4_hadd(vector_sum);

	t1 = std::chrono::steady_clock::now();
	std::cout << "SSE sum!" << std::endl;
	time_span = std::chrono::duration_cast<std::chrono::duration<double>>(t1 - t0);
	std::cout << "Time Span: " << time_span.count() << std::endl;

	std::cout << sum << std::endl;

	_aligned_free(vars);
}

上面的例子还可以扩展出更多内容,不过本篇文章的内容已经够多了,所以留着以后有机会再讲吧...

如果你觉得本篇文章的内容对你有帮助,请点赞!!!

引用:

  1. https://software.intel.com/zh-cn/articles/ticker-tape-part-2
  2. Writing C++ Wrappers for SIMD Intrinsics (1)
  3. How To Write A Maths Library In 2016
  4. Fast 4x4 Matrix Inverse with SSE SIMD, Explained
  5. 在C/C++代码中使用SSE等指令集的指令(3)SSE指令集基础 - 。。。。 - CSDN博客
  6. Easy SIMD through Wrappers
  7. C++中使用SIMD的几种方法 - 道道道人间道 - CSDN博客

如果你想查看所有的SSE/AVX指令集可以查看Intrinsics Guide

https://db.in.tum.de/~finis/x86%20intrinsics%20cheat%20sheet%20v1.0.pdf 这个pdf也罗列出了大部分的SSE/AVX指令集,以及对应的功能

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

SIMD简介 的相关文章

随机推荐

  • 通过配置NFS使Ubuntu和海思3559A板子共享目录

    之前在Ubuntu和海思3559A板子之间来回拷贝文件都是用的scp命令 不是很方便 这里通过配置NFS来实现它们之间共享目录 操作步骤如下 1 在Ubuntu上安装NFS 执行以下命令 执行结果如下 sudo apt get instal
  • An attempt was made to call a method that does not existThe attempt was made from following location

    Dubbo和zookeeper整合springboot报错 An attempt was made to call a method that does not exist The attempt was made from the fol
  • 利用XMLHttpRequest同步和异步下载二进制文件的解决方案。

    在XMLHttpRequest2里支持二进制数据的下载了 现分别以同步和异步两种方式分别介绍 异步的方式下载 xmlRequest open GET 0 jpg true xmlRequest responseType blob 这里是关键
  • Android Studio的APP目录下的build.gradle的配置说明

    Build gradle属性说明 声明是Android程序 apply plugin com android application android 程序在编译的时候会检查lint 有任何错误提示都会停止build lintOptions
  • 集合框架知识总汇之(list集合)

    目录 编辑 1 UML 统一建模语 3 List集合 3 1特点 3 2遍历方式 3 3List优化 初始容量10 负载因子1 5 3 4LinkedList 队列 堆栈 3 5如何对Arraylist进行去重处理 面试常问题 1 Coll
  • Django4.0+使用rest_framework_jwt的问题

    问题描述 python版本 3 10 Django版本 4 1 djangorestframework jwt版本 1 11 0 在写jwt认证功能时 发现run的时候会报以下错误 from django utils translation
  • VUE 自身页面跳转自身页面

    先说一下要实现的功能 点击原案件 要回到原案件 但是原案件页面和现在的页面一样 也就是自身跳转自身页面 路由地址不变 使用vue祖传的push 方法来挑转的话 你会发现可以跳转过去 但是页面会刷新 不会触发vue生命周期函数 方法一 thi
  • [转]No response for the toolbars in BEx Analyzer 2004s

    Summary Symptom After installing the frontend either from the CD or through applying the frontend support package or the
  • 2022年蓝桥杯省赛 C/C++ A组B题灭鼠先锋题解

    问题描述 本题为填空题 只需要算出结果后 在代码中使用输出语句将所填结果输出即可 灭鼠先锋是一个老少咸宜的棋盘小游戏 由两人参与 轮流操作 灭鼠先锋的棋盘有各种规格 本题中游戏在两行四列的棋盘上进行 游戏的规则为 两人轮流操作 每次可选择在
  • 《UNIX网络编程》卷一第四章学习笔记

    UNIX网络编程 卷一第四章学习笔记 4 2 socket函数 include
  • 2023华为OD机试真题【计算快递业务主站点/回溯法/深度优先搜索】

    题目描述 快递覆盖的范围有N的站 如果A和B都可以用来中转 我们就称A B站可达 如果A B可达 B C可达 则A C达 我们现在有N个编号 如果s i j 1 表示i j可达 如果s i j 0 表示i j不可达 现用二维数组给定N个站点
  • 使用python爬取微信公众号文章

    一 背景 有时候看到某一个微信公众号中的文章 觉得写的非常不错 有种当时就想把该公众号所有的文章都看完的冲动 但是使用手机看不是特别方便 就想把文章全部下载下来到电脑上面看 二 爬虫实现步骤 使用python爬取微信公众号文章 总共分为如下
  • 图片加载防闪动的CSS方法

    图片闪动 在移动端设置图片布局时 图片使用自适应的方式 其父元素的高度是被图片高度撑开的 在图片加载前 父元素高度为0 加载后 父元素高度为图片高度 这样的过程会造成视觉上的闪烁 影响用户体验 因此 在用图片撑开父元素高度之前 就需要给父元
  • 安装sql server时提示缺少.NET 3.5 sp1

    这几天遇到了一个问题 在安装sql server的时候总是提示我没有安装 NET framework 3 5 sp1 但是我电脑上已经安装了它 多次尝试之后我百思不得其解 今天终于解决了 我的系统是win8升级上来的win10 在升级的时候
  • 一种横向业务的解决方案 -- AOP

    AOP Aspect Oriented Programming 即面向切片编程 所谓面向切片编程 就是可以按照时间 将程序分成无数个时间节点 利用AOP的思想 可以在任何一个时间节点插入其他的代码 来实现自己的业务需求 换句话说 对于那些非
  • java循环栅栏CyclicBarrier 使用详解

    1 CyclicBarrier 是什么 从字面上的意思可以知道 这个类的中文意思是 循环栅栏 大概的意思就是一个可循环利用的屏障 它的作用就是会让所有线程都等待完成后才会继续下一步行动 举个例子 就像生活中我们会约朋友们到某个餐厅一起吃饭
  • 单片机c语言屏蔽第四位,【单片机C语言基础入门】第四章:运算符与表达式

    大家好 今天和大家探讨的是单片机C语言中的运算符和表达式 前边介绍了C语言中的变量的表示 然而在计算的过程中只有变量是不能完成计算的 因此运算符和表达式为变量 包括常量 来做特定的操作 来实现数据的运算 因此运算符和表达式是C语言中不可或缺
  • 网卡多队列 (解决traceroute路由不能直达)以及高丢包问题

    多队列指实例规格支持的最大网卡队列数 单个ECS实例vCPU处理网络中断存在性能瓶颈时 您可以将实例中的网络中断分散给不同的CPU处理 经测试 在相同的网络PPS和网络带宽的条件下 与1个队列相比 2个队列最多可提升性能达50 到100 4
  • Keras-9 实现Seq2Seq

    A ten minute introduction to sequence to sequence learning in Keras 简单介绍如何用Keras实现Seq2Seq模型 原文链接 https blog keras io a t
  • SIMD简介

    SIMD简介 知乎本篇文章包含的内容有SIMD指令集简介以及简短的practice环节 1 SIMD的历史与分类SIMD Single Instruction Multiple Data 即单指令流多数据流 是一种采用一个控制器来控制多个处