NEON Intrinsics 介绍
ARM 编译器工具链中的 NEON Intrinsics是一种编写 NEON 代码的方法,该代码比汇编程序更容易维护,同时仍然保持对生成哪些 NEON 指令的控制。
NEON Intrinsics是编译器用适当的 NEON 指令或 NEON 指令序列替换的函数调用。然而,您必须优化代码以充分利用 NEON 单元提供的速度提升。
有些数据类型对应于包含不同大小元素的 NEON 寄存器(D 寄存器和 Q 寄存器)。这些允许您创建直接映射到 NEON 寄存器的 C 变量。这些变量被传递给 NEON 内部函数。编译器将直接生成 NEON 指令,而不是执行实际的子例程调用。
Intrinsics和数据类型,或缩写形式的Intrinsics,提供从 C 或 C++ 源代码访问低级 NEON 功能的能力。软件可以将 NEON 向量作为函数参数或返回值传递,并将它们声明为普通变量。
内部函数提供的控制几乎与编写汇编语言一样多,但将寄存器的分配留给编译器,以便您可以专注于算法。此外,编译器可以像普通 C 或 C++ 代码一样优化Intrinsics,如果可能的话,用更有效的序列替换它们。它还可以执行指令调度以消除指定目标处理器的流水线停顿。这使得源代码比使用汇编语言更易于维护。
NEON Intrinsics在头文件arm_neon.h 中定义。头文件还定义了一组向量类型。
注意 ARMv7 之前的体系结构不支持 NEON 指令。当为早期架构或不包含 NEON 单元的 ARMv7 架构配置文件进行构建时,编译器将 NEON Intrinsics视为普通函数调用。这会导致错误。
NEON Intrinsics矢量数据类型
NEON 矢量数据类型根据以下模式命名:
您可以使用这些矢量数据类型之一指定内在函数的输入和输出。一些内在函数使用向量类型的数组。它们组合了两个、三个或四个相同的向量类型:
这些类型是普通的 C 结构,包含名为 val 的单个元素。
这些类型映射由 NEON 加载和存储操作访问的寄存器,可以使用一条指令加载/存储最多四个寄存器。结构定义示例如下:
这些类型仅由加载、存储、转置、交错和解交错指令使用;要对实际数据执行操作,请从各个寄存器中选择元素,例如 <var_name>.val[0] 和 <var_name>.val[1]。
为 2 到 4 之间的数组长度定义了数组类型,表 4-1 中列出了任何向量类型。
注意:向量数据类型和向量数据类型的数组不能通过直接文字赋值来初始化。您可以使用 load 内部函数之一或使用 vcreate 内部函数来初始化它们,
NEON Intrinsics 原型
内在函数使用类似于 NEON 统一汇编器语法的命名方案:
提供了一个附加的 q 标志来指定内在函数对 128 位向量进行操作。
例如:l代表有符号,u代表无符号
注意:使用 __fp16 的 NEON 内部函数原型仅适用于具有 NEON 半精度 VFP 扩展的目标。
要启用 __fp16,请使用 --fp16_format 命令行选项。
使用 NEON 内在函数
ARM 编译器工具链在名为 arm_neon.h 的特殊头文件中定义 NEON 内在函数。
内联函数是 ARM ABI 的一部分,因此可以在 ARM 编译器工具链和 GCC 之间移植。
使用“q”后缀的内联函数通常在 Q 寄存器上运行。不带“q”后缀的内在函数通常在 D 寄存器上运行,但其中一些内在函数可能使用 Q 寄存器。
下面的示例显示了同一内在函数的不同变体。
uint8x8_t vadd_u8(uint8x8_t a, uint8x8_t b);内在 vadd_u8 没有“q”后缀。在这种情况下,输入和输出向量是 64 位向量,使用 D 寄存器。
uint8x16_t vaddq_u8(uint8x16_t a, uint8x16_t b);内在 vaddq_u8 具有“q”后缀,因此输入和输出向量是 128 位向量,使用 Q 寄存器。
uint16x8_t vaddl_u8(uint8x8_t a, uint8x8_t b);内在 vaddl_u8 没有“q”后缀。在这种情况下,输入向量是 64 位,输出向量是 128 位。
一些 NEON 内在函数使用 32 位 ARM 通用寄存器作为输入参数来保存标量值。例如,从向量中提取单个值 (vget_lane_u8)、设置向量的单个通道 (vset_lane_u8)、从文字值创建向量 (vcreate_u8) 以及将向量的所有通道设置为相同的文字值 ( vdup_n_u8)。
对每种类型使用单独的内在函数意味着很难意外地对不兼容的类型执行操作,因为编译器将跟踪哪些类型保存在哪些寄存器中。
编译器还可以重新安排程序流程并使用替代的更快指令。无法保证生成的指令将与内在函数隐含的指令相匹配。当从一种微架构迁移到另一种微架构时,这尤其有用。
示例中的代码显示了一个短函数,该函数采用 32 位无符号整数的四通道向量作为输入参数,并返回一个向量,其中所有通道中的值都已加倍。
上述代码对应的反汇编版本,该代码是针对硬浮点 ABI 编译的。 double_elements() 函数转换为单个 NEON 指令和返回序列:
显示了为软件链接而编译的同一示例的反汇编。在这种情况下,代码必须在使用前将参数从 ARM 通用寄存器复制到 NEON 寄存器。计算完成后,必须将返回值从 NEON 寄存器复制回 ARM 通用寄存器:
GCC 和 armcc 支持相同的内在函数,因此使用 NEON 内在函数编写的代码在工具链之间完全可移植。您必须在使用内部函数的任何源文件中包含arm_neon.h 头文件,并且必须指定命令行选项。
使用内在函数优化源模块非常有用,也可以为不实现 NEON 技术的处理器进行编译。宏 ARM_NEON 是由 GCC 在编译实现 NEON 技术的目标时定义的。 RVCT 4.0 build 591 或更高版本以及 ARM 编译器工具链也定义了此宏。软件可以使用此宏来提供文件中提供的函数的优化版本和普通 C 或 C++ 版本,由传递给编译器的命令行参数进行选择。
有关内部函数和向量数据类型的信息,请参阅《ARM Compiler
toolchain Compiler Reference Guide》,可从 http://infocenter.arm.com 获取。 GCC 文档可从 http://gcc.gnu.org/onlinedocs/gcc/ARM-NEON-Intrinsics.html 获取
NEON 代码中的变量和常量
NEON 代码访问变量数据或常量数据的示例代码。
声明一个变量
声明一个新变量就像在 C 中声明任何变量一样简单:
uint32x2_t vec64a, vec64b; // 创建两个D寄存器变量
使用常量
使用常量很简单。以下代码将把一个常量复制到向量的每个元素中: uint8x8 start_value = vdup_n_u8(0);
要将通用 64 位常量加载到向量中,请使用:
uint8x8 start_value = vreinterpret_u8_u64(vcreate_u64(0x123456789ABCDEFULL));
将结果赋值给正常的 C 变量
要访问 NEON 寄存器的结果,请使用 VST 将其存储到内存,或使用“get lane”类型操作将其移回 ARM:
result = vget_lane_u32(vec64a, 0); // extract lane 0
从 Q 寄存器访问 D 寄存器
使用 vget_low 和 vget_high 从 Q 寄存器访问 D 寄存器:
vec64a = vget_low_u32(vec128); // 分割128位向量
vec64b = vget_high_u32(vec128); // 转换为 2x 64 位向量
在不同类型之间转换 NEON 变量
NEON 内在函数是强类型的,因此它们必须在不同类型的向量之间显式转换。要转换向量,请对 D 寄存器使用 vreinterpret,或对 Q 寄存器使用 vreinterpretq。这些内在函数不会生成任何代码,而只是使您能够转换 NEON 类型:
uint8x8_t byteval;
uint32x2_t wordval;
byteval = vreinterpret_u8_u32(wordval);
uint8x16_t byteval2;
uint32x4_t wordval2;
byteval2 = vreinterpretq_u8_u32(wordval2);
输出类型 u8 列在 vreinterpret 之后、输入类型 u32 之前
从 C 访问向量类型
使用内部函数需要头文件arm_neon.h,并为向量运算定义C 样式类型。 C 类型的写法如下:
uint8x16_t 这是一个包含无符号 8 位整数的向量。向量中有 16 个元素。因此向量必须位于 128 位 Q 寄存器中。
int16x4_t 这是一个包含有符号 16 位整数的向量。向量中有 4 个元素。因此向量必须位于 64 位 D 寄存器中。
由于 ARM 标量和 NEON 向量类型之间不兼容,因此无法将标量分配给向量,即使它们具有相同的位长度也是如此。标量值和指针只能与直接使用标量的 NEON 指令一起使用。
例如,要从 NEON 向量的通道 0 中提取无符号 32 位整数,请使用:
result = vget_lane_u32(vec64a, 0)
在armcc中,除了赋值之外,向量类型不能使用标准C运算符进行操作。因此应使用适当的 VADD 内在函数而不是运算符“+”。然而,GCC 允许标准 C 运算符对 NEON 向量类型进行操作,从而使代码更具可读性。
如果向量类型仅在元素数量上有所不同(uint32x2_t、uint32x4_t),则有特定指令将 128 位值的顶部或底部向量元素分配给 64 位值,反之亦然。如果寄存器可以被调度为别名,则此操作不使用任何代码空间。
要使用 128 位寄存器的底部 64 位,请使用:
vec64 = vget_low_u32(vec128);
将数据从内存加载到向量中
如何使用 NEON 内在函数创建向量。来自内存位置的连续数据可以加载到单个向量或多个向量。执行此操作的 NEON 内在函数是 vld1_datatype。例如,要加载具有四个 16 位无符号数据的向量,请使用 NEON 内在函数 vld1_u16。
在以下示例中,数组 A 包含八个 16 位元素。该示例展示了如何将此数组中的数据加载到向量中。
可与 vld1_datatype 内在函数 (VLD1)(第 D-120 页)一起使用的向量类型的信息:
从文字(literal)位模式构造向量
您可以根据文字值创建向量。执行此操作的 NEON 内在函数是 vcreate_datatype。
例如,如果要加载具有 8 个 8 位无符号数据的向量,可以使用 NEON 内在 vcreate_u8。
该示例展示了如何从文字数据创建向量。
从交错内存构造多个向量
很多时候,内存中的数据是交错的。 Neon 内在函数支持 2 路、3 路和 4 路交错模式。
例如,存储器区域可能包含左声道数据和右声道数据交织的立体声数据。这是 2 路交错模式的示例。
另一个例子是内存包含 24 位 RGB 图像。 24 位 RGB 图像是来自红色、绿色和蓝色通道的 8 位数据的 3 路交错。当内存包含交错数据时,解交错使您能够加载包含所有红色值的向量、包含所有绿色值的单独向量以及包含所有蓝色值的单独向量。
去交错的 NEON 内在函数是 vldn_datatype,其中 n 代表交错模式,可以是 2、3 或 4。如果要将 24 位 RGB 图像去交错为 3 个不同的向量,可以使用 NEON 内在函数 vld3_u8 。
该示例演示如何从内存中的 24 位 RGB 图像中解交织三个向量:
从内存加载向量的单个通道
如果要从内存中分散的数据构建向量,则必须使用单独的内部函数单独加载每个通道。
执行此操作的 NEON 内在函数是 vld1_lane_datatype。例如,如果您想要加载具有 8 位无符号数据的向量的一个通道,您可以使用 NEON 内在函数 vld1_lane_u8。
使用 NEON 内在函数进行编程
直接在汇编程序中或使用内部函数接口编写最佳的 NEON 代码需要彻底了解所使用的数据类型以及可用的 NEON 指令。
要了解要使用哪些 NEON 操作,了解如何将算法拆分为并行操作会很有用。
从 SIMD 的角度来看,交换运算(例如加法、最小值和最大值)特别容易。
要将数组中的八个数字相加:
上面的代码显示可以使用一个向量来保存累加器和临时寄存器的四个 32 位值。这假设对数组元素求和适合 32 位通道。然后可以使用 SIMD 指令执行操作。将代码扩展为四的任意倍数:
vget_high_u32 和 vget_low_u32 与任何 NEON 指令都不相似。这些内在函数指示编译器从输入 Q 寄存器引用高位或低位 D 寄存器。因此,这些操作不会转换为实际代码,但它们会影响哪些寄存器用于存储 vec64a 和 vec64b。
根据编译器的版本、目标处理器和优化选项,生成的汇编代码将变为:
没有等效内在指令的指令
大多数 NEON 指令都有等效的 NEON 内在函数。以下NEON 指令没有等效的内在函数:
• VSWP • VLDM • VLDR • VMRS • VMSR • VPOP • VPUSH • VSTM • VSTR • VBIT • VBIF。
VBIF 和 VBIT 无法显式生成。但内在 VBSL 可以生成任何 VBSL、VBIT 或 VBIF 指令。
VSWP 指令没有内在函数,因为编译器可以在必要时生成 VSWP 指令,例如使用简单的 C 样式变量赋值交换变量时。
VLDM、VLDR、VSTM和VSTR主要用于上下文切换,这些指令具有对齐约束。编写内部函数时,使用 vldx 内部函数更简单。除非明确指定,否则 vldx 内在函数不需要对齐。
VMRS 和 VMSR 访问 NEON 的条件标志。对于使用 NEON 内在函数进行数据处理来说,这些并不是必需的。
VPOP 和 VPUSH 用于向函数传递参数。减少变量重用或使用更多 NEON 内在变量,允许寄存器分配器跟踪活动寄存器。
优化 NEON 代码
介绍了在针对特定处理器优化 NEON 代码时,应如何考虑该处理器如何集成 NEON 技术的实现定义方面。
优化 NEON 汇编代码
考虑处理器如何集成 NEON 技术的实现定义方面,因为针对特定处理器优化的指令序列在不同处理器上可能具有不同的时序特征,即使 NEON 指令周期时序相同。
为了从手写的 NEON 代码中获得最佳性能,有必要了解一些底层硬件功能。特别是,程序员应该意识到流水线和调度问题、内存访问行为和调度危险
Cortex-A 处理器之间的 NEON 管道差异
Cortex-A8 和 Cortex-A9 处理器共享相同的基本 NEON 管道,尽管其集成到处理器管道的方式存在一些差异。 Cortex-A5 处理器包含完全兼容的简化 NEON 执行管道,但它是为尽可能最小和最低功耗的实现而设计的
内存访问优化
NEON 单元很可能会处理大量数据,例如数字图像。
一项重要的优化是确保算法以最适合缓存的方式访问数据。这样可以从 L1 和 L2 缓存获得最大命中率。考虑活动内存位置的数量也很重要。在 Linux 下,每个 4KB 页都需要一个单独的 TLB 条目。 Cortex-A9 处理器有 32 个元素的微 TLB 和一个 128 个元素的主 TLB,之后它将开始使用 L1 缓存来加载页表条目。典型的优化是安排算法处理适当大小的图像数据,以最大化缓存和 TLB 命中率。
支持交织和解交织的指令可以为性能改进提供显着的范围。 VLD1 从内存加载寄存器,不进行解交错。
然而,其他 VLDn 操作使我们能够加载、存储和解交织包含两个、三个或四个同等大小的 8、16 或 32 位元素的结构。 VLD2 加载两个或四个寄存器,对偶数和奇数元素进行解交织。例如,这可用于分割左声道和右声道立体声音频数据,如第 5-3 页上的图 5-1 所示。类似地,VLD3可用于将RGB像素分割成单独的像素
时序
为了从 NEON 单元获得最佳性能,您必须了解如何为您使用的特定 ARM 处理器调度代码。建议仔细手动调度,以充分利用您编写的任何 NEON 汇编程序代码,特别是对于视频编解码器等性能关键型应用程序。
如果编写 C 或 NEON 内在函数,编译器(GCC 或 armcc)将自动调度来自 NEON 内在函数或可向量化 C 源代码的代码,但它仍然可以帮助使源代码尽可能友好地进行调度优化。
NEON指令调度
NEON指令流经ARM管道,然后进入ARM和NEON管道之间的NEON指令队列。虽然从ARM流水线的角度来看,NEON指令队列中的一条指令已经完成,但NEON单元仍然必须对指令进行解码和调度。
只要这些队列未满,处理器就可以继续运行并执行 ARM 和 NEON 指令。当 NEON 指令或数据队列已满时,处理器会停止执行下一条 NEON 指令,直到队列中有该指令的空间。以这种方式,NEON单元中调度的NEON指令的周期时序可以影响指令序列的整体时序,但前提是有足够的NEON指令来填充指令或数据队列。
注意 当处理器配置为没有 NEON 单元时,所有尝试的 NEON 和 VFP 指令都会导致未定义指令异常
混合 ARM 和 NEON 指令序列
如果序列中的大多数指令是 NEON 指令,则 NEON 单元指示该序列所需的时间。序列中偶尔有 ARM 指令与 NEON 指令并行出现。如果序列中的大多数指令是 ARM 指令,则它们主导序列的时序,并且 NEON 数据处理指令通常需要一个周期。在手动计算周期时序时,必须考虑是 ARM 指令还是 NEON 指令占主导地位。
在 ARM 通用寄存器和 NEON 寄存器之间传递数据
使用 VMOV 指令将数据从 NEON 寄存器传递到 ARM 寄存器。然而,这很慢,尤其是在 Cortex-A8 上。数据从 NEON 流水线后面的 NEON 寄存器文件移动到 ARM 流水线开头的 ARM 通用寄存器文件。
多次连续传输可以隐藏部分延迟。处理器继续发出 VMOV 指令后的指令,直到遇到必须读取或写入 ARM 通用寄存器文件的指令。此时,指令发出将停止,直到所有待处理的寄存器从 NEON 寄存器到 ARM 通用寄存器的传输完成。
使用 VMOV 指令还可以将数据从 ARM 通用寄存器传递到 NEON 寄存器。对于 NEON 单元,传输类似于 NEON 加载指令。
NEON 指令的双重发行
NEON 单元的双重发射功能有限,具体取决于实施方式。加载/存储、置换、MCR 或 MRC 类型指令可以与 NEON 数据处理指令同时发出。加载/存储、置换、MCR 或 MRC 类型指令在 NEON 加载和存储置换管道中执行。 NEON 数据处理指令在 NEON 整数 ALU、移位、MAC、浮点加法或乘法流水线中执行。这是唯一允许的双发配对。
NEON 单元可以在多周期指令的第一个周期(使用较旧的指令)和多周期指令的最后一个周期(使用较新的指令)进行双重发布。多周期指令的中间周期不能配对,必须是单个指令。
如何读取 NEON 指令表
在这些 NEON 指令表中,QLo 映射到 D<2n>,QHi 映射到 D<2n+1>。
(1)NEON 整数 ALU 指令
VADDL.S16 Q2, D1, D2
这是整数 NEON 向量和长指令。 Source1(本例中为 D1)和 Source2(本例中为 D2)在 N1 中都是必需的。在这种情况下,结果存储在 Q2 中,可在 N3 中用于需要该寄存器作为源操作数的下一条后续指令。
(2)NEON 浮点乘法指令
VMUL.F32 Q0, Q1, D4[0]
这是浮点 NEON 向量乘标量指令。它是一条多周期指令,在第一个和第二个周期中都有源操作数要求。在第一个周期中,N2 中需要 Source1(在本例中为 Q1Lo 或 D2)。 N1 中需要 Source2(本例中为 D4)。在第二个周期中,N2 中需要 Source1(本例中为 Q1Hi 或 D3)。在本例中,乘法结果存储在 Q0 中,可在 N5 中用于需要该寄存器作为源操作数的下一条指令。结果的低半部分 Q0Lo 或 D0 在第一个周期中计算。结果的高半部分 Q0Hi 或 D1 在第二个周期中计算。假设没有数据危险,指令至少需要两个周期才能执行,如周期列中的值所示。
(3)结果使用调度
这是编写 NEON 代码时主要的性能优化。 NEON 指令通常在一个周期内发出,但结果并不总是在下一个周期准备好,除了最简单的 NEON 指令,例如 V ADD 和 VMOV 。
在某些情况下,可能会出现相当大的延迟,特别是 VMLA 乘法累加(整数为 5 个周期;浮点为 7 个周期)。使用这些指令的代码应该进行优化,以避免在结果值准备好之前尝试使用它,否则会发生停顿。
尽管有几个周期导致延迟,但这些指令完全流水线化,因此多个操作可以同时“进行”。
对于大多数指令,Cortex-A8 和 Cortex-A9 处理器的结果延迟是相同的。 Cortex-A5 处理器使用简化的 NEON 架构,该架构更适合降低功耗和面积实现,并且大多数 NEON 指令具有 3 个周期的结果延迟。
双发出调度 在 Cortex-A8 处理器上,某些类型的 NEON 指令可以并行发出(在一个周期内)。加载/存储、排列或 MCR/MRC 类型指令可以与 NEON 数据处理指令双重发出,例如浮点加法或乘法,或 NEON 整数 ALU、移位或乘法累加。程序员可以通过将代码排序来节省周期
通过变量传播进行优化
编写程序时经常会想减少使用的变量数量。
当使用 NEON 内在函数时,这不一定是有益的。
此示例演示了一个将两个 4x4 浮点矩阵相乘的函数。每个矩阵都以列优先格式存储。这意味着矩阵存储为十六个浮点数的线性数组,其中每列的元素连续存储。
该函数一次计算一列矩阵R = 矩阵A * 矩阵B。这是第 7-2 页的矩阵乘法中给出的矩阵乘法示例的变体。
由于向量变量 r 的重用,上面的 NEON 代码对编译器有调度限制。在继续下一列之前,它必须完整地计算每一列。由于每个浮点乘法 (vmulq) 或乘法累加 (vmlaq) 取决于前一条指令的结果,因此 NEON 单元无法将更多指令调度到管道中。
这意味着 NEON 单元将停止运行,同时等待上一个操作完成
将数据加载到不同的变量中
实现上述内容的另一种方法是在函数开头将两个矩阵的所有列加载到不同的变量中:
第二个实现具有与第一个实现相同数量的加载、存储和乘法。但现在编译器有更大的自由度来调度代码。例如,它可以在函数开始时执行所有加载,以便它们在需要之前完成。此外,它还可以在交替列上执行乘法和累加指令 (vmlaq),以便相邻指令之间不存在数据依赖性。这减少了失速。例如,在以下四个内在函数中,由于使用不同的变量 r0、r1、r2 和 r3,而不是相同的变量 r,因此不存在数据依赖性。因此,编译器可以将以下四个内在函数一起调度:
使用加长指令时的优化
您也许可以用一条 NEON 指令替换两条 NEON 指令。例如,vmovl延长操作可以作为其他延长指令的一部分来执行。此示例显示单独的 vmovl 和 vshl 指令。
vmovl.u16 q7, d31 …
vshl.s32 q7, q7, #8 注意 vshl 指令使用寄存器 q7 作为输入,它是 vmovl 指令的输出。
这会产生对寄存器 q7 的数据依赖性。因此,最好在它们之间安排不使用寄存器 q7 的其他指令。
这两条指令可以用vshll指令代替。
vshll.s32 q7,d31,#8