用 Neon Intrinsics 优化 C 代码

以下内容翻译自:Optimizing C Code with Neon Intrinsics

概述

本指南向您展示如何在 C 或 C++ 代码中使用 Neon intrinsics 函数,以利用 Armv8架构中的 Advanced SIMD 技术。这些简单的示例演示了如何使用这些内嵌原语,并提供了一个解释其用途的机会。

希望使用 Advanced SIMD 技术的底层软件工程师、库编写人员和其他开发人员会发现本指南非常有用。

本指南的末尾有“检查您的知识”部分,以测试您是否了解以下关键概念:

  • 要了解什么是 Neon,并了解使用 Neon 的不同方法。
  • 了解在 C 语言中使用 Neon intrinsics 函数的基础知识。
  • 要知道在哪里可以找到 Neon intrinsics 函数参考和 Neon 指令集。

什么是 Neon?

Neon 是 Arm Advanced SIMD 架构的实现。

Neon 的目的是通过提供以下内容来加速数据处理:

  • 32个128位向量寄存器,每个寄存器都可以包含多个数据通道。
  • 可同时在多个数据通道上运行的 SIMD 指令。

可以从 Neon 技术中受益的应用包括多媒体和信号处理、3D 图形、语音、图像处理或其他重度依赖定点和浮点性能的应用。

作为一名程序员,有很多方法可以使用 Neon 技术:

  • 支持 Neon 的开源库(例如 Arm Compute Library)提供了利用 Neon 的最简单方法之一。
  • compiler 中的自动向量化功能可以自动优化代码以利用 Neon。
  • Neon intrinsics 是函数调用,编译器将其替换为适当的 Neon 指令。这使得 C 或 C++代码可以直接从底层访问所需的确切 Neon 指令。
  • 为了极高的性能,手工编码的 Neon assembler 程序可能是经验丰富的程序员的最佳方法。

在本指南中,我们将重点介绍对 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 指令集,而无需手写汇编代码。
  • 可移植性:手写的 Neon 汇编指令可能需要针对不同的目标处理器重新编写。包含 Neon intrinsics 函数的 C 和 C++代码可以为新目标或新的执行状态(例如,从 AArch32迁移到 AArch64)进行编译,而代码更改很少甚至无需更改。
  • 灵活性:程序员可以在需要时使用 Neon,在不需要时使用 C/C++,同时免去了许多底层工程问题。

然而,内嵌原语并非在所有情况下都是正确的选择:

  • 与导入库或依赖编译器相比,使用 Neon intrinsics 函数的学习曲线更陡峭。
  • 手工优化的汇编代码可能会提供最大的性能改进范围,即使更难编写。

现在,我们将通读几个使用 Neon intrinsics 函数重新实现一些 C 函数的示例。所选择的示例并未反映出其应用程序的全部复杂性,但会说明内嵌原语的用法,并作为更复杂代码的起点。

示例:RGB 解交错

考虑一个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 elementWhat is it?Why are we using it?
uint8x16_t16个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

矩阵乘法示例

矩阵乘法是许多数据密集型应用程序中执行的操作。它由一组简单重复的算术运算组成:

Neon 优化 C 代码矩阵图
在这里插入图片描述

矩阵乘法过程如下:

  • A- 在第一个矩阵中取一行
  • B- 该行与第二个矩阵中的一列进行点乘
  • C- 将结果存储在新矩阵的相应行和列中

对于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] MijM_array[nj+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矩阵相乘有几个原因:

  • 有些应用程序特别需要4x4矩阵,例如图形或相对论物理学。
  • Neon 向量寄存器包含4个32位值,因此令程序与体系结构相匹配将使优化变得更容易。
  • 我们可以采用这种4x4内核,并在更通用的内核中使用它。

让我们总结一下这里使用的内嵌原语:

Code elementWhat is it?Why are we using it?
float32x4_t4个32位浮点数组成的数组。一个uint32x4_t可以放入128位寄存器中。我们可以确保即使在C代码中也不会浪费寄存器位。
vld1q_f32(…)将4个32位浮点数加载到float32x4_t中的函数。AB得到我们需要的矩阵值。
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 函数进行比较,结果显示:

  • 给定矩阵乘法的算术指令更少,因为我们利用具有完整寄存器打包功能的 Advanced SIMD 技术。纯 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_NEON
    • 编译器支持 Advanced SIMD
    • AArch64架构下始终为1
  • __ARM_NEON_FP
    • 支持 Neon 浮点运算
    • AArch64架构下始终为1
  • __ARM_FEATURE_CRYPTO
    • 加密指令可用。
    • 因此,可以使用加密 Neon 内嵌原语。
  • __ARM_FEATURE_FMA
    • 融合乘加指令可用。
    • 因此可以使用依赖该指令的 Neon 内嵌原语。

该列表并非详尽无遗,Arm C Language Extensions 中进一步详细介绍了宏。

类型

arm_neon.h 中有三种主要的数据类型类别,它们遵循以下模式:

  • baseW_t:标量数据类型
  • baseWxL_t:向量数据类型
  • baseWxLxN_t:向量数组数据类型

其中:

  • base是指基本数据类型。
  • W是基本类型的宽度。
  • L是向量数据类型(例如标量数组)中的标量数据类型实例的数量。
  • N是向量数组类型(例如标量数组结构体)中向量数据类型实例的数量。

通常,WL使得矢量数据类型的长度为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 标量操作。它可以是 bhsd(即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 指令集的完整规范。

有用的培训链接:

参考资料:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值