以下内容翻译自:Optimizing C Code with Neon Intrinsics
本指南向您展示如何在 C 或 C++ 代码中使用 Neon intrinsics 函数,以利用 Armv8架构中的 Advanced SIMD 技术。这些简单的示例演示了如何使用这些内嵌原语,并提供了一个解释其用途的机会。
希望使用 Advanced SIMD 技术的底层软件工程师、库编写人员和其他开发人员会发现本指南非常有用。
本指南的末尾有“检查您的知识”部分,以测试您是否了解以下关键概念:
Neon 是 Arm Advanced SIMD 架构的实现。
Neon 的目的是通过提供以下内容来加速数据处理:
可以从 Neon 技术中受益的应用包括多媒体和信号处理、3D 图形、语音、图像处理或其他重度依赖定点和浮点性能的应用。
作为一名程序员,有很多方法可以使用 Neon 技术:
在本指南中,我们将重点介绍对 AArch64使用 Neon intrinsics 函数,但也可以对 AArch32进行编译。有关 AArch32 Neon 的更多信息,请参阅 Introducing Neon for Armv8-A。首先,我们将看一个简化的图像处理示例和矩阵乘法。然后,我们将继续对内在函数本身进行更一般的讨论。
内嵌原语是编译器已知其精确实现的函数。Neon intrinsics 函数是 arm_neon.h 中定义的一组 C 和 C++函数,并在 Arm 编译器和 GCC 中得到支持。这些函数使您可以使用 Neon 而不必直接编写汇编代码,因为这些函数本身包含内联到调用代码中的短汇编内核。另外,寄存器分配和流水线优化由编译器处理,避免了汇编程序员面临的许多困难。
有关所有 Neon Intrinsics 函数的列表,请参见 Neon Intrinsics Reference。Neon intrinsics 工程规范包含在 Arm C Language Extensions (ACLE) 中。
使用 Neon intrinsics 函数有很多好处:
然而,内嵌原语并非在所有情况下都是正确的选择:
现在,我们将通读几个使用 Neon intrinsics 函数重新实现一些 C 函数的示例。所选择的示例并未反映出其应用程序的全部复杂性,但会说明内嵌原语的用法,并作为更复杂代码的起点。
考虑一个24位 RGB 图像,其中图像是一个像素数组,每个像素都有一个红色、蓝色和绿色元素。在内存中,它可能显示为:
这是因为 RGB 数据是交织的,访问和操作三个独立的颜色通道给程序员带来了一个问题。在简单的情况下,我们可以通过对交错的 RGB 值应用“模3”来编写自己的单色通道操作。
我们在内存中有一个 RGB 值数组,我们想对它们进行解交织并将其放在单独的颜色数组中。执行此操作的 C 过程如下所示:
void rgb_deinterleave_c(uint8_t *r, uint8_t *g, uint8_t *b, uint8_t *rgb, int len_color) {
/*
* Take the elements of "rgb" and store the individual colors "r", "g", and "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];
}
}
但是有一个问题。使用 Arm Compiler 6在优化级别为-O3
(非常高的优化)下进行编译,并检查反汇编结果表明未使用 Neon 指令或寄存器。每个单独的8位值都存储在单独的64位通用寄存器中。考虑到Neon 寄存器的全宽为128位宽,在示例中,每个寄存器都可以容纳16个8位值,因此重写解决方案使用 Neon intrinsics 函数应该会给我们带来不错的结果。
void rgb_deinterleave_neon(uint8_t *r, uint8_t *g, uint8_t *b, uint8_t *rgb, int len_color) {
/*
* Take the elements of "rgb" and store the individual colors "r", "g", and "b"
*/
int num8x16 = len_color / 16;
uint8x16x3_t intlv_rgb;
for (int i=0; i < num8x16; i++) {
intlv_rgb = vld3q_u8(rgb+3*16*i);
vst1q_u8(r+16*i, intlv_rgb.val[0]);
vst1q_u8(g+16*i, intlv_rgb.val[1]);
vst1q_u8(b+16*i, intlv_rgb.val[2]);
}
}
在此示例中,我们使用了以下类型和内嵌原语:
Code element | What is it? | Why are we using it? |
---|---|---|
uint8x16_t | 16个8位无符号整数的数组 | 一个uint8x16_t 可装入128位寄存器。我们可以确保即使在 C 代码中也没有浪费的寄存器位。 |
uint8x16x3_t | 具有三个uint8x16_t 元素的结构 |
循环中当前颜色值的临时存放区域。 |
vld3q_u8(…) | 通过加载3*16字节内存的连续区域来返回uint8x16x3_t 的函数。 每个加载的字节以交替模式放置在三个uint8x16_t 数组之一中。 |
在最低级别上,此内嵌原语确保生成 LD3 instruction,该指令以交替模式将来自给定地址的值加载到三个 Neon 寄存器中。 |
vst1q_u8(…) | 在给定地址存储一个uint8x16_t 的函数。 |
它将字节值填满一个完整的128位寄存器。 |
上面的完整源代码可以使用以下命令在 Arm 机器上编译和反汇编:
gcc -g -o3 rgb.c -o exe_rgb_o3
objdump -d exe_rgb_o3 > disasm_rgb_o3
如果您无法访问基于 Arm 的硬件,可以使用 Arm DS-5 Community Edition and the Armv8-A Foundation Platform。
矩阵乘法是许多数据密集型应用程序中执行的操作。它由一组简单重复的算术运算组成:
矩阵乘法过程如下:
对于32位浮点数的矩阵,乘法可以写为:
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];
}
}
}
}
我们假设矩阵在内存中为列主序布局。也就是说, n × m n \times m n×m 矩阵 M M M 表示为一个数组 M _ a r r a y M\_array M_array,其中 M i j = M _ a r r a y [ n ∗ j + i ] M_{ij} = M\_array[n * j + i] Mij=M_array[n∗j+i]。
这段代码是次优的,因为它没有充分利用 Neon。我们可以使用内嵌原语对其进行改进,但是让我们先解决一个更简单的问题,先查看固定大小的小矩阵,然后再转到更大的矩阵。
以下代码使用内嵌原语将两个4x4矩阵相乘。 由于待处理值的数量很小且固定,并且所有值都可以同时放入处理器的 Neon 寄存器中,因此我们可以完全展开循环。
对于32位浮点矩阵,乘法可以写成:
void matrix_multiply_4x4_neon(float32_t *A, float32_t *B, float32_t *C) {
// these are the columns A
float32x4_t A0;
float32x4_t A1;
float32x4_t A2;
float32x4_t A3;
// these are the columns B
float32x4_t B0;
float32x4_t B1;
float32x4_t B2;
float32x4_t B3;
// these are the columns C
float32x4_t C0;
float32x4_t C1;
float32x4_t C2;
float32x4_t C3;
A0 = vld1q_f32(A);
A1 = vld1q_f32(A+4);
A2 = vld1q_f32(A+8);
A3 = vld1q_f32(A+12);
// Zero accumulators for C values
C0 = vmovq_n_f32(0);
C1 = vmovq_n_f32(0);
C2 = vmovq_n_f32(0);
C3 = vmovq_n_f32(0);
// Multiply accumulate in 4x1 blocks, i.e. each column in C
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);
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);
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);
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);
}
我们选择将固定大小的4x4矩阵相乘有几个原因:
让我们总结一下这里使用的内嵌原语:
Code element | What is it? | Why are we using it? |
---|---|---|
float32x4_t | 4个32位浮点数组成的数组。 | 一个uint32x4_t 可以放入128位寄存器中。我们可以确保即使在C代码中也不会浪费寄存器位。 |
vld1q_f32(…) | 将4个32位浮点数加载到float32x4_t 中的函数。 |
从A 和B 得到我们需要的矩阵值。 |
vfmaq_lane_f32(…) | 使用融合乘法累加指令的函数。 将float32x4_t 中的值与另一个float32x4_t 的单个元素相乘,然后与第三个float32x4_t 相加并返回结果。 |
由于矩阵行对列点积是一组乘法和加法运算,因此该操作顺理成章。 |
vst1q_f32(…) | 在给定地址存储一个uint8x16_t 的函数。 |
在计算结果后存储结果。 |
现在我们可以将一个4x4矩阵相乘,并将较大的矩阵视为4x4矩阵的块来相乘。 这种方法的一个缺点是,它仅适用于二维尺寸均为4的倍数的矩阵大小,但是通过用零填充任意矩阵,您可以使用此方法而无需对其进行更改。
下面列出了更通用的矩阵乘法的代码。内核的结构变化很小,主要的变化是增加了循环和地址计算。在4x4内核中,我们对B列使用了唯一的变量名,尽管可以使用一个变量并重新加载。这提示编译器将不同的寄存器分配给这些变量,这将使处理器能够在等待另一列加载的同时完成一列的算术指令。
void matrix_multiply_neon(float32_t *A, float32_t *B, float32_t *C, uint32_t n, uint32_t m, uint32_t k) {
/*
* Multiply matrices A and B, store the result in C.
* It is the user's responsibility to make sure the matrices are compatible.
*/
int A_idx;
int B_idx;
int C_idx;
// these are the columns of a 4x4 sub matrix of A
float32x4_t A0;
float32x4_t A1;
float32x4_t A2;
float32x4_t A3;
// these are the columns of a 4x4 sub matrix of B
float32x4_t B0;
float32x4_t B1;
float32x4_t B2;
float32x4_t B3;
// these are the columns of a 4x4 sub matrix of 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){
// zero accumulators before matrix op
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){
// compute base index to 4x4 block
a_idx = i_idx + n*k_idx;
b_idx = k*j_idx k_idx;
// load most current a values in row
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);
// multiply accumulate 4x1 blocks, i.e. each column 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=v1d1q_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,B3,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);
}
//Compute base index for stores
C_idx = n*j_idx + i_idx;
vstlq_f32(C+C_idx, C0);
vstlq_f32(C+C_idx+n,Cl);
vstlq_f32(C+C_idx+2*n,C2);
vstlq_f32(C+C_idx+3*n,C3);
}
}
}
编译然后反汇编此函数,并将其与我们的 C 函数进行比较,结果显示:
FMLA
代替FMUL
指令。如内嵌原语所指定。float32x4_t
)的初始化而导致不必要的加载和存储,纯 C 代码中则未使用这些数据类型。可以使用以下命令在 Arm 机器上编译和反汇编上面的完整源代码:
gcc -g -o3 matrix.c -o exe_matrix_o3
objdump -d exe_ matrix _o3 > disasm_matrix_o3
如果您无法访问基于 Arm 的硬件,可以使用 Arm DS-5 Community Edition and the Armv8-A Foundation Platform。
内嵌原语需要 Advanced SIMD 体系结构的支持,并且在任何情况下都可能启用或不启用某些特定的指令。当定义了以下宏并等于1时,相应的功能可用:
为了使用内部函数,必须支持 Advanced SIMD 体系结构,并且在任何情况下都可以启用或不启用某些特定指令。当定义了以下宏并等于1时,相应的功能可用:
该列表并非详尽无遗,Arm C Language Extensions 中进一步详细介绍了宏。
arm_neon.h 中有三种主要的数据类型类别,它们遵循以下模式:
baseW_t
:标量数据类型baseWxL_t
:向量数据类型baseWxLxN_t
:向量数组数据类型其中:
base
是指基本数据类型。W
是基本类型的宽度。L
是向量数据类型(例如标量数组)中的标量数据类型实例的数量。N
是向量数组类型(例如标量数组结构体)中向量数据类型实例的数量。通常,W
和L
使得矢量数据类型的长度为64或128位,因此完全适合 Neon 寄存器。N
对应于那些同时在多个寄存器上操作的指令。
在前面的代码中,我们遇到了这三种情况的一个示例:
uint8_t
uint8x16_t
uint8x16x3_t
根据 Arm C Language Extensions (ACLE),arm_neon.h 中的函数原型遵循一个通用模式。在最一般的级别上是:
ret v[p][q][r]name[u][n][q][x][_high][_lane | laneq][_n][_result]_type(args)
请注意,某些字母和名称会重载,但请按照上述顺序:
ret
:函数的返回类型。v
:vector 的缩写,存在于所有的 intrinsics 函数中。p
:表示成对操作。([value]
表示可能存在值)。q
:表示饱和操作(AArch64操作中的vqtb[l][x]
除外,其中q
表示128位索引和结果操作数)。r
:表示舍入操作。name
:基本操作的描述性名称。通常这是一个 Advanced SIMD 指令,但并非必须如此。u
:表示有符号到无符号饱和。n
:表示缩小操作。q
:该名称后缀表示对128位向量的运算。x
:表示 AArch64中的 Advanced SIMD 标量操作。它可以是 b
、h
、s
或d
(即8位、16位、32位或64位)之一。_high
:在 AArch64中,用于涉及128位操作数的扩展和缩小操作。对于加宽128位操作数,high
是指源操作数的高64位。对于缩小,它指的是目标操作数的高64位。_n
:指示作为参数提供的标量操作数。_lane
:表示从向量通道中获取的标量操作数。_laneq
表示从128位宽度的输入向量的通道中获取的标量操作数。(left | right
表示只出现左或右)。type
:缩写形式的主操作数类型。args
:函数的参数。Neon intrinsics 函数的工程规范可以在 Arm C Language Extensions (ACLE) 中找到。
Neon Intrinsics Reference 提供了 ACLE 指定函数的可搜索参考。
Architecture Exploration Tools 使您可以研究 Advanced SIMD 指令集。
Arm Architecture Reference Manual 提供了 Advanced SIMD 指令集的完整规范。
有用的培训链接: