Arm NEON 架构优化详解
SIMD
SIMD,全称为"单指令多数据"(Single Instruction Multiple Data),是一种处理器架构技术。它允许单个指令同时作用于多个数据点,从而提高处理器的并行计算能力。这种技术特别适用于需要对大量数据进行相同操作的应用,如多媒体处理、信号处理和科学计算等。
SIMD 指令可以同时对多个数据元素进行相同的操作。例如,在处理音频或视频时,通常需要对大量的像素或音频样本执行相同的处理操作。传统的处理器在处理这些数据时,需要逐个元素执行相同的指令,而 SIMD 技术则可以通过一次指令对多个元素同时操作,从而显著提高处理效率。
Neon 架构
NEON 是 ARM 推出的高级 SIMD(单指令多数据)架构实现,旨在通过允许在多个数据点上同时执行相同的操作来加速多媒体和信号处理任务。NEON 技术随着 ARMv7 架构的引入而出现,结合了64-bit和128-bit的SIMD指令集,提供128-bit宽的向量运算(vector operations)
Neon 数据类型
NEON 支持多种数据类型,涵盖整数、浮点数和多项式数据。这些数据类型的支持使 NEON 能够广泛应用于各种计算和处理任务。以下是 NEON 支持的主要数据类型:
-
integers:
- 8-bit:支持有符号和无符号 8 位整数。
- 16-bit:支持有符号和无符号 16 位整数。
- 32-bit:支持有符号和无符号 32 位整数。
- 64-bit:支持有符号和无符号 64 位整数。
-
single-precision floating point elements:
- 32-bit:32位单精度浮点
-
polynomials:
- 8-bit and 16-bit polynomials:NEON 支持 8 位和 16 位多项式数据类型,适用于某些特定的数学计算和加密算法。
Neon 寄存器
NEON 寄存器主要分为两种类型:将四对 32 位单精度(32-bit)数值相加。这些值被 load 进 128 位的寄存器中,每个寄存器包含四个 32 位的通道。
-
16 × 128 位四字寄存器(Quadword Registers, Q0-Q15) :
- 每个寄存器宽度为 128 bit。
- 可以容纳 16 × 8 bit数据、8 × 16 bit数据、4 × 32 bit数据或 2 × 64 bit数据。
-
32 × 64 位双字寄存器(Doubleword Registers, D0-D31) :
- 每个寄存器宽度为 64 bit。
- 可以容纳 8 × 8 位数据、4 × 16 位数据或 2 × 32 位数据。
NEON 寄存器与 VFP(矢量浮点处理)寄存器共享同一个寄存器组。这意味着 NEON 的 D0-D31 寄存器与 VFP 的 D0-D31 寄存器相同,而 Q0-Q15 寄存器则映射到一对 D 寄存器上。例如,Q0 寄存器映射到 D0 和 D1 寄存器 。
寄存器映射示例:
- Q0 -> D0, D1
- Q1 -> D2, D3
- 以此类推
寄存器视图如下:
下面展示一个样例: ADD V10.4S, V8.4S, V9.4S
这个指令将两个 128 位寄存器 V8 和 V9 中的四对 32 位数值相加,并将结果存储在 128 位寄存器 V10 中。每个寄存器包含四个 32 位的通道,且每个通道分别相加,没有跨通道的进位。(加法操作完全独立于每个通道。例如,通道 0 的溢出或进位不会影响通道 1,这是一种完全独立的计算。)
Neon 指令
NEON 提供多种数据处理指令,可以根据操作数和结果的不同类型和宽度进行分类。这些指令可以显著提高多媒体处理和信号处理任务的效率。以下是 NEON 数据处理指令的详细分类及其功能解释:
所有的支持NEON指令都有一个助记符V
,下面以32位指令为例,说明指令的一般格式:
V{<mod>}<op>{<shape>}{<cond>}{.<dt>}{<dest>}, src1, src2
-
mod
- Q: 带有 Q 标识符的指令使用饱和算术运算,这意味着结果将被限制在指定数据类型的范围内,避免溢出。 如
VQABS
,VQSHL
等. - H: 带有 H 标识符的指令将结果减半。它通过右移一位来实现(相当于除以二并截断)。例如:
VHADD
,VHSUB
. - D: 带有 D 标识符的指令将结果加倍。例如:
VQDMULL
,VQDMLAL
,VQDMLSL
andVQ{R}DMULH
. - R: 带有 R 标识符的指令在截断之前对结果进行四舍五入,相当于在截断前加 0.5。例如:
VRHADD
,VRSHR
.
- Q: 带有 Q 标识符的指令使用饱和算术运算,这意味着结果将被限制在指定数据类型的范围内,避免溢出。 如
-
op - the operation (for example,
ADD
,SUB
,MUL
). -
shape - Shape,即前文中的Long (L), Wide (W), Narrow (N).
-
cond - Condition, used with IT instruction.
-
.dt - 数据类型, such as s8, u8, f32 etc.
-
dest - Destination.
-
src1 - Source operand 1.
-
src2 - Source operand 2.
注: {} 表示可选的参数。
普通指令 (Normal Instructions)
普通指令可以对任意类型的向量进行操作,并生成与操作数向量相同大小且通常相同类型的结果向量。
长指令 (Long Instructions)
长指令操作双字(64位)向量操作数,并生成四倍长字(128位)向量结果。结果元素的宽度通常是操作数的两倍,且类型相同。长指令通过在指令中添加 "L" 来指定。
例如:
-
VMLAL: 用于长乘累加操作,将两个双字向量相乘,并将结果累加到一个四倍长字向量中。
int32x4_t vmlal_s16(int32x4_t acc, int16x4_t a, int16x4_t b);
宽指令 (Wide Instructions)
宽指令操作一个双字向量操作数和一个四倍长字向量操作数,并生成一个四倍长字向量结果。结果元素和第一个操作数的宽度都是第二个操作数元素的两倍。宽指令通过在指令中添加 "W" 来指定。
例如:
-
VADDW: 用于宽加法操作,将一个双字向量与一个四倍长字向量相加,结果存储在一个四倍长字向量中。
int32x4_t vaddw_s16(int32x4_t a, int16x4_t b);
窄指令 (Narrow Instructions)
窄指令操作四倍长字向量操作数,并生成双字向量结果。结果元素的宽度通常是操作数元素的一半。窄指令通过在指令中添加 "N" 来指定。
例如:
-
VQMOVN: 用于窄移动操作,将四倍长字向量缩小为双字向量。
int16x4_t vqmovn_s32(int32x4_t a);
饱和运算 (Saturating Variants)
ARM 中的饱和算法是一种防止溢出的方法,根据数据类型(有符号或无符号)不同,其行为如下:
- 有符号饱和运算:如果结果小于 -2^n,则返回结果为 -2^n;如果结果大于 2^n - 1,则返回结果为 2^n - 1。
- 无符号饱和运算:如果结果为负值,则返回结果为 0;如果结果大于 2^n - 1,则返回结果为 2^n - 1。
在 NEON 中,通过在 V 和指令助记符之间使用 Q 前缀来指定饱和指令。例如:
-
VQADD: 饱和加法指令,计算两个向量的加法,并对结果进行饱和处理。
int16x8_t vqaddq_s16(int16x8_t a, int16x8_t b);
Neon Intrinsic 优化实例
1. 以官方提供的4 x 4 矩阵乘为例:(乘累加指令)
对于 32 位浮点数的矩阵乘法,可以使用以下 C 代码实现:
c
void matrix_multiply_c(float32_t *A, float32_t *B, float32_t *C, uint32_t n, uint32_t m, uint32_t k) {
for (int i_idx=0; i_idx < n; i_idx++) {
for (int j_idx=0; j_idx < m; j_idx++) {
C[n*j_idx + i_idx] = 0;
for (int k_idx=0; k_idx < k; k_idx++) {
C[n*j_idx + i_idx] += A[n*k_idx + i_idx] * B[k*j_idx + k_idx];
}
}
}
}
这种代码没有充分利用 NEON 的并行计算能力,可以通过 NEON 内在函数进行优化。
使用 NEON 内在函数优化矩阵乘法
以下代码展示了如何使用 NEON 内在函数对两个 4x4 矩阵进行乘法运算。由于处理的是固定大小的小矩阵,所有值都可以一次性加载到处理器的 NEON 寄存器中,因此可以完全展开循环。 具体优化步骤包括:
- 使用
vld1q_f32
将数据加载到 NEON 寄存器中。 - 使用
vfmaq_laneq_f32
进行乘累加运算。 - 使用
vst1q_f32
将结果存储回内存。
c
#include <arm_neon.h>
void matrix_multiply_4x4_neon(float32_t *A, float32_t *B, float32_t *C) {
float32x4_t A0 = vld1q_f32(A);
float32x4_t A1 = vld1q_f32(A + 4);
float32x4_t A2 = vld1q_f32(A + 8);
float32x4_t A3 = vld1q_f32(A + 12);
float32x4_t C0 = vmovq_n_f32(0);
float32x4_t C1 = vmovq_n_f32(0);
float32x4_t C2 = vmovq_n_f32(0);
float32x4_t C3 = vmovq_n_f32(0);
float32x4_t B0 = vld1q_f32(B);
C0 = vfmaq_laneq_f32(C0, A0, B0, 0);
C0 = vfmaq_laneq_f32(C0, A1, B0, 1);
C0 = vfmaq_laneq_f32(C0, A2, B0, 2);
C0 = vfmaq_laneq_f32(C0, A3, B0, 3);
vst1q_f32(C, C0);
float32x4_t B1 = vld1q_f32(B + 4);
C1 = vfmaq_laneq_f32(C1, A0, B1, 0);
C1 = vfmaq_laneq_f32(C1, A1, B1, 1);
C1 = vfmaq_laneq_f32(C1, A2, B1, 2);
C1 = vfmaq_laneq_f32(C1, A3, B1, 3);
vst1q_f32(C + 4, C1);
float32x4_t B2 = vld1q_f32(B + 8);
C2 = vfmaq_laneq_f32(C2, A0, B2, 0);
C2 = vfmaq_laneq_f32(C2, A1, B2, 1);
C2 = vfmaq_laneq_f32(C2, A2, B2, 2);
C2 = vfmaq_laneq_f32(C2, A3, B2, 3);
vst1q_f32(C + 8, C2);
float32x4_t B3 = vld1q_f32(B + 12);
C3 = vfmaq_laneq_f32(C3, A0, B3, 0);
C3 = vfmaq_laneq_f32(C3, A1, B3, 1);
C3 = vfmaq_laneq_f32(C3, A2, B3, 2);
C3 = vfmaq_laneq_f32(C3, A3, B3, 3);
vst1q_f32(C + 12, C3);
}
以下是流程说明:
加载矩阵 A 的数据:
c
float32x4_t A0 = vld1q_f32(A);
float32x4_t A1 = vld1q_f32(A + 4);
float32x4_t A2 = vld1q_f32(A + 8);
float32x4_t A3 = vld1q_f32(A + 12);
-
使用
vld1q_f32
内在函数将矩阵 A 的四行分别加载到四个 128 位 NEON 寄存器 A0, A1, A2 和 A3 中。 -
每个寄存器存储 4 个 32 位浮点数。
初始化结果矩阵 C:
c
float32x4_t C0 = vmovq_n_f32(0);
float32x4_t C1 = vmovq_n_f32(0);
float32x4_t C2 = vmovq_n_f32(0);
float32x4_t C3 = vmovq_n_f32(0);
使用 vmovq_n_f32
内在函数将结果矩阵 C 的四行初始化为零。
处理矩阵 B 的每一列并进行乘加运算:
以处理矩阵 B 的第一列为例:
c
float32x4_t B0 = vld1q_f32(B);
C0 = vfmaq_laneq_f32(C0, A0, B0, 0);
C0 = vfmaq_laneq_f32(C0, A1, B0, 1);
C0 = vfmaq_laneq_f32(C0, A2, B0, 2);
C0 = vfmaq_laneq_f32(C0, A3, B0, 3);
vst1q_f32(C, C0);
-
使用
vld1q_f32
内在函数将矩阵 B 的第一列加载到寄存器 B0 中。 -
使用
vfmaq_laneq_f32
内在函数进行融合乘加运算(FMA),将 A 矩阵的行与 B 矩阵的列进行点积,并累加到 C 矩阵的对应行中。 -
vfmaq_laneq_f32
函数的第四个参数指定了 B 寄存器中要使用的元素索引。每次调用该函数时,使用 B0 的不同元素与 A 的每一行进行乘积运算。 -
使用
vst1q_f32
内在函数将结果存储到结果矩阵 C 中。
重复处理 B 矩阵的其余列:
- 对 B 矩阵的第二、三、四列重复上述操作,将结果存储到 C 矩阵的相应位置中。
使用 NEON 扩展到任意维度的矩阵乘法运算方法
在之前的 4x4 矩阵乘法基础上,通过 NEON 内在函数对矩阵乘法进行优化,可以将其扩展到任意维度。通过将大矩阵分块成多个 4x4 的小矩阵并利用 NEON 并行计算能力,可以显著提升计算效率。
分块矩阵乘法的原理
对于任意维度的矩阵,可以将其分解为 4 x 4 的子矩阵进行运算。例如,假设矩阵 A 的大小为 <math xmlns="http://www.w3.org/1998/Math/MathML"> n × k n \times k </math>n×k,矩阵 B 的大小为 <math xmlns="http://www.w3.org/1998/Math/MathML"> k × m k \times m </math>k×m,则它们的乘积矩阵 C 的大小为 <math xmlns="http://www.w3.org/1998/Math/MathML"> n × m n \times m </math>n×m。我们可以将这些矩阵分成多个 4 x 4 的子矩阵,并对这些子矩阵进行独立运算,最后将结果合并。
代码示例
以下是扩展到任意维度的矩阵乘法优化代码示例:
c
#include <arm_neon.h>
void matrix_multiply_neon(float32_t *A, float32_t *B, float32_t *C, uint32_t n, uint32_t m, uint32_t k) {
int A_idx;
int B_idx;
int C_idx;
// 子矩阵 A 的列
float32x4_t A0;
float32x4_t A1;
float32x4_t A2;
float32x4_t A3;
// 子矩阵 B 的列
float32x4_t B0;
float32x4_t B1;
float32x4_t B2;
float32x4_t B3;
// 子矩阵 C 的列
float32x4_t C0;
float32x4_t C1;
float32x4_t C2;
float32x4_t C3;
for (int i_idx = 0; i_idx < n; i_idx += 4) {
for (int j_idx = 0; j_idx < m; j_idx += 4) {
// 初始化累加器
C0 = vmovq_n_f32(0);
C1 = vmovq_n_f32(0);
C2 = vmovq_n_f32(0);
C3 = vmovq_n_f32(0);
for (int k_idx = 0; k_idx < k; k_idx += 4) {
// 计算 4x4 子块的基准索引
A_idx = i_idx + n * k_idx;
B_idx = k * j_idx + k_idx;
// 加载 A 的当前行的值
A0 = vld1q_f32(A + A_idx);
A1 = vld1q_f32(A + A_idx + n);
A2 = vld1q_f32(A + A_idx + 2 * n);
A3 = vld1q_f32(A + A_idx + 3 * n);
// 乘累加操作,每次处理 C 的一个列
B0 = vld1q_f32(B + B_idx);
C0 = vfmaq_laneq_f32(C0, A0, B0, 0);
C0 = vfmaq_laneq_f32(C0, A1, B0, 1);
C0 = vfmaq_laneq_f32(C0, A2, B0, 2);
C0 = vfmaq_laneq_f32(C0, A3, B0, 3);
B1 = vld1q_f32(B + B_idx + k);
C1 = vfmaq_laneq_f32(C1, A0, B1, 0);
C1 = vfmaq_laneq_f32(C1, A1, B1, 1);
C1 = vfmaq_laneq_f32(C1, A2, B1, 2);
C1 = vfmaq_laneq_f32(C1, A3, B1, 3);
B2 = vld1q_f32(B + B_idx + 2 * k);
C2 = vfmaq_laneq_f32(C2, A0, B2, 0);
C2 = vfmaq_laneq_f32(C2, A1, B2, 1);
C2 = vfmaq_laneq_f32(C2, A2, B2, 2);
C2 = vfmaq_laneq_f32(C2, A3, B2, 3);
B3 = vld1q_f32(B + B_idx + 3 * k);
C3 = vfmaq_laneq_f32(C3, A0, B3, 0);
C3 = vfmaq_laneq_f32(C3, A1, B3, 1);
C3 = vfmaq_laneq_f32(C3, A2, B3, 2);
C3 = vfmaq_laneq_f32(C3, A3, B3, 3);
}
// 计算存储的基准索引
C_idx = n * j_idx + i_idx;
vst1q_f32(C + C_idx, C0);
vst1q_f32(C + C_idx + n, C1);
vst1q_f32(C + C_idx + 2 * n, C2);
vst1q_f32(C + C_idx + 3 * n, C3);
}
}
}
-
分块矩阵:
- 将矩阵 A、B 和 C 分成 4x4 的子矩阵。通过循环控制,每次处理一个 4x4 的子矩阵块。
-
初始化累加器:
- 在处理每个 4x4 子矩阵块之前,将结果累加器 C0, C1, C2, C3 初始化为零。
-
加载数据:
- 使用
vld1q_f32
内在函数将矩阵 A 和 B 的数据加载到 NEON 寄存器中。每次加载 4 个 32 位浮点数,分别存储在 A0, A1, A2, A3 和 B0, B1, B2, B3 中。
- 使用
-
乘累加操作:
- 使用
vfmaq_laneq_f32
内在函数进行融合乘加运算(FMA)。该函数将 A 矩阵的行与 B 矩阵的列进行点积,并将结果累加到 C 矩阵的对应行中。
- 使用
-
存储结果:
- 使用
vst1q_f32
内在函数将结果存储到矩阵 C 中。
- 使用
优化和注意事项
-
循环展开:
- 通过展开循环,可以减少循环控制带来的额外开销,进一步提高计算效率。
-
寄存器复用:
- 为了充分利用 NEON 寄存器,可以在不需要的情况下避免频繁加载和存储数据。
-
内存对齐:
- 确保矩阵数据在内存中的对齐,以充分利用 NEON 的数据加载和存储性能。
通过以上优化方法,可以将 NEON 用于任意维度的矩阵乘法运算中,充分发挥 SIMD 的并行计算能力,提高运算效率
2. 以官方提供的使用 NEON 进行 RGB 去交织为例:(load - store 指令)
在处理图像数据时,RGB 数据通常是交织存储的,每个像素的红、绿、蓝(RGB)分量依次排列。为了对每个颜色通道进行单独的操作,可能需要将这些交织的数据分离到独立的数组中。这一过程称为 RGB 去交织。以下将详细介绍如何使用 NEON 指令优化 RGB 去交织操作。
原始 C 实现
首先,提供一个不使用 NEON 指令的基本 C 实现:
c
void rgb_deinterleave_c(uint8_t *r, uint8_t *g, uint8_t *b, uint8_t *rgb, int len_color) {
/*
* 从 "rgb" 数组中提取元素,并分别存储到 "r"、"g" 和 "b" 数组中。
*/
for (int i = 0; i < len_color; i++) {
r[i] = rgb[3 * i];
g[i] = rgb[3 * i + 1];
b[i] = rgb[3 * i + 2];
}
}
使用 NEON 的优化实现
通过 NEON 指令可以显著提升性能。以下是使用 NEON 内在函数优化后的实现:
c
#include <arm_neon.h>
void rgb_deinterleave_neon(uint8_t *r, uint8_t *g, uint8_t *b, uint8_t *rgb, int len_color) {
/*
* 使用 NEON 内在函数从 "rgb" 数组中提取元素,并分别存储到 "r"、"g" 和 "b" 数组中。
*/
int num8x16 = len_color / 16; // 每次处理 16 个像素
uint8x16x3_t intlv_rgb;
for (int i = 0; i < num8x16; i++) {
intlv_rgb = vld3q_u8(rgb + 48 * i); // 每次加载 48 字节(16 个像素的 RGB 数据)
vst1q_u8(r + 16 * i, intlv_rgb.val[0]); // 将 R 分量存储到 r 数组
vst1q_u8(g + 16 * i, intlv_rgb.val[1]); // 将 G 分量存储到 g 数组
vst1q_u8(b + 16 * i, intlv_rgb.val[2]); // 将 B 分量存储到 b 数组
}
}
主要步骤如下: 加载交织数据:
-
使用
vld3q_u8
内在函数从内存中加载交织的 RGB 数据到 NEON 寄存器。每次加载 48 字节,相当于 16 个像素的 RGB 数据。 -
vld3q_u8
返回一个uint8x16x3_t
结构,其中包含三个 128 位寄存器,每个寄存器分别存储 R、G、B 分量。
存储解交织数据:
使用 vst1q_u8
内在函数将 R、G、B 分量分别存储到对应的数组中。每次存储 16 字节,相当于 16 个像素的某一颜色分量。
使用 NEON 内在函数的优势
- 并行处理:通过 NEON 寄存器和指令,可以在一次操作中处理 16 个 8 位像素分量,大幅提高数据处理速度。
- 简化代码:内在函数封装了底层汇编指令,使代码更简洁易读,同时具备高性能。