可以并行化执行是因为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
返回值类型.v
vector
的首字母,所有intrinsics
都有p
表示是一个成对操作pairwise operation
q
表示是一个饱和操作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
操作。对于widening
128位操作数,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
编译选项后,编译器允许使用寄存器存储局部变量。
发生寄存器溢出时,编译器会将结果溢出到栈中。程序需要在内存中读写这些变量,会抵消使用多个值并行累积所获得的好处,并行积累的优势就可能消失。