前言
ARM NEON 可以提升音视频,图像,计算机视觉等计算密集型程序的性能,在上一篇大前端CPU优化技术--NEON技术的介绍中,我们知道一些编译器可以将 C/C++ 代码自动转换为 NEON 指令,这种技术称为自动向量化,我们总是想要追求最极致的性能,想要极力的榨干CPU的性能,而自动向量化技术受限于编译器的规则和环境限制很难达到最理想的收益。所以想要有更好的性能还是需要手工编写 NEON 汇编代码或者用内联函数,但熟练掌握 NEON 指令是实现理想性能的基础。
本文接下来会详细地介绍 Armv7 和 Armv8 架构下 NEON 向量寄存器、NEON 汇编指令格式、NEON Intrinsics 指令格式等内容,感谢耐心阅读。
NEON指令格式
ARM 主流架构
Armv7-A 和 Armv8-A 的关系如下图所示
Arm v8-A是一个非常重要的架构变化,它支持64位执行模式 “AArch64” ,并且带来了全新的64位指令集 “A64” 。同时,为了兼容Arm v7-A (32位架构)指令集,也引入了 “AArch32” 的概念。
- AArch64 是 64 位执行状态,支持 A64 指令集。
- AArch32 是 32 位执行状态,支持 T32 和 A32 指令集,同时 AArch32 与 AArch64 中一些的功能保持一致,而且 AArch32 兼容 Armv7-A。
ARM v7 的NEON是可选的,而在ARM v8上是默认支持的,ARM v8 支持浮点类型的除法向量操作,这是ARM v7所没有的。另外AArch64还支持double类型的操作。
Armv9-A 是 arm 当前最新的指令架构,Armv9-A 除了向前兼容 Armv8-A,在性能计算上有了很大的提升,主要表现在安全、AI 以及改进矢量扩展(SVE2)和 DSP 能力。
AArch64 向量寄存器
AArch64 有 32 个 128bit 的向量寄存器,这些寄存器又可以划分为:
- 32 个 128bit 的 V 寄存器,V0~V31。
- 32 个 64bit 的 D 寄存器,D0~D31。
- 32 个 32bit 的 S 寄存器,S0~S31。
每种类型寄存器的映射关系如下:
AArchh32 / ARMV7向量寄存器
AArch32/Armv7 有 16 个 128bit 的向量寄存器,这些寄存器又可以划分为:
- 16 个128bit 的 Q 寄存器,Q0~Q15。
- 32 个 64bit 的 D 寄存器,D0~D31。
- 32 个 32bit 的 S 寄存器,S0~S31。
每种类型寄存器的映射关系如下:
大前端CPU优化技术--NEON技术也有介绍NEON的寄存器,每个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;
NEON指令格式
AArch64 与AArch32 / Armv7-A 的 NEON 汇编指令除了种类上存在差异,格式上也存在很大差异。
指令中有一些通用的书写格式, 含义如下:
- {}, 表示可选项
- <>, 表示必选项
AArch64 NEON指令格式通用描述如下:
{<prefix>}<op>{<suffix>}Vd.<T>,Vn.<T>,Vm.<T>
指令解读如下:
<prefix>
前缀:
- 如S/U/F/P 分别表示有符号整数/无符号整数/浮点数/布尔数据类型
- Q:表示饱和(Saturating)计算。
- R:表示舍入(Rounding)计算, Rounding 操作等价于加上 0.5 之后再截断。
- H:表示折半(Halving)计算。
- D:表示翻倍(Doubling)算。
<op>
操作符。例如ADD,AND等。
<suffix>
后缀,通常是有以下几种:
- P:将向量按对操作,例如ADDP
- V:跨所有的数据通道操作,例如FMAXV
- H:表示结果只取每个通道的高半部分(High)
- L/N/W/L2/N2/W2:表示数据长度的变化
- 2:在宽指令/窄指令中操作数据的高位部分。例如ADDHN2,SADDL2。
ADDHN2:两个128位矢量相加,得到64位矢量结果,并将结果存到NEON寄存器的高64位部分。
SADDL2: 两个NEON寄存器的高64位部分相加,得到128-位结果。
<T>
数据类型,通常是8B/16B/4H/8H/2S/4S/2D等。B代表8bit数据类型;H代表16位数据宽度;S代表32位数据宽度,可以是32位整数或单精度浮点;D代表64位数据宽度,可以是64位整数或双精度浮点。
UADDLP V0.4S, V2.2D
// 指令: 将 V2 向量相邻相邻元素进行两两和后,并将结果保存在V0的低64位上
//
// U -- 表示无符号操作
// ADD -- 表示加操作
// P -- 将向量按对操作
// L -- 表示将结果保存到输出向量的低 64bit
// V2.2D -- 表示输入向量寄存器,长度为 64bit,一共两个通道,每个通道 64bit
// V0.4S -- 表示输出向量寄存器,长度为 128bit,一共四个通道,每个通道 32bit
AArch32 / Armv7汇编指令格式
AArch32/Armv7 NEON指令格式通用描述如下:
V{<mod>}<op>{<shape>}{<cond>}{.<dt>}{<dest>}, src1, src2
指令解读如下:
V 固定格式,AArch32 / Armv7 以"V"开头,表示vector向量指令,也就是NEON指令
<mod> 该修饰字可以表示为以下类型:
- Q, 表示饱和(Saturating)计算。
- R, 表示舍入(Rounding)计算,Rounding 操作等价于加上 0.5 之后再截断。
- H, 表示折半(Halving)计算。
- D, 表示翻倍(Doubling)计算。
<op> 表示 操作运算符 如ADD, SUB, MUL
<shape> - 数据长度,Long (L), Wide (W), Narrow (N).
<.dt> - 数据类型,如 s8, u8, f32。默认为第二个操作数的数据类型。如果第二个操作数不存在,为第一个操作数类型,仍不存在为结果操作数类型。
<dest> - 输出.
<src1> ,<src2>- 输入操作数
注: {} 表示可选的参数。
VADD.S16 D0, D1, D2 16位加法
// 指令语句作用:
// 64bit 向量 D1 和 D3 中每个元素相加赋值给向量 D0
//
// 指令格式说明:
// ADD -- 表示加法操作
// .S16 -- 表示操作元素的数据类型为有符号 16bit
// D0 -- 表示输出向量寄存器,长度为 64bit
// D1 -- 表示输入向量寄存器,长度为 64bit
// D3 -- 表示输入向量寄存器,长度为 64bit
intrinsics指令格式
NEON Intrinsics 是一种更简单的编写 NEON 代码的方法,NEON Intrinsics 类似于 C 函数调用,在编译时由编译器替换为相应的汇编指令,使用时需要包含头文件arm_neon.h
。NEON intrinsics可以跨Arm v7-A/v8-A运行。
非数组向量格式
<基本类型>x<lane个数>_t 如int8x8_t
基本类型int8,int16,int32,int64,uint8等
lane个数表示并行处理的基本类型数据的个数(通道数)。
数组向量格式
<基本类型>x<lane个数>x<向量个数>_t 如 uint8x8x3_t
对于多个向量的类型实际上是结构体
typedef struct {
uint8x8_t val[3];
} uint8x8x3_t;
函数格式
v<mod><op><shape>[suffix]_<type>
<mod>
q:表示饱和计算,
a加b的结果做饱和计算
int8x8_t vqadd_s8(int8x8_t a, int8x8_t b);
h:表示折半计算,
a减b的结果右移一位
int8x8_t vhsub_s8(int8x8_t a, int8x8_t b);
d:表示加倍计算,
a乘b的结果扩大一倍, 最后做饱和操作
int32x4_t vqdmull_s16(int16x4_t a, int16x4_t b);
r:表示舍入计算,例如:
将a与b的和减半,同时做rounding 操作, 每个通道可以表达为: (ai + bi + 1) >> 1
int8x8_t vrhadd_s8(int8x8_t a, int8x8_t b);
p:表示pairwise计算。例如:
将a, b向量的相邻数据进行两两和操作
int8x8_t vpadd_s8(int8x8_t a, int8x8_t b);
<指令名>表示具体操作,比如 add,sub。
<shape>
l:long,表示长指令,输出数据的基本类型位数是输入的2倍,
uint16x8_t vaddl_u8(uint8x8_t a, uint8x8_t b);
n:narrow,表示窄指令,输出数据的基本类型位数是输入的一半,
uint32x2_t vmovn_u64(uint64x2_t a);
w:wide,第一个输入向量和输出向量类型一样,且是第二个输入向量元素长度的2倍,
uint16x8_t vsubw_u8(uint16x8_t a, uint8x8_t b);
_high:AArch64专用,而且和 l/n 配合使用。当使用 l(Long) 时,表示输入向量只有高 64bit 有效;当使用 n(Narrow) 时,表示输出只有高 64bit 有效。
// a 和 b 只有高 64bit 参与运算
int16x8_t vsubl_high_s8(int8x16_t a, int8x16_t b);
_n:表示有标量参与向量计算,
// 向量 a 中的每个元素右移 n 位
int8x8_t vshr_n_s8(int8x8_t a, const int n);
_lane: 指定向量中某个通道参与向量计算,
// 取向量 v 中下标为 lane 的元素与向量 a 做乘法计算
int16x4_t vmul_lane_s16(int16x4_t a, int16x4_t v, const int lane);
[suffix]
后缀如果没有,表示64位并行;如果后缀是q,表示128位并行。
<type>
数据基本类型简写:s8,s16,s32,s64,u8,u16,u32,u64,f16,f32,f64
例如:
vadd_u16:两个uint16x4相加为一个uint16x4
vaddq_u16:两个uint16x8相加为一个uint16x8
总结
本文主要介绍了 NEON 指令相关的知识,首先我们了解了 arm 在不同架构下指令集的分类,尤其是NEON相关的特点和区别,然后我们介绍了向量寄存器,了解了寄存器的种类和数量。最后我们又介绍了不同指令集下的NEON指令格式,这对于后面的汇编编写及性能调优是非常重要的。
工欲善其事,必先利其器,我们只有掌握了基本的指令和寄存器,才能对后续的汇编编程有更好的理解和更多的调优手段,后续我们会继续深入解析NEON指令。希望大家能在本文中有所收获。