NEON优化:性能优化经验总结
- 1. 什么是 NEON
- Arm Adv SIMD 历史
- 2. 寄存器
- 3. NEON 命名方式
- 4. 优化技巧
Reference:
- NEON优化:性能优化经验总结
- NEON官方内联函数
- Arm NEON programming quick reference
- Learn the architecture - Neon programmers’ guide
1. 什么是 NEON
NEON 技术是用于 Arm Cortex-A
系列处理器的先进 SIMD(单指令多数据)架构。它可以加速多媒体和信号处理算法,如视频编码器/解码器、2D/3D图形、游戏、音频和语音处理、图像处理、电话和声音。
NEON 指令执行“打包 SIMD”处理:
- 寄存器被认为是相同数据类型元素的向量
- 数据类型支持:带符号/无符号 8 8 8 位, 16 16 16 位, 32 32 32 位, 64 64 64 位,ARM 32 32 32位平台上的单精度浮点数,ARM 64 64 64位平台上的单精度浮点数和双精度浮点数。
- 指令在所有通道中执行相同的操作
Arm Adv SIMD 历史
Armv6 | Armv7-A | Armv8-A AArch64 |
---|---|---|
SIMD extension | NEON | NEON |
在32位通用ARM寄存器上操作 | 独立的寄存器库,32x64位NEON寄存器 | 独立的寄存器库,32x128位NEON寄存器 |
8位或16位整数 | 8/16/32/64位整数 | 8/16/32/64位整数 |
每条指令进行2x16位/4x8位操作 | 单精度浮点数 | 单精度浮点数、双精度浮点数 |
– | 每条指令最多16x8位操作(16x4吧,待确认) | 每条指令最多16x8位操作 |
2. 寄存器
Armv7-A 和 AArch32 具有相同的通用 Arm 寄存器 - 16 x 32 16 x 32 16x32 位通用 Arm 寄存器(R0-R15)。
Armv7-A 和 AArch32 具有 32 x 64 位 NEON 寄存器(D0-D31)。这些寄存器也可以看作是 16 × 128 位寄存器(Quadword, Q0-Q15)。每个 Q0-Q15 寄存器映射到一对D寄存器,如下图所示。
相比之下,AArch64 具有
31
31
31 个
64
64
64 位通用 Arm 寄存器和
1
1
1 个具有不同名称的特殊寄存器,这取决于使用它的上下文。这些寄存器可以被看作是
31
31
31 个
64
64
64 位寄存器(X0-X30)或
31
31
31 个
32
32
32 位寄存器(W0-W30)。
AArch64 具有
32
32
32 x
128
128
128 位 NEON 寄存器(V0-V31,Vector Registers
)。这些寄存器也可以看作是
32
32
32 位 Sn寄存器(Single-word Registers)
或
64
64
64 位 Dn寄存器(Double-word Registers)
。
(也就是说,V 寄存器有 128 位,D 寄存器 64 位,S寄存器 32 位,可以将 V 寄存器拆开使用)
3. NEON 命名方式
-
变量命名方式:
baseWxLxN_t
- base:是基础数据类型
- W:是基础类型的宽度
- L:是向量的长度
- N:是向量数组的个数
- 如
uint8x16_t
、uint8x16x3_t
-
函数命名方式:
ret v[p][q][r]name[u][n][q](args)
- ret:返回值类型
- v:表示vector
- q:饱和运算,溢位后,为自动限制在数据类型的最大范围内
- r:圆整操作
- name:SIMD指令名称
- u:unsigned
- n:narrow
- q:做后缀表示128位满位宽寄存器运算 quarter*32
4. 优化技巧
-
热点函数涉及到大量 IO 读写操作时,数据的内存地址尽量与 NEON 数组或系统位数对齐,如32位对齐,可降低访问开销;
-
重点优先搞 NEON 指令并行计算,能大幅降低开销;
-
大部分的 NEON 问题会出在存取、移动指令的滥用、混乱使用上(neon的寄存器和普通arm的寄存器是分开,也就是说arm的普通指令和neon指令之间不可以有过多的数据交换,但是sse没有这个限制?待验证);
-
for
循环:- 如
for (b = 0; b < num; b++)
: - 可改成
for (b = 0; b < num - 3; b += 4)
; - 或者
for (b = num - 1; b >= 3; b -= 4)
; - 需注意结尾不能整除的几个还是用非SIMD方式计算:
- 原始:for (i = 0; i < size; i++);
- 并行:for (i = 0; i < size - 3; i += 4);
- 扫尾:for (; i < size; i ++)。
- 如
-
数组索引取值:
- 数组索引以及索引内部涉及运算的,尽量换成指针偏移加减来做;
- 避免大范围索引跳跃,减少 cache miss。
-
内存使用:
- 优先用局部变量,而非 malloc 堆内存,减少 cache miss;
- 针对具体变量类型,手动 for 循环并行拷贝值,可能比 memcpy() 函数更高效,因为 memcpy 内部还涉及大量判断,以保证平台兼容性;
- 用NEON指令时,4 路运算的数组(128位=16字节),内存地址最好要 16 字节对齐。
-
指令运算
- 矩阵乘场景,在不大幅增加寄存器变量的前提下,外部的A也最好并行多读几路数据进来,跟B的各列运算,减少B各列的读取次数;
- 乘加指令,add 和 mul 可以合并为 mla,一条指令完成乘加操作。
-
C 语言编码级考虑
- C 语言中一条事件的处理函数尽可能在一个源文件中(便于编译器自动向量化);
switch
比if else
快,而且代码整洁。
-
深入理解计算机系统
- 组织代码结构,善用 CPU 缓存,数据段/代码段连续可以提高 CPU 缓存命中率;
- 极简函数时,尽量 inline 展开,减少函数调用栈的开销;
- 消除不必要的存储器引用,如 for 循环中 *dest = *dest - nums[i],可用中间变量替换 *dest,for 循环后再赋值给 *dest,可减少 for 内的一次读写操作;
- 简单的循环展开,编译器可以自己完成,优化选项 O2 及以上,或者命令 -funroll-loops(O2 及以上自带),可调用 gcc 进行循环展开;
- 能用整型不用浮点,整数乘法/加法和浮点加法,只用一个周期,浮点乘法需要2个周期。
-
NEON 与 SSE 在寄存器处理数据时有一些区别。在 NEON 中,通常需要先将要处理的数据加载到 NEON 寄存器,然后执行 SIMD 操作。这与 SSE 有一些不同,因为 SSE 寄存器(XMM 寄存器)可以直接与内存交互(这一步可能格外耗时,需要特别注意)。在 NEON 中,加载数据到 NEON 寄存器通常包括以下步骤:
- 从内存加载数据到通用寄存器(通常是ARM通用寄存器)。
- 将数据从通用寄存器传送到 NEON 寄存器。
然后,您可以在 NEON 寄存器上执行 SIMD 操作,例如矢量加法、矢量乘法等。
这与 SSE 不同,因为 SSE 寄存器可以直接从内存加载数据,而不需要中间步骤。这可以在 SSE 指令中实现,而不需要将数据先加载到通用寄存器中。
总之,NEON 需要额外的步骤来加载数据到寄存器,然后才能进行 SIMD 操作,而 SSE 可以更直接地在寄存器中操作数据。这是因为不同架构和指令集设计的差异。