前言
学习SIMD的笔记
参考教程:SIMD Tutorial.pdf
一、使用SIMD的场景
考虑如下代码:
vec3 velocity = GetPlayerSpeed();
float length = velocity.Length();
获取玩家速度,是个包含x,y,z三个分量的向量,然后对这个向量求长度。
这个Length让我们写的话,一般就是:
float Length(){
return sqrt(x*x+y*y+z*z);
}
或者:
x = velocity.x * velocity.x;
y = velocity.y * velocity.y;
z = velocity.z * velocity.z;
sum = x + y;
sum = sum + z;
length = sqrtf( sum )
此时对前面三个值求平方就可以是并行的,但这同时是一种浪费。
三个并行的浮点运算只会占用其中的一部分插槽,这样会浪费25%的SSE数据槽位或62.5%的AVX数据槽位。
但是如果我们用另一种方式;考虑我们如果有四个玩家,那么代码会是如下的方式组织:
x4 = GetPlayerXSpeeds();
y4 = GetPlayerYSpeeds();
z4 = GetPlayerZSpeeds();
x4squared = x4 * x4;
y4squared = y4 * y4;
z4squared = z4 * z4;
sum4 = x4squared + y4squared;
sum4 = sum4 + z4squared;
length4 = sqrtf4( sum4 );
x4,y4,z4都是承载着四个浮点数的向量,一次取出四个玩家的同一分量,同时对这一分量做运算,从新的维度操作数据,使指令集的利用率得到提高,从这点上看,一次操作的数据数量最好是4或8的倍数。
同时也可以看出,为了更好的SIMD使用,最好配合高效的数据结构。
二、数据操控
C++是强类型语言,但对于一个数据,我们可以把它看做另一个类型来操控它:
int a;
float& b = (float&)a;
也可以用联合体的方式来操控:
union { int a; float b; }
假如我们用如下方式写出联合体,那么我们既可以当成无符号整形一次性运算或赋值,也可以当成无符号字符类型依次取值或运算:
union { unsigned int c4; unsigned char c[4]; };
实验代码:
union int_char { int c4; char c[4]; };
int main(){
int_char ic;
ic.c[0] = 0; ic.c[1] = 0;
ic.c[2] = 0; ic.c[3] = 1;
std::cout << ic.c4 << std::endl;
// output:
// 16777216 = 1 * (16^6)
}
由此可见int的存储形式(英特尔CPU在PC上是小端处理方式)。
SIMD数据类型
首先引入两个头文件:
#include "nmmintrin.h" // for SSE4.2
#include "immintrin.h" // for AVX
其中__m128和__128i是SSE的数据类型,代表四位浮点数和四位整数,__m256和__256i则是AVX的。
__m128包含四位浮点数,因此我们同样可以使用上面的技巧:
union float4{ __m128 f4; float f[4]; };
这种用法称为类型双关,不过我们按ctrl点入__m128中,会发现这本身就是一个多关类型:
typedef union __declspec(intrin_type) __declspec(align(16)) __m128 {
float m128_f32[4];
unsigned __int64 m128_u64[2];
__int8 m128_i8[16];
__int16 m128_i16[8];
__int32 m128_i32[4];
__int64 m128_i64[2];
unsigned __int8 m128_u8[16];
unsigned __int16 m128_u16[8];
unsigned __int32 m128_u32[4];
} __m128;
我们甚至可以直接:__m128 f4 = {1,2,3,4};
,这样会存储为float类型,用f4.m128_f32
可以查看,用f4.m128_i32
则会发生值的错误;存储顺序会按照数组的书写顺序存储。
用以下的方法塞入数字:
float4 a, b;
a.f4 = _mm_set_ps(4.0f, 4.1f, 4.2f, 4.3f);
b.f4 = _mm_set_ps(1.0f, 1.0f, 1.0f, 1.0f);
可以塞入不同类型的数值,上例子中塞入的是float值,和数组式初始化不同的是,它的存储顺序刚好相反,类似最上边的联合体存取方式,是小端存储。
然后调用方法使它们并行相加:
__m128 sum4 = _mm_add_ps(a.f4, b.f4);
此时我们想办法将其打印出来:
float4 sum;
sum.f4 = sum4;
std::ostream_iterator foi = { std::cout, " " };//STL中的输出迭代器
std::copy(std::begin(sum.f), std::end(sum.f), foi);//输出
//output:
//5.3 5.2 5.1 5
同样的运算操作,还有减、乘、除、开方、取倒数:
_mm_sub_ps(a, b);
_mm_mul_ps(a, b);
_mm_div_ps(a, b);
_mm_sqrt_ps(a, b);
_mm_rcp_ps(a, b);// reciprocal
对于AVX指令集的操作类似,不过运算函数的名称都要在"_mm"后加上256,例如:“_mm256_add_ps”