计算机 · 2021年12月7日 0

NEON加速

NEON技术

关于ARM处理器NEON技术的介绍可以参考维基百科、百度百科词条、ARM官网,关于NEON技术的使用可以参考网上的各种博客,但是似乎来自于ARM官网的这份NEON Programmer’s Guide(或者从本网站直接下载)最为全面可靠。

NEON技术简介

处理器处理数据的指令大致有三种:

  1. Single Instruction Single Data 一个指令的源操作数和目的操作数都是唯一的。这里的唯一是指源操作数和目的操作数都是标量(scalar)/单个的数据。
    比如add r0, r5这种指令所做的就仅仅是把r0,r5这两个寄存器里的东西加起来。
  2. Single Instruction Multiple Data(vector mode) 一个指令的源操作数和目的操作数是向量(或者说数组),该指令会依次/顺序为数组的每个元素执行相应的操作。
    比如VADD.F32 S24, S8, S16这条指令实际上会依次执行下面四个操作:S24 = S8 + S16 S25 = S9 + S17 S26 = S10 + S18 S27 = S11 + S20
  3. Single Instruction Multiple Data(packed data) 一个指令的源操作数和目的操作数是向量,该指令会同时为数组的每个元素执行相应操作。由于这个同时,因此这种指令就比上面第二种顺序执行的向量模式的SIMD更有效率。至于所谓的packed data的含义是这种指令的源操作数和目的操作数必须放在特殊的寄存器(所谓的D寄存器和Q寄存器)里才能进行这种运算。ARM称这种技术为Advanced SIMD technology或者NEON technology

在C/C++中使用NEON技术

首先确定能否使用NEON技术

  • ARMv7开始的处理器才有NEON技术,可以通过/proc/cpuinfo检查一下处理器是否带neon扩展就行;
  • 通过在代码中检查编译链是否具有__ARM_NEON__这个预定义宏确认是否可以使用NEON技术,即代码应该是这样的:#ifdef __ARM_NEON__ //使用了NEON技术的代码 #else //没有使用NEON技术的代码 #endif

在C/C++中使用NEON技术

NEON技术原理

首先叙述一下我所理解的NEON技术,在ARM处理器存在着所谓的D寄存器和Q寄存器。D寄存器长度为64位,可以存2个32位长度的数据类型(如int_32/uint_32)或者4个16位的数据类型或者8个8位的数据类型(总之就是一样的数据类型凑齐64位),这寄存器里的每个数据类型叫做一个lane(或者说对应一个lane吧)。Q寄存器也是类似的,只是Q寄存器长度更长,有128位而已。D寄存器和Q寄存器里的数据就可以用来执行一开始讲的SIMD(packed data)这种高效率指令。比如两个D寄存器D1,D2都包含8个uint_8的数据,在将这两个寄存器执行相加操作时,处理器会把两个处理器对应位置上的uint_8相加,然后存到目的寄存器比如D3里。比如D1的第一个uint_8和D2的第一个uint_8相加,结果存到D3的第一个uint_8里,D1的第二个uint_8和D2的第二个uint_8相加,结果存到D3的第二个uint_8里,以此类推。

所以,只要我们能够把我们要作运算的数据给放入D或者Q寄存器里,执行相应的运算,再把数据从D或者Q寄存器里取出来,就达到了使用NEON技术加速(加速不加速还得看实际效果)运算的目的。

使用NEON Intrinsics

最直接的使用NEON技术的方式当然是使用汇编了,但是没多少人有兴趣和能力去折腾汇编吧,或者人手写出来的汇编不一定真有程序自动生成的好。为了方便C/C++程序员也能使用NEON技术,于是就有了NEON Intrinsics存在的需求。我不知道NEON Intrinsics的中文翻译是什么,大致就是内联函数一样的东西,通过类似函数一样的接口去调用相关的NEON指令。

包含必要的头文件

想要使用NEON Intrinsics需要包含arm_neon.h这个头文件。

将数据放入D或者Q寄存器

只有特殊数据类型的变量才可以放入D或者Q寄存器里进行操作,这些特殊的数据类型是这样子的:<type><size>x<number_of_lanes>_t

比如:

  • int16x4_t表示4个16位的short,这4个16位的short可以一起放在一个D寄存器中
  • float32x4_t表示4个32位的float,这4个float可以一起放在一个Q寄存器中

在初始化上述数据类型的时候,是不能直接初始化的,需要调用相应的NEON Intrinsics函数。
NEON Intrinsics函数是这样子的:<opname><flags>_<type>

opname是运算或者操作类型,flags如果没有的话,就表示一般的、在D寄存器上进行的操作,有的话,一般是q(表示是对Q寄存器进行操作)或者l(结果数据类型比源操作数数据类型长)之类的值。type表示寄存器里单个数据类型(每个lane的数据类型)。

比如:

  • vmul_s16是将两个D寄存器里对应的short相乘
  • vaddl_u8把两个D寄存器里的uint8x8_t相加,结果给放在了Q寄存器的uint16x8_t里面。

回到具体的初始化这些特殊类型变量的问题:

  • 将初始值都设为0uint8x8 a; a = vdup_n_u8(0);
  • 将初始值设为特定的值uintix8 a = vreinterpret_u8_u64(vcreate_u64(0x123456789ABCDEFULL));
  • 设置某个lane的值 使用VSET_LANE:Result_t vset_lane_type(Scalar_t N, Vector_t M, int n); Result_t vsetq_lane_type(scalar_t N, Vector_t M, int n);
  • 根据已有某个数组的值/某块内存初始化 使用VLD1Intrinsic:Result_t vld1_type(Scalar_t *N); Result_t vld1q_type(Scalar_t *N);

进行所需要的运算

比如对于三个int16x4_t类型的变量sumax,要计算sum = a * x + sum,可以这样写:

sum = vmla_s16(sum, a, x);

这完全取决于自己的需要而选择合适的NEON Intrinsic。

D或者Q寄存器中取出运算结果

  • 可以使用VST1Intrinsic把结果存到内存中:void vst1_type(Scalar_t *N, Vector_t M); void vst1q_type(Scalar_t *N, Vector_t M);
  • 也可以用VGET_LANEIntrinsic直接获取每一个lane的值:Result_t vget_lane_type(Vector_t N, int n); Result_t vgetq_lane_type(Vector_t N, int n);

编译器选项设置

  • 在编译器的命令选项中指定要使用NEON技术:
    • -mfpu=neon
    • -mcpu=<一个支持NEON的处理器>
    • -ftree-vectorize
    • -mfloat-abi=softfp -mfloat-abi选项有三个可取的值,soft,softfp,hard,我只用过softfp,关于这三个值的具体含义可以查看manual。
    在实际使用过程中,我没用过-ftree-vectorize这个选项,使用-O3选项就已经默认包含了这个选项。

一个矩阵乘法的例子

最后直接贴上一个NEON加速的矩阵乘法的例子:

void neon_float_matrix_vec_mul(const float *matrix, const float *vec,
                             float *vec_ret, int row, int col) {

    float32x4_t sum, a, x;
    for (int i = 0; i < row; i++) {
        sum = vdupq_n_s32(0);
        for (int j = 0; j < col; j += 4) {
            a = vld1q_f32(matrix + i*col + j);
            x = vld1q_f32(vec + j);
            sum = vmlaq_f32(sum, a, x);
        }
        vec_ret[i] = vgetq_lane_f32(sum, 0)
                     +vgetq_lane_f32(sum, 1)
                     +vgetq_lane_f32(sum, 2)
                     +vgetq_lane_f32(sum, 3);
    }
}

如果需要处理图像或者声音这种多路数据交替存储在一块的东西,可以参考手册中Constructing multiple vectors from interleaved memory这一部分内容。