Translated from 《NEON Programmer’s Guide》
翻译可能有偏差,描述可能有错误,请以原著为准😁
Chapter 1 : Introduction
Chapter 2 : Compiling NEON Instructions
Chapter 3 : NEON Instruction Set Architecture
Chapter 4 : NEON Intrinsics
Chapter 5 : Optimizing NEON Code
本章介绍了如何使用C或汇编,写针对NEON硬件的代码。以及一些工具和库,用来支持此功能。
1. Vectorization
向量化
NEON被设计成一种附加的加载/存储体系结构,以提供对C / C ++等语言的良好向量化编译器支持。这实现了高度的并行性,可以手动编码NEON指令,实现非常高性能的应用程序。它包括低成本升级和降低数据大小,还包括结构化的加载能力,可以访问那些交错在内存的多个数据流。
可以把NEON当做常规ARM代码的一部分来写。与使用外部硬件加速器相比,这使NEON编程更简单,更高效。NEON指令可用于读取和写入外部存储器,在NEON寄存器和其他ARM寄存器之间移动数据,以及执行SIMD操作。
所有已编译的代码和子例程都将遵循EABI(Embedded Application Binary Interface),EABI指定了哪些寄存器可能损坏,以及哪些寄存器必须保留。
向量化编译器可以获取您的C或C ++源代码,并将其向量化,从而可以有效利用NEON硬件。 这意味着您可以编写可移植的C代码,同时仍然获得NEON指令所能实现的性能水平
为了辅助向量化,请将循环迭代的次数设为向量长度的倍数。 GCC和ARM编译器工具链都有选项,可以为NEON技术启用自动向量化。但由于C和C ++标准未涵盖并发方面,因此要向编译器提供其他信息,才能得到NEON的好处。 修改的源代码也是是标准语言规范的一部分,因此不会影响代码在平台和体系结构之间的可移植性。
当向量化编译器可以确定程序员的意图时,其效果最佳。 相比针对特定处理器进行了高度优化的代码,人们能够理解的简单代码更容易向量化。
1.1 Enabling auto-vectorization in ARM Compiler toolchain
在ARM交叉编译链中启动自动向量化
DS-5™Professional中的ARM编译器工具链包括对向量化编译器的支持。 要启用自动向量化,您必须针对具有NEON单元的处理器。 所需的命令行选项是:
--vectorize
,启动向量化--CPU 7A
或--Cpu Cortex A8
,选择指定的架构和核,支持NEON-O2
或-O3
,选择高级或者低级的优化-Otime
,为速度做优化(而不针对空间)
使用armcc命令行参数--remarks
可提供有关执行优化的更多信息,或提供有关编译器无法执行某些优化的问题。
1.2 Enabling auto-vectorization in GCC compiler
在GCC编译其中启动自动向量化
为了在GCC中启动自动向量化,用下面的命令行选项:
-ftree-vectorize
-mfpu=neon
- -
mcpu
,指定内核或者架构
用优化级别-O3
编译,相当于-ftree-vectorize
。
如果没有指定-mcpu
选项,则GCC将使用内置的默认内核,生成的代码可能运行缓慢或根本不运行。
在许多支持SIMD操作的架构上,-ftree-vectorize
是可用的。
1.3 C pointer aliasing
C指针别名
优化标准C(ISO C90)的主要挑战是,你可以解引用指针,而这个指针可能(根据标准)指向相同或重叠数据集。这会导致什么问题?后面的例子有讲。
随着C标准的发展,通过在C99和C++中添加关键字limit
来解决此问题。在指针声明中添加限制是一种保证,只有该指针才能访问其指向的地址(就像unique_ptr智能指针)。 这使编译器可以进行设置和退出限制方面的工作,可以在事先通知的情况下预加载数据,并缓存中间结果。
ARM编译器允许在所有模式下使用关键字__restrict
。如果指定命令行选项--restrict
,则可以使用不带下划线的关键字restrict
。GCC有类似的选择,有关更多信息,请参见GCC文档。
1.4 Natural types
自然类型
由于历史原因、内存大小、外围因素等,算法常被设计用于处理某种类型数据。通常将这些类型转换成处理器的自然类型,因为对这种大小的数据进行数学运算通常会更快,并且仅在传递或存储数据时才计算截断和溢出。
1.5 Array grouping
数组分组
处理器中用于存储内存指针的寄存器很少(例如x86),对于这样的处理器,通常将多个数组组合起来。 然后用一个指针,通过指针位移来访问不同部分的数据。这会让编译器误以为偏移指针导致数据重叠。除非您可以保证不对阵列进行任何写操作,才能避免这种情况。将复合数组拆分为单个数组,以简化指针的使用能消除这种风险。
1.6 Inside knowledge
内部知识
要把没有size信息的数组转换为NEON代码,编译器必须假定size可能在0到4GB之间。 在没有其他信息的情况下,编译器必须生成设置代码(用于测试数组是否太小而无法使用整个NEON寄存器),以及用于清理的代码(使用标量管道(pipeline)来消耗数组中的最后几项)。
在某些情况下,数组大小在编译时是已知的,应直接指定而不是作为参数传递。在其他情况下,工程师通常比编译器更了解矩阵的布局。例如,数组通常表示为2的幂。程序员可能知道循环迭代计数将始终是2、4、8、16、32的倍数,依此类推。可以编写代码来利用它。
1.7 Enabling the NEON unit in bare-metal applications
在裸机应用中启用NEON单元
裸机应用程序是直接在硬件上运行的应用程序,无需内核或操作系统支持。
默认情况下,NEON单元在重置时处于禁用状态,因此对于需要NEON指令的裸机应用,必须手动启用它。EnableNEON
代码段显示了如何手动启用NEON设备。
// hello.c:
#include <stdio.h>
// Bare-minimum start-up code to run NEON code
__asm void EnableNEON(void)
{
MRC p15,0,r0,c1,c0,2 // Read CP Access register
ORR r0,r0,#0x00f00000 // Enable full access to NEON/VFP by enabling access to Coprocessors 10 and 11
MCR p15,0,r0,c1,c0,2 // Write CP Access register
ISB
MOV r0,#0x40000000 // Switch on the VFP and NEON hardware
MSR FPEXC,r0 // Set EN bit in FPEXC
}
为带有NEON单元的处理器编译裸机应用程序时,编译器可能会使用NEON指令。例如,ARM编译器工具链armcc默认情况下使用-O2
优化,如果指定了-Otime
和--vectorize
选项,则它会尝试使用NEON单元对处理器的代码进行向量化。
可以像这样把上面的代码编译成裸机应用程序:
armcc -c --cpu=Cortex-A8 --debug hello.c -o hello.o
armlink --entry=EnableNEON hello.o -o hello.axf
1.8 Enabling the NEON unit in a Linux stock kernel
在原版Linux内核中启用NEON单元
原版的内核在Linux在www.kernel.org上发布,没有经过修改。如果您使用原版的Linux内核来运行应用程序,则无需手动启用NEON单元。内核在遇到第一条NEON指令时自动启用NEON单元。
如果NEON单元被禁用,而应用程序尝试执行NEON指令,它将抛出<未定义的指令>异常。内核使用这个异常来启用NEON单元,然后执行NEON指令。NEON单元保持启用状态,直到有上下文切换。当需要上下文切换时,内核可能会禁用NEON单元以节省时间和电源。
1.9 Enabling the NEON unit in a Linux custom kernel
在自定义的Linux内核中启用NEON单元
如果使用修改过的的Linux内核运行应用程序,则必须启用NEON单元。要启用NEON单元,必须使用内核配置设置来选择:
- Floating point emulation → VFP-format floating point maths
- Floating point emulation → Advanced SIMD (NEON) Extension support
图1显示了通用快速板的配置设置。
如果存在/proc/config.gz
,则可以使用以下命令在内核中测试对NEON的支持:
zcat /proc/config.gz | grep NEON
如果存在NEON单元,命令会输出:
CONFIG_NEON=y
为了确保处理器支持NEON扩展,可以使用命令:
cat /proc/cpuinfo | grep neon
如果它支持NEON扩展,则输出:
Features : swp half thumb fastmult vfp edsp neon vfpv3 tls vfpv4 idiva idivt
1.10 Optimizing for vectorization
向量化优化
C和C++语言没有提供指定并发行为的语法,因此编译器无法安全地生成并行代码。但是,开发人员可以提供其他信息,让编译器知道在什么地方可以安全地进行向量化。
与内部函数(intrinsics)不同,这些修改不依赖于体系结构,并且可能会改善任何目标平台上的向量化。在那些无法进行向量化的平台上,这些修改不会对性能造成负面影响。
下面描述主要规则:
- 短、简单的循环效果最好(即使代码中有多个循环)
- 避免用break来退出循环
- 把迭代次数设为2的幂
- 让编译器能明确迭代次数
- 在一个循环内部调用的函数,应该用内联(inlined)函数
- 在数组中,用索引好过用指针
- 间接寻址(多个索引或解引用)不会向量化。
- 使用
restrict
关键字告诉编译器,让指针不引用内存的重叠区域。
Indicate knowledge of number of loop iterations
关于循环迭代次数的知识
如果循环具有固定的迭代计数,或者您知道迭代计数始终为2的幂,则对于编译器而言显而易见的是,这使编译器能够执行优化,否则该优化是不安全的或困难的。
下面的代码显示了一个函数,该函数计算int
大小的元素个数(len)。如果您知道传进来的参数len
始终是4的倍数,在比较循环次数的时候,将最低两位置零(len & ~3
),通过这种方式向编译器指示这一点。这样可以确保循环始终执行4的倍数次。这样编译器可以安全地向量化,与此同时:
- 无需为了在运行时检查
len
而添加代码。 - 无需添加代码来处理悬空的迭代。
// 指定循环计数器是4的倍数
int accumulate(int * c, int len)
{
int i, retval;
for(i = 0, retval = 0; i < (len & ~3) ; i++) //
{
retval = retval + c[i]
}
return retval;
}
Avoid loop-carried dependencies
避免循环携带的依赖
如果您的代码包含一个循环,某次循环受到上一次循环结果的影响,则编译器无法对其进行向量化。最好重新检查代码,消除这种循环中的依赖。
Use the restrict keyword
使用restrict
关键字
C99引入了restrict
关键字,可用于通知编译器,通过特定指针访问的位置,不能通过当前作用域内的任何其他指针访问。
下面的代码展示了一种情况,其中e
指向要更新位置,对这个指针使用restrict
会使向量化变得安全,否则就不安全。
int accumulate2(char * c, char * d, char * restrict e, int len)
{
int i;
for(i=0 ; i < (len & ~3) ; i++)
{
e[i] = d[i] + c[i];
}
return i;
}
如果没有restrict
关键字,则编译器会假定e[i]
可以引用与d[i+1]
相同的位置,这可能会产生循环依赖关系,从而阻止它对向量序列进行向量化处理。程序员可以用restrict
告诉编译器,通过e
访问的任何位置只能通过此函数中的指针e
访问。这意味着编译器可以忽略混叠的可能性,并可以对序列进行向量化处理。
使用restrict
关键字不能使编译器对函数调用执行其他检查。因此,如果传递给函数的c
或d
值与e
的值重叠,则向量化代码可能无法正确执行。
编译的不是C99标准的代码时,GCC和ARM编译器工具链都支持替代形式__restrict__
和__restrict
。 当在编译命令行指定了--restrict
时,ARM编译工具链还支持在C90和C++中使用restrict
关键字。
Avoid conditions inside loops
避免循环内的条件
通常,编译器无法向量化包含条件语句的循环。在最佳情况下,它会复制循环,但在许多情况下根本无法向量化。
Use suitable data types
使用合适的数据类型
当在没有SIMD的情况下,优化某些对16位或8位数据运行的算法时,将它们当做32位变量来用,有时会获得更好的性能。在写自动地向量化的软件时,为了获得最佳性能,请始终使用可容纳所需值的最小数据类型。这样NEON寄存器可以容纳更多数据元素,并并行执行更多操作。在给定的周期内,NEON单元可以处理的8位值是16位值的两倍。
另外,NEON技术不支持某些数据类型,而某些操作仅支持某些数据类型。例如,它不支持双精度浮点数,因此在单精度浮点数足够的情况下,使用双精度数可能会阻止编译器对代码进行向量化处理。NEON技术仅对某些操作支持64位整数,因此请尽可能避免使用long long
变量。
注意
NEON技术包括一组可以执行结构化加载和存储操作的指令。这些指令只能用于向量访问所有成员大小均相同的数据结构。
Floating-point vectorization
浮点向量化
浮点运算可能会导致精度降低。可以再排列一下操作的顺序或浮点输入的数据,以减少精度损失。更改操作或输入的顺序可能会导致精度进一步下降。 因此,默认情况下不会对某些浮点运算进行向量化,因为向量化会更改操作的顺序,导致程序逻辑的改变。如果算法不需要这种精度水平,则可以在命令行上为armcc指定--fpmode=fast
或为GCC指定-ffast-math
以启用这些优化。
下面展示了一段代码,使用上面其中一个命令行参数来进行向量化。在这种情况下,它将执行并行累加,可能会降低结果的精度。
float g(float const *a)
{
float r = 0;
int i;
for (i = 0 ; i < 32 ; ++i)
r += a[i];
return r;
}
NEON单元始终以“对齐到零(Flush-to-zero)”模式运行,导致不符合IEEE754。默认情况下,armcc使用--fpmode=std
以符合标准。 但是,如果命令行参数指定要求符合IEEE 754的模式选项,例如--fpmode=ieee_full
,则大多数浮点操作无法向量化。
2. Generating NEON code using the vectorizing compiler
使用向量化编译器生成NEON代码
向量化编译器会预估可向量化的循环,以及潜在的NEON应用。如果你写的C或C ++代码使编译器可以确定代码的意图,则编译器将更有效地对其进行优化。尽管编译器可以在不修改源代码的情况下生成一些NEON代码,但是某些编码样式可以促进更佳的输出。当向量化器(vectorizer)发现代码具有潜在的向量化机会,但没有足够的信息时,它可以为用户生成注释,以提示用户对源代码进行更改,以提供更多有用的信息。尽管这些修改能帮助向量化编译器,但它们都是标准C表示法,并允许使用任何符合C99*的编译器进行重新编译。解析restrict
关键字需要用到C99。在其他编译模式下,armcc还允许使用等效的ARM特定扩展名__restrict
。
2.1 Compiler command line options
编译器命令行选项
如果有向量化许可证选项,可以通过使用O2
或O3
,Otime
,vectorize
和cpu
选项告诉编译器生成NEON代码。cpu
选项必须指定具有NEON硬件的处理器。
由于数组清理和其他开销,SIMD代码有时大于等效的ARM代码。
要在Cortex-A8目标上生成快速NEON代码,应使用以下命令行:
armcc --cpu=Cortex-A8 -O3 -Otime --vectorize ...
如果您没有向量化编译器的许可证,则此命令将回复一条错误消息。
Using the vectorizing compiler
使用向量化编译器
我们可以简洁地编写C代码:
/* file.c */
unsigned int vector_add_of_n(unsigned int* ptr, unsigned int items)
{
unsigned int result=0;
unsigned int i;
for (i=0; i<(items*4); i+=1)
{
result += ptr[i];
}
return result;
}
注意
- 通过使用
(items*4)
,我们告诉编译器数组的大小是4的倍数,这种就属于有效信息。尽管向量化程序不需要创建NEON代码,但它为编译器提供了有关数组的更多信息。在这种情况下,它知道该数组可以与向量数组一起使用,并且不需要任何额外的标量代码来处理任何备用项的清理。 - 传递给函数的值
items
值必须是数组中实际项目数的 1 / 4 1/4 1/4,例如:
vector_add_of_n(p_list, item_count/4);
用以下命令编译上述代码:
armcc --cpu=Cortex-A8 -O3 –c -Otime –-vectorize file.c
使用命令fromelf –c file.o
查看生成的文件:
vector_add_of_n PROC
LSLS r3,r1,#2
MOV r2,r0
MOV r0,#0
BEQ |L1.72|
LSL r3,r1,#2
VMOV.I8 q0,#0
LSRS r1,r3,#2
BEQ |L1.48| |L1.32|
VLD1.32 {d2,d3},[r2]!
VADD.I32 q0,q0,q1
SUBS r1,r1,#1
BNE |L1.32| |L1.48|
CMP r3,#4
BCC |L1.72|
VADD.I32 d0,d0,d1
VPADD.I32 d0,d0,d0
VMOV.32 r1,d0[0]
ADD r0,r0,r1 |L1.72|
BX lr
尽管这代码比手写的代码长,但例程的主要部分(内部循环)的长度相同,并且包含相同的指令。 这意味着,如果数据集大小合理,则执行的时间差很小。但是在Cortex-A8处理器上,与手写代码相比,编译器生成的代码是非最佳计划的。因此,Cortex-A8处理器的性能差异随数据集大小而定。
3. Vectorizing examples
使用向量化编译器生成NEON代码
3.1 Vectorization example on unrolling addition function
展开附加功能的向量化示例
考虑下面的代码:
Void add_int (int * __restrict pa, int * __restrict pb, unsigned int n, int x)
{
unsigned int i;
for (i = 0; i < (n & ~3); i++)
pa[i] = pb [i] + x;
}
1.分析每次循环:
- 指针访问对向量化安全吗?
- 正在使用什么数据类型,它们如何映射到NEON寄存器?
- 有多少个循环迭代?
2.将循环展开到适当的迭代次数,然后执行其他转换,例如使用指针。
3.将每个展开的操作映射到NEON向量通道上,并生成相应的NEON指令。
3.2 Vectorizing example with vectorizing compilation
向量化示例与向量化编译
下表显示了使用向量化编译和不使用向量化编译的代码的比较:
3.3 Vectorizing examples with different command line switches
用不同的命令行,对例子进行向量化
本节包含非常简单的编译器向量化例子。 这些示例显示了各种命令行开关的影响以及各种源代码更改的影响。
Optimized for code space
针对代码空间进行了优化
在此示例中,编译器选项-Ospace
生成较小的代码, 生成的代码未针对速度进行优化。
void add_int (int *pa, int * pb, unsigned int n, int x)
{
unsigned int i;
for(i = 0; i < n; i++)
pa[i] = pb[i] + x;
}
附带下面的选项来编译:
armcc --cpu=Cortex-A8 -O3 -Ospace
汇编结果:
add_int PROC
PUSH {r4, r5, 1r}
MOV r4, #0
|L0.8|
CMP r4,r2
LDRCC r5, [r1, r4, LSL, #2]
ADCC r5, r5, r3
STRCC r5, [r0, r4, LSL, #2]
ADCC r4,r4, #1
BCC |L0.8|
POP {r4, r5, pc}
Optimized for time
针对时间优化
在此示例中,编译器选项-Otime
生成代码, 结果比例4
的代码更快更长。
void add_int (int *pa, int * pb, unsigned int n, int x)
{
unsigned int i;
for(i = 0; i < n; i++)
pa[i] = pb[i] + x;
}
附带下面的选项来编译:
armcc --cpu=Cortex-A8 -O3 -Otime
汇编结果:
add_int PROC
CMP r2,#0
BXEQ lr
TST r2,#1
SUB r1,r1,#4
SUB r0,r0,#4
PUSH {r4}
BEQ |L0.48|
LDR r4,[r1,#4]!
ADD r12,r0,#4
ADD r4,r4,r3
STR r4,[r0,#4]
MOV r0,r12
|L0.48|
LSRS r2,r2,#1
BEQ |L0.96|
|L0.56|
LDR r12,[r1,#4]
SUBS r2,r2,#1
ADD r12,r12,r3
STR r12,[r0,#4]
ADD r12,r0,#8
LDR r4,[r1,#8]!
ADD r4,r4,r3
STR r4,[r0,#8]
MOV r0,r12
BNE |L0.56|
|L0.96|
POP {r4}
BX lr
Optimized using knowledge of array size
用已知数组大小来优化
在此示例中,将for循环迭代范围设置为(n & ~3)
。 这告诉编译器数组pa
的大小是4的倍数。
void add_int (int *pa, int * pb, unsigned int n, int x)
{
unsigned int i;
for(i = 0; i < (n&~3); i++)
pa[i] = pb[i] + x;
}
附带下面的选项来编译:
armcc --cpu=Cortex-A8 -O3 -Otime
汇编结果:
add_int
BICS r12,r12,#3
BXEQ lr
LSR r2,r2,#2
SUB r1,r1,#4
SUB r0,r0,#4
LSL r2,r2,#1
PUSH {r4}
|L0.28|
LDR r12,[r1,#4]
SUBS r2,r2,#1
ADD r12,r12,#3
STR r12,[r0,#4]
ADD r12,r0,#8
LDR r4,[r1,#8]!
ADD r4,r4,r3
STR r4,[r0,#8]
MOV r0,r12
BNE |L0.28|
POP {r4}
BX lr
Optimized using auto-vectorize and knowledge of array size
用自动向量化和已知数组大小来优化
在此示例中,编译器选项--vectorize
使编译器使用NEON指令VLD1
,VADD
和VST1
。
void add_int (int *pa, int * pb, unsigned int n, int x)
{
unsigned int i;
for(i = 0; i < (n&~3); i++)
pa[i] = pb[i] + x;
}
附带下面的选项来编译:
armcc --cpu=Cortex-A8 -O3 -Otime --vectorize
汇编结果:
add_int
PUSH r4,r5}
SUB r4,r0,r1
ASR r12,r4,#2
CMP r12,#0
BLE |L0.32|
BIC r12,r2,#3
CMP r12,r4,ASR #2
BHI |L0.76|
|L0.32|
BICS r12,r2,#3
BEQ |L0.68|
VDUP.32 q1,r3
LSR r2,r2,#2
|L0.48|
VLD1.32 {d0,d1},[r1]!
SUBS r2,r2,#1
VADD.I32 q0,q0,q1
VST1.32 {do,d1},[r0]!
BNE |L0.48|
|L0.68|
POP {r4,r5}
BX lr
|L0.76|
BIC r2,r2,#3
CMP r2,#0
BEQ |L0.68|
MOV r2,#0
BLS |L0.68|
|L0.96|
LDR r4,[r11,r2,LSL #2]
ADD r5,r0,r2,LSL #2
ADD r4,r4,r3
STR r4,[r0,r2,LSL #2]
ADD r4,r1,r2,LSL #2
ADD r2,r2,#2
LDR r4,[r4,#4]
CMP r12,r2
ADD r4,r4,r3
STR r4,[r5,#4]
BHI |L0.96|
POP {r4,r5}
BX lr
Optimized using the restrict keyword
使用限制关键字进行了优化
本例中,数组指针pa
和pb
使用了关键字restrict
。
void add_int (int* restrict pa, int* restrict pb, unsigned int n, int x)
{
unsigned int i;
for(i = 0; i < (n&~3); i++)
pa[i] = pb[i] + x;
}
附带下面的选项来编译:
armcc --cpu=Cortex-A8 -O3 -Otime --vectorize
汇编结果:
add_int PROC
BICS r12,r2,#3
BEQ |L0.36|
VDUP.32 q1,r3
LSR r2,r3,#2
|L0.16|
VLD1.32 {d0, d1}, [r1]!
SUBS r2,r2,#1
VADD.I32 q0,q0,q1
VST1.32 {d0,d1},[r0]!
BNE |L0.16|
|L0.36|
BX lr
4. NEON assembler and ABI restrictions
NEON汇编程序和ABI限制
对于极高的性能,手写NEON汇编程序是经验丰富的程序员的最佳方法。GNU汇编器(gas)和ARM编译器工具链汇编器(armasm)均支持NEON指令的汇编。编写汇编器函数时,您必须注意ARM EABI,它决定了如何使用寄存器。ARM嵌入式应用程序二进制接口(Embedded Application Binary Interface EABI) 指定哪些寄存器用于传递参数,返回结果、或必须保留。 除了ARM内核寄存器外,它还指定了32个D寄存器的使用,下表对此进行了总结。
D0 - D7 | 参数寄存器和返回寄存器。 如果子例程没有参数或返回值,则这些寄存器中的值可能未初始化。 |
D8 - D15 | callee-saved寄存器(callee:被调用者) |
D16 - D31 | caller-saved寄存器(caller:调用者) |
D0
-D7
D16
-D31
D8
-D15
(如果他们被保存了)
子例程(subroutines)必须在使用寄存器S16
-S31
(也是D8
-D15
,Q4
-Q7
)之前保存(preserve)他们。
寄存器S0
-S15
(D0
-D7
,Q0
-Q3
)不需要被保存(preserved),他们可以在标准程序调用的变量中,用于传递参数或者返回结果。
寄存器D16
-D31
(Q8
-Q15
)不需要被保存(preserved),
程序调用标准(Procedure Call Standard)指定了两种方法,可以传递浮点参数:
- 对于软件浮点,他们通过ARM寄存器
R0
-R3
来传递,如果有要求,可以在栈上。 - 另外一种,处理器中存在浮点硬件,是在NEON寄存器中传递参数。
4.1 Passing arguments in NEON and floating-point registers
在NEON和浮点寄存器中传递参数
这种硬件浮点变量表现为以下方式:
整数参数的处理方式与softfp中的处理方式完全相同。因此,如果我们考虑下面的函数f,我们会看到32位值的a将传递给R0
中的函数,并且由于值b必须在一对奇数/偶数寄存器中传递,因此它将进入R2
/ R3
,而R1
没有被使用。
void f(uint32_t a, uint64_t b)
r0: a
r1: unused
r2: b[31:0]
r3: b[63:32]
FP参数填充D0-D7(或S0-S15),与任何整数参数无关。 这意味着整数参数可以流到堆栈上,并且FP参数仍将放入NEON寄存器中(如果有足够的可用空间)。
FP参数能够回填(back-fill),因此我们看在整形参数中,很少去获取未使用的插槽。(FP arguments are able to back-fill, so it’s less common to get the unused slots that we see in integer arguments. )考虑下面的示例:
void f(float a, double b)
d0:
s0: a
s1: unused
d1: b
此处,b
在被分配到d1
的时候自动对齐(d1
与VFP s2 / s3占用相同的物理寄存器)。
void f(fl
void f(float a, double b, float c)
d0:
s0: a
s1: c
d1: b
在此示例中,编译器能够将c
放入s1
中,因此不需要将其放入s4
中。
实际上,这是通过对参数s
,d
和q
使用单独的计数器来实现(并描述)的,并且计数器始终指向该大小的下一个可用插槽(slot)。在上面的第二个FP示例中,首先分配a
,因为它在列表中排在第一位,并且进入第一个可用的S寄存器,即s0
。接下来,将b
分配到第一个可用的D寄存器,该寄存器为d1
,因为a
使用的是d0
的一部分。分配c
时,第一个可用的S寄存器为s1
。随后的双或单参数将分别输入d2
或s4
。
还有一种情况是把参数填到NEON寄存器:当参数必须要溢出到栈的时候,就不会发生回填,并且为其他参数分配堆栈槽的方式,与为整数参数分配方式完全相同。
void f(double a, double b, double c, double d, double e, double f, float g, double h, double i, float j)
d0: a
d1: b
d2: c
d3: d
d4: e
d5: f
d6:
s12: g
s13: unused
d7: h
*sp: i
*sp+8: j
*sp+12: unused (4 bytes of padding for 8-byte sp alignment)
参数a
-f
按照预期分配给d0
-d5
。单精度的g
分配到s12
,h
到d7
去了。下一个参数i
无法填到寄存器里,所以存到栈上。(它将会和栈上的整数参数交错到一起,要是有的话)。然而s13
仍然没有被使用,j
必须去到栈上,因为当FP参数到达堆栈时,我们无法回填到寄存器。
D和Q寄存器也可用于保存矢量数据。这种情况不会发生在典型的C代码中。
可变参数过程(即没有固定数量的参数的过程)不使用NEON寄存器。 相反,它们在softfp中被处理,因为它们在ARM内核寄存器(或堆栈)中传递。 请注意,单精度可变参数会转换为双精度,如softfp一样。
5. NEON libraries
NEON库
有一些免费的开源软件,让NEON得以使用,例如:
- Ne10库函数,这些函数的C接口,提供汇编程序和NEON实现。 参http://projectne10.github.com/Ne10/
- OpenMAX,一组用于处理音频,视频和静止图像的API。 它是Khronos组创建的标准的一部分。 有为了NEON的OpenMAX DL层的免费ARM实现。 参见http://www.khronos.org/openmax/
- ffmpeg,是LGPL许可下的许多不同音频和视频标准的编解码器集合,见http://ffmpeg.org/
- Eigen3,是线性代数、矩阵数学C++的模板库,位于eigen.tuxfamily.org/
- Pixman,一个2D图形库(Cairo图形的一部分),位于http://pixman.org/
- x264,一个免版权的GPL H.264视频编码器,位于http://www.videolan.org/developers/x264.html
- Math-neon,位于http://code.google.com/p/math-neon/
6. Intrinsics
NEON C / C++内部函数(intrinsics)在armcc,GCC / g++和llvm中可用。 它们使用相同的语法,因此可以使用任何这些编译器,来编译使用内部函数(intrinsics)的源代码。 它们在第4章中有更详细的描述。
NEON内部函数(intrinsics)提供了一种编写NEON代码的方法,该方法比汇编程序代码更易于维护,同时仍然可以控制生成的NEON指令。
内部函数(intrinsics)使用新的数据类型,与NEON寄存器D和Q相对应。 数据类型允许创建C变量,这些变量直接映射到NEON寄存器。
NEON内部函数(intrinsics)的编写类似于函数调用,该函数使用这些变量作为参数或返回值。 但是,编译器将内部函数直接转换为NEON指令,而不执行子例程调用。
NEON内部函数(intrinsics)提供对NEON指令的低级访问。编译器帮你做了那些很难的工作,这些工作通常要用写汇编语言来做,例如:
- 寄存器分配
- 代码调度或重新排序指令,以获得最佳性能。 C编译器知道目标处理器是什么,它们可以对代码重新排序以确保最少的停顿数。
内在函数(intrinsics)的主要缺点是无法使编译器准确输出所需的代码,因此在转至NEON汇编程序代码时仍有改进的可能性。
7. Detecting presence of a NEON unit
检测NEON单元的存在
由于在处理器的实现中可以省去NEON单元,因此可能有必要测试NEON单元是否存在。
7.1 Build-time NEON unit detection
编译时检测NEON单元
这是检测NEON单元是否存在的最简单方法。在ARM编译器工具链(armcc)v4.0和更高版本或GCC中,当向编译器提供了一组合适的处理器和FPU选项时,将会定义预定义的宏__ARM_NEON__
。 armasm等效的预定义宏是TARGET_FEATURE_NEON
。
可以将其用于C源文件,该源文件针对具有NEON单元的系统和不具有NEON单元的系统优化了代码。
7.2 Run-time NEON unit detection
运行时检测NEON单元
要在运行时检测NEON单元,需要操作系统的帮助。这是因为ARM体系结构故意不向用户模式的应用程序开放处理器功能。在Linux下,/proc/cpuinfo
里用人话描述了这些信息。
在Tegra2(带有FPU的双核Cortex-A9处理器),cat /proc/cpuinfo
显示:
…
Features : swp half thumb fastmult vfp edsp thumbee vfpv3 vfpv3d16
…
带有NEON单元的ARM四核Cortex-A9处理器显示不同的结果:
…
Features : swp half thumb fastmult vfp edsp thumbee neon vfpv3
…
由于/proc/cpuinfo
输出是基于文本的,因此通常最好查看辅助向量/proc/self/auxv
。 它包含二进制格式的内核hwcap
。 可以轻松地在/proc/self/auxv
文件中搜索AT_HWCAP
记录,以检查HWCAP_NEON
位(4096)。
一些Linux发行版(例如Ubuntu 09.10或更高版本)透明地利用NEON单元。修改了ld.so
链接程序脚本,以通过glibc
读取hwcap
。还为了启用NEON的共享库,添加其他搜索路径。对于Ubuntu,新的搜索路径/lib/neon/vfp
包含来自/lib
的经过NEON优化的库版本。
8. Writing code to imply SIMD
8.1 Writing loops to imply SIMD
当数据结构化地存储时,优秀地做法是写个寻缘同时使用结构的所有内容。这样可以更好地利用缓存。对于工作寄存器很少的机器,可能会把处理流程分到三个单独的循环:
for (...) { outbuffer[i].r = ....; }
for (...) { outbuffer[i].g = ....; }
for (...) { outbuffer[i].b = ....; }
通过简单的重写,合并为一个循环,可以在具有高速缓存的处理器上获得更好的结果。这还允许向量化编译器访问结构的每个部分并向量化循环:
for (...)
{
outbuffer[i].r = ....;
outbuffer[i].g = ....;
outbuffer[i].b = ....;
}
8.2 Tell the compiler where to unroll inner loops
告诉编译器在哪里展开内部循环
对于armcc,可以使用以下语句:
#pragma unroll (n)
在for语句之前,要求编译器将循环展开一定次数。您可以使用它来指示编译器可以展开内部循环,这可以使编译器在更复杂的算法中,向量化外部循环。
其他编译器可能有不同的选项来展开内部循环。
8.3 Write structures to imply SIMD
“加载”操作使用结构体的所有数据项,编译器仅对“加载”操作进行向量化。在某些情况下,会填充结构以保持对齐。下面程序显示了一个像素结构,把它扩展1个Byte,以将每个像素对齐到1个Word(这里一个word是32位)。由于有未使用的字节,编译器不会向量化它的“加载”操作。NEON的“加载”指令可以加载未对齐的结构。因此,在这种情况下,最好删除这个填充的字节,使编译器可以向量化“加载”操作。
struct aligned_pixel
{
char r;
char g;
char b;
char not_used; /* Padding used to keep r aligned to a 32-bit word */
}screen[10];
NEON的结构加载指令要求结构体的所有元素长度相同,因此下面例15的代码不会被向量化
struct pixel
{
char r;
short g; /* Green channel contains more information */
char b;
}screen[10];
要是g
必须要更高的精度,请考虑把其它变量的长度也扩展,好让这个结构体能被向量化地加载。
9. GCC command line options
GCC命令行选项
GCC的用于ARM处理器的命令行选项最初是好多年前设计的,那时候的架构比现在的要简单。随着体系结构的发展,为了更好滴生成代码,命令行选项也改了。我们已经努力尝试确保现有选项集的含义不会被改变。 现在需要一套复杂的选项才能让编译器为Cortex-A系列处理器生成最佳的代码。主要的选项有-mcpu
,-mfpu
和-mfloat-abi
。
9.1 Option to specify the CPU
用于指定CPU的选项
编译文件的时候,必须要让编译器知道你的代码要在什么处理器上运行。所以一个主要的选项是-mcpu=[cpu-name]
,[cpu-name]
是小写的处理器名字,例如在Cortex-A9上就是-mcpu=cortex-a9
,GCC支持下表的Cortex-A系列处理器:
CPU | 选项 |
---|---|
Cortex-A5 | -mcpu=cortex-a5 |
Cortex-A7 | -mcpu=cortex-a7 |
Cortex-A8 | -mcpu=cortex-a9 |
Cortex-A9 | -mcpu=cortex-a9 |
Cortex-A15 | -mcpu=cortex-a15 |
… | … |
如果你的GCC版本不能识别上表的核,那它可能太旧了,要考虑升级。如果没有指定要使用的处理器,则GCC将使用其内置默认值。默认值可能会有所不同,具体取决于当时编译器是怎么被build的。生成的代码可能在你的CPU上无法运行,或者跑得很慢。
9.2 OOption to specify the FPU
用于指定FPU的选项
几乎所有的Cortex-A处理器都带有浮点单元,并且大多数还带有NEON单元。但是,处理器的浮点单元(Floating-Point Unit,FPU)决定了具体的指令集是否可用。
GCC需要另外一个单独的选项-mfpu
来指定FPU。它不会尝试通过-mcpu
选项确定FPU。 -mfpu
选项控制哪种浮点和SIMD指令是可用的。
下面的表给出了每个CPU的推荐选项:
处理器 | 仅FP | FP + SIMD |
---|---|---|
Cortex-A5 | -mfpu=vfpv3-fp16 -mfpu=vfpv3-d16-fp16 | -mfpu=neon-fp16 |
Cortex-A7 | -mfpu=vfpv4 -mfpu=vfpv4-d16 | -mfpu=neon-vfpv4 |
Cortex-A8 | -mfpu=vfpv3 | -mfpu=neon |
Cortex-A9 | -mfpu=vfpv3-fp16 -mfpu=vfpv3-d16-fp16 | -mfpu=neon-fp16 |
Cortex-A15 | -mfpu=vfpv4 | -mfpu=neon-vfpv4 |
VFPv3和VFPv4实现提供32个双精度寄存器。但是,当不存在NEON单元时,前16个寄存器(D16
-D31
)变为可选。 这由选项名称中的-d16
表示,这意味着前16个D寄存器不可用。选项名称的fp16
组件指定半精度(16位)浮点的加载、存储、转换指令的存在。这是对VFPv3的扩展,但在所有VFPv4实现中也可用。
9.3 Option to enable use of NEON and floating-point instructions
用于开启NEON和浮点指令集的选项
GCC仅使用浮点和NEON指令,如果明确告知GCC可以安全使用。 控制它的选项是-mfloat-abi
。注意,-mfloat-abi
也可以更改编译器遵循的ABI。
-mfloat-abi
有3中选项:
mfloat-abi=soft
:不使用任何FPU和NEON指令。仅使用核心寄存器集。使用库调用模拟所有浮点运算。-mfloat-abi=softfp
:使用与-mfloat-abi=soft
相同的调用约定,但在适当的手使用浮点和NEON指令。用这个选项编译的应用程序可以与soft float库链接。如果有相关的硬件指令可用,则可以使用此选项来提高代码的性能,并且使代码符合soft-float环境。mfloat-abi=hard
:适当地使用浮点和NEON指令,并更改ABI调用约定,以生成更有效的函数调用。 可以在NEON寄存器中的函数之间传递浮点和向量类型,这大大减少了复制量。 这也意味着在堆栈上传递参数时需要更少的调用。
用于-mfloat-abi
的正确选项取决于您的目标系统。对于以平台为目标的编译器,默认选项通常是正确和最佳的选择。Ubuntu 12.04默认使用-mfloat-abi=hard
。有关ARM体系结构的ABI的更多信息,请参见ARM体系结构的过程调用标准(Procedure Call Standard for the ARM Architecture),http://infocenter.arm.com/help/topic/com.arm.doc.ihi0042e/index.html
9.4 Vectorizing floating-point operations
向量化浮点选项
NEON体系结构包含适用于整数和浮点数据类型的操作。GCC具有强大的自动向量化单元,可以检测何时适合使用向量引擎来优化代码并提高性能。但是,编译器可能不希望对代码进行向量化处理。 造成这种情况的原因多种多样:
Optimization level for the vectorizer
向量化器的优化级别
默认情况下,向量化程序仅在优化级别-O3
启用。 有一些选项可以使向量化程序启用其他级别的优化。有关这些选项,请参见GCC文档。
IEEE compliance
IEEE的符合
NEON单元中的浮点运算使用IEEE单精度格式来保存正常的操作范围值。 为了最小化NEON单元所需的电量并最大化性能,如果输入或结果为非正常值或NaN值(超出正常工作范围),则NEON单元将不完全符合IEEE标准。
GCC的默认配置是生成严格符合IEEE浮点算术规则的代码。因此,即使启用了向量化程序,默认情况下,GCC也不使用NEON指令对浮点代码进行向量化。
GCC提供了许多命令行选项来精确控制所需的IEEE遵从级别。 在大多数情况下,使用-ffast-math
选项放宽规则并启用向量化是安全的。另外,您可以在GCC 4.6或更高版本上使用-Ofast
选项来达到相同的效果。它打开-O3
和许多其他优化来从您的代码中获得最佳性能。 要了解使用-ffast-math
选项的所有效果,请参阅GCC文档。
注意
- NEON单元仅支持对单精度数据进行向量运算。
- 如果您的代码使用的不是单精度格式的浮点数据,则向量化可能不起作用。
- 您还必须确保浮点常量(字面的变量)不会强制编译器以双精度执行计算。在C和C++中,用
1.0F
而不是1.0
,以确保编译器使用单精度格式。
9.5 Example GCC command line usage for NEON code optimization
用于NEON代码优化的GCC命令行用法
确定目标环境后,可以对目标使用GCC命令行选项。下面的例子适用于具有NEON单元的Cortex-A15处理器,其中操作系统支持在NEON寄存器中传递参数。如果有一个数组具有浮点数据类型,然有浮点代码可以操作这些数据,则可以在GCC命令行上指定硬浮点处理:
arm-gcc -O3 -mcpu=cortex-a15 -mfpu=neon-vfpv4 -mfloat-abi=hard -ffast-math -o myprog.exe myprog.c
下面的例17用于带有NEON单元的Cortex-A9处理器,它支持在NEON寄存器上传递参数。如果有浮点代码,那你可以用GCC命令行指定hard
浮点处理:
arm-gcc -O3 -mcpu=cortex-a9 -mfpu=neon-vfpv3-fp16 -mfloat-abi=hard -ffast-math -o
myprog.exe myprog.c
例18适用于不带NEON单元的Cortex-A7处理器,其中操作系统仅支持在ARM的核心寄存器中传递参数,但可以支持使用浮点硬件进行处理。如果有浮点代码,则可以在GCC命令行上指定softfp
浮点处理:
arm-gcc -O3 -mcpu=cortex-a7 -mfpu=vfpv4-d16 -mfloat-abi=softfp -ffast-math -o
myprog2.exe myprog2.c
例19是针对Cortex-A8处理器(运行于无法使用NEON寄存器的环境中)的。这可能是因为代码位于中断处理程序的中间,并且浮点上下文为USER状态保留。在这种情况下,您可以在GCC命令行上指定soft浮点处理:
arm-gcc -O3 -mcpu=cortex-a8 -mfloat-abi=soft -c -o myfile.o myfile.c
9.6 GCC information dump
倒出GCC信息
如果要了解有关GCC正在执行的操作的更多信息,请使用-fdump-tree-vect
和-ftree-vectorizer-verbose=level
选项,其中level
是1到9之间的数字。较低的值输出的信息较少。这些选项控制生成的信息量。尽管生成的大多数信息仅对编译器开发人员有意义,但您可能会在输出中找到提示,这些提示解释了为什么编译器没有按预期对代码进行向量化。