可以并行化执行是因为CPU在设计时,增加了一些专用的向量寄存器,这些寄存器的长度往往大于通用寄存器,比如SEE的XMM寄存器,位宽为128位;AVX和AVX2的YMM寄存器,位宽为256位;AVX512的ZMM寄存器,位宽为512位。这些专用的向量寄存器可以同时放入多个数据。
变量定义
第一部分,统一为__m;
第二部分为位数如64、128、256等;
第三部位为变量类型,i表示int型,d表示double型,float型什么也不加。
例如__m128i 表示定义128位的int型数据。__m256 表示定义256位的float型数据。
函数定义
第一部分:输出的数据类型的位数:256位_mm256,512位_mm512,128位不需要带数字:_mm;
第二部分:功能的名称例如,载入数据load,加法add,存储store;
第三部分:该功能的数据类型,如8位整数epi8、无符号8位整数epu8、16位整数epi16,单精度浮点数_ps,双精度浮点数_pd;
其中 p = packed,s = 单精度浮点数,d = 双精度浮点数,
ps: 由float类型数据组成的向量pd:由double类型数据组成的向量epi8/epi16/epi32/epi64: 由8位/16位/32位/64位的有符号整数组成的向量epu8/epu16/epu32/epu64: 包含8位/16位/32位/64位的无符号整数组成的向量si128/si256: 未指定的128位或者256位向量
例如_mm256_add_epi32(a, b),的意思是数据总位数为256,将a和b其中每32位为一个数字进行对应相加。也就是说a可以看作a[0]、a[1]、a[2]…a[7]。b看作b[0]、b[1]、b[2]…b[7]。然后a[0]+b[0]、a[1]+b[1]…a[7]+b[7]得到了新的结果。
应用
MindSpore中为了方便各种位宽指令的统一编写,通过宏封装了intrinsic指令,如果需要看真正的指令,可以通过下面的命令
1
gcc -E -C ./build/mindspore/src/nnacl/avx512/layer_norm_fp32_avx512.h -I ./mindspore/ccsrc/plugin/device/cpu/kernel/ > intrinsic.h
比如,下面是部分宏与intrinsic指令的对应关系(avx512上):
1
2
3
4
5
6
7
8
9
SIMD_F32 => __m512
SIMD_MOV_F32 => #define MS_MOV512_F32 _mm512_set1_ps
SIMD_LD_F32 => #define MS_LD512_F32 _mm512_loadu_ps
SIMD_FMADD_F32 => #define MS_FMADD512_F32(src1, src2, src3) _mm512_fmadd_ps(src1, src2, src3)
SIMD_GET_SUM_F32 => #define MS_GET_SUM512_F32(src) _mm512_reduce_add_ps(src)
SIMD_SUB_F32 => #define MS_SUB512_F32 _mm512_sub_ps
SIMD_MUL_F32 => #define MS_MUL512_F32(src1, src2) _mm512_mul_ps(src1, src2)
SIMD_ST_F32 => #define MS_ST512_F32 _mm512_storeu_ps
1
2
3
4
5
6
7
8
9
10
11
# broadcast singe-prcison (32-bit) floating-point value a to all elements of dst
__m512 _mm512_set1_ps(float a);
# set singe-prcison (32-bit) floating-point elements in dst with the supplied values.
__m512 _mm512_set_ps(float e15, float e14, ..., float e1, float e0);
将512位(由16个单精度(32-bit)打包的浮点数组成)从内存加载到dst中,mem_addr不需要对齐。
__m512 _mm512_loadu_ps(void const* mem_addr)
将512位(由16个单精度(32-bit)打包的浮点数组成)从内存加载到dst中,mem_addr必须64字节对齐,否则可能会产生通用保护异常。
__m512 _mm512_load_ps(void const* mem_addr)
同样,arm neon的也可以用类似处理。
1
2
3
4
5
6
7
8
9
10
SIMD_F32 => float32x4_t
SIMD_MOV_F32 => #define MS_MOVQ_F32 vmovq_n_f32
SIMD_LD_F32 => #define MS_LDQ_F32 vld1q_f32
SIMD_FMADD_F32 => #define MS_MLAQ_F32(src1, src2, src3) vmlaq_f32(src1, src2, src3)
#define MS_FMADD128_F32(src1, src2, src3) vmlaq_f32(src3, src1, src2)
SIMD_GET_SUM_F32 => #define MS_ADDVQ_F32(src) vaddvq_f32(src)
SIMD_SUB_F32 => #define MS_SUBQ_F32 vsubq_f32
SIMD_MUL_F32 => #define MS_MULQ_F32(src1, src2) vmulq_f32(src1, src2)
SIMD_ST_F32 => #define MS_STQ_F32 vst1q_f32
根据Arm C语言扩展,arm_neon.h中的函数原型遵循一种通用模式。在最一般的层面上是:
1
ret v[p][q][r]name[u][n][q][x][_high][_lane | laneq][_n][_result]_type(args)
ret返回值类型.vvector的首字母,所有intrinsics都有p表示是一个成对操作pairwise operationq表示是一个饱和操作saturating operation(AArch64下vqtb[l][x]是例外,q表示128-bit index and result operands).r表示是一个舍入操作。name基本操作的描述性名称。这通常是一条高级SIMD指令,但不一定是。u表示有符号到无符号saturation.n表示是一个narrowing操作.q作为操作的后缀表示对128位向量的操作。x表示AArch64中高级SIMD标量操作。它可以是b/h/s/d之一(即[8/16/32/64]位)。_high在AArch64中,用于涉及128位操作数的widening和narrowing操作。对于widening128位操作数,high指源操作数的前64位。对于narrowing,它指的是目标操作数的前64位。_n指示作为参数提供的标量操作数。_lane表示从向量的通道获取的标量操作数。_laneq表示从128-bit输入向量的通道中获取的标量操作数。type缩写形式的主操作数类型。args函数参数
NEON intrinsic检索 link
重点:如果对ARM体系结构感兴趣,可以阅读更系统的Cortex-A Series Programmer’s Guide。
- 了解
registers,vectors,lanes,elements的概念以及它们对专用寄存器的占用; 新的
Armv8a架构有32个128bit向量寄存器,老的ArmV7a架构有32个64bit(可当作16个128bit)向量寄存器,编码时记得数一下占用多少专用寄存器(如1个float32x4就占用1个128bit寄存器),别用过量了,避免寄存器溢出(Register Spilling)导致的负优化。对非整数倍元素个数
leftovers的处理技巧一条
Neon指令最多可以计算4个float32,或8个float16,或16个int8。假设现在有3或5个(即不是4的整数倍)float32要计算,应该怎样解决? ARM官方的Coding for Neon(第4节 Load and store - leftovers)给了处理技巧:有3种方法可以处理
leftovers,根据自己的需求决定具体采用那种方法。3种方法如下所示,按照性能从高到低顺序排列:- Extend arrays with padding
- Overlap data elements
- Process leftovers as single elements
上层C++代码最终还是编译成汇编代码,而到了最底层的实现,大概流程都是加载数据到寄存器,进行计算,最后把寄存器的值写回内存。而一般运行瓶颈就在于数据的加载和写出还有指令之间的数据依赖等等,所以怎么更高效的读写数据还有使相邻指令之间的数据依赖最小等等。
armv7 包含16个128-bit向量寄存器,用q0-q15表示。
armv8 包含32个128-bit向量寄存器,用v0-v31来表示。
每个128-bit向量寄存器可以当做:
- 包含 2 个 64-bit 元素的向量寄存器来用,表达形式是
vn.2d; - 包含 4 个 32-bit 元素的向量寄存器来用,表达形式是
vn.4s; - 包含 8 个 16-bit 元素的向量寄存器来用,表达形式是
vn.8h; - 包含 16 个 8-bit 元素的向量寄存器来用,表达形式是
vn.16b;
或者每个向量寄存器也可以只用低 64-bit:
- 1 个 64-bit 元素的向量寄存器来用,表达形式是
vn.1d; - 2 个 32-bit 元素的向量寄存器来用,表达形式是
vn.2s; - 4 个 16-bit 元素的向量寄存器来用,表达形式是
vn.4h; - 8 个 8-bit 元素的向量寄存器来用,表达形式是
vn.8b;
大多数时间我们都是在寄存器中处理数据。最常见的SIMD编程模版就是:
- 加载原始数据进入Neon寄存器;
- 在Neon寄存器里做计算;
- 保存数据到内存里。
那绑核有什么作用呢?
首先大核主频高,绑定到大核上比绑定到小核上运行耗时小。
其次可以优化缓存性能。如果进程没有绑定在一个cpu上,那么当该进程切换cpu的时候,新cpu的cache并没有之前cpu cache上缓存的数据,就会导致cache miss,然后需要从内存加载数据,然后过一段时间切回去原来cpu之后,可能原来的cache里面的内容也失效了,又导致cache miss,那么这样来回切换就很影响性能。
默认的编译选项不提供“消除不必要的存储器引用”的机制,所有局部变量按定义顺序存储在栈中,每次读写时需要从存储器中访问并写回存储器。开启-O2编译选项后,编译器允许使用寄存器存储局部变量。
发生寄存器溢出时,编译器会将结果溢出到栈中。程序需要在内存中读写这些变量,会抵消使用多个值并行累积所获得的好处,并行积累的优势就可能消失。