ARM NEON Intrinsics 使用详解

目录

 

前言

SIMD简介

ARM NEON Intrinsics简介

函数改写示例

结语


 

前言

最近公司在视频直播项目中要使用H.265/HEVC,具体的是使用HW硬件编码H.264/AVC,云端转码成H.265/HEVC并推流的解决方案。方案中使用的解码器是FFMpeg中的H.265解码器,该解码器是从OpenHEVC直接获取的,比起备受好评的H.264/AVC解码器,这个解码器目前优化不足,在手机上占用资源较高。因此一个工作就是优化该解码器在手机上的性能表现,主要使用ARM提供的SIMD指令进行优化。

SIMD简介

Single Instruction Multiple Data (SIMD),单指令多数据。从字面理解,就是在CPU执行中,一条操作指令可以同时操作多个寄存器,从而在物理上倍数的加速运行。我理解范畴内的X86平台上最早的SIMD指令应该是奔腾MMX上自带的MMX指令,其寄存器宽度是64位,可以同时操作8个字节。MultiMedia eXtensions (MMX)是多媒体扩展的意思,其最初的设计目的就是为了加速图像/视频等高并行数据的处理速度。

  • 一个简单的SIMD示意图如下所示:

     

    ARM NEON Intrinsics 使用详解

    SIMD 8x8加法示意图

在这里,一条SIMD加法指令可以同时得到8个加法结果。就计算步骤本身而言,比单独使用8条加法指令能够获得8倍的加速比。从该示例也可以看出,随着寄存器长度的变长,单指令能够处理的数据量也越来越大,从而获得更高的加速性能。在Intel最新的AVX2指令集中,寄存器最大长度已经达到512位。

ARM NEON Intrinsics简介

NEON指令是从Armv7架构开始引入的SIMD指令,其共有16个128位寄存器。发展到最新的Arm64架构,其寄存器数量增加到32个,但是其长度仍然为最大128位,因此操作上并没有发生显著的变化。对于这样的寄存器,因为可以同时存储并处理多组数据,称之为向量寄存器。Intrinsics是使用C语言的方式对NEON寄存器进行操作,因为相比于传统的使用纯汇编语言,具有可读性强,开发速度快等优势。如果需要在代码中调用NEON Intrinsics函数,需要加入头文件"arm_neon.h"。

数据类型

NEON Intrinsics内置的整数数据类型主要包括以下几种:

  • (u)int8x8_t;
  • (u)int8x16_t;
  • (u)int16x4_t;
  • (u)int16x8_t;
  • (u)int32x2_t;
  • (u)int32x4_t;
  • (u)int64x1_t;

其中,第一个数字代表的是数据类型宽度为8/16/32/64位,第二个数字代表的是一个寄存器中该类型数据的数量。如int16x8_t代表16位有符号数,寄存器*有8个数据。

常用指令

NEON Intrinsics支持的所有指令可参看ARM NEON Intrinsics,其包含了常用的arm汇编指令类型,如数学运算,逻辑运算等。另外,其引入了有针对性的加载/存储/转置/交叉存取等指令。部分常见的指令在会下面的示例环节中予以说明。需要注意的是,指令中的助记符与arm汇编是相同的。

示例1:

  • int16x8_t vqaddq_s16 (int16x8_t, int16x8_t)
  • int16x4_t vqadd_s16 (int16x4_t, int16x4_t)
  1. 第一个字母'v'指明是vector向量指令,也就是NEON指令;
  2. 第二个字母'q'指明是饱和指令,即后续的加法结果会自动饱和;
  3. 第三个字段'add'指明是加法指令;
  4. 第四个字段'q'指明操作寄存器宽度,为'q'时操作QWORD, 为128位;未指明时操作寄存器为DWORD,为64位;
  5. 第五个字段's16'指明操作的基本单元为有符号16位整数,其最大表示范围为-32768 ~ 32767;
  6. 形参和返回值类型约定与C语言一致。

其它可能用到的助记符包括:

  • l 长指令,数据扩展
  • w 宽指令,数据对齐
  • n 窄指令, 数据压缩

示例2

  • uint8x8_t vld1_u8 (const uint8_t *)
  1. 第二个字段'ld'表示加载指令
  2. 第三个字段'1'(注意是1,不是l)表示顺次加载。如果需要处理图像的RGB分量,可能会用到vld3。关于vld/vst指令更详细的说明,请自己参阅arm官方文档。

函数改写示例

1. 简单示例

原始代码

// uint8_t *_dst, uint8_t *_src, int16_t *src2
// int height, int width
for (y = 0; y < height; y++) {
  for (x = 0; x < width; x++) {
    dst[x] = av_clip_pixel(((src[x] << 6) + src2[x] + offset) >> shift);
  }
  src  += srcstride;
  dst  += dststride;
  src2 += MAX_PB_SIZE;
}

改写代码

int16x8_t result_16x8;
int16x8_t offset_16x8 = vmovq_n_s16(offset);
int16x8_t minusshift_16x8 = vmovq_n_s16(-1 * shift);
int16x8_t min_16x8 = vmovq_n_s16(0);
int16x8_t max_16x8 = vmovq_n_s16(255);
        
for (y = 0; y < height; y++) {
  for (x = 0; x < width; x+=8) {
    result_16x8 = vshlq_n_s16(vreinterpretq_s16_u16(vmovl_u8(vld1_u8(&src[x]))), 6);
            result_16x8 = vshlq_s16(vqaddq_s16(vqaddq_s16(result_16x8, vld1q_s16(&src2[x])), offset_16x8), minusshift_16x8);
            vst1_u8(&dst[x], vqmovn_u16(vreinterpretq_u16_s16(vmaxq_s16(vminq_s16(result_16x8, max_16x8), min_16x8))));
  }
  src  += srcstride;
  dst  += dststride;
  src2 += MAX_PB_SIZE;
}

说明:

  • 这里只针对宽度为8的倍数进行了改写,实际代码中需要对传入参数进行判断
  • vld1_u8读取8字节数据,vmovl_u8对读取的uint8x8进行宽度扩展
  • vreinterpretq_s16_u16对数据类型进行强制转换
  • vshlq_n_s16对数据进行左移处理(P.S. NEON提供了右移指令,但是只能使用整数常量。需要根据变量进行右移时,只能使用左移负数位的方法。)
  • vqmovn_u16对处理结果进行宽度压缩
  • vst1_u8将处理后的int16x8_t数据写回内存

2.进阶示例

原始代码

/*
#define QPEL_FILTER(src, stride)   \
(filter[0] * src[x - 3 * stride] + \
 filter[1] * src[x - 2 * stride] + \
 filter[2] * src[x -     stride] + \
 filter[3] * src[x             ] + \
 filter[4] * src[x +     stride] + \
 filter[5] * src[x + 2 * stride] + \
 filter[6] * src[x + 3 * stride] + \
 filter[7] * src[x + 4 * stride])
 
DECLARE_ALIGNED(16, const int8_t, ff_hevc_qpel_filters[3][16]) = {
    { -1,  4,-10, 58, 17, -5,  1,  0, -1,  4,-10, 58, 17, -5,  1,  0},
    { -1,  4,-11, 40, 40,-11,  4, -1, -1,  4,-11, 40, 40,-11,  4, -1},
    {  0,  1, -5, 17, 58,-10,  4, -1,  0,  1, -5, 17, 58,-10,  4, -1}
};
*/
filter = ff_hevc_qpel_filters[mx - 1];
for (y = 0; y < height + QPEL_EXTRA; y++) {
  for (x = 0; x < width; x++)
    tmp[x] = QPEL_FILTER(src, 1);
  src += srcstride;
  tmp += MAX_PB_SIZE;
}

改写代码

/*
DECLARE_ALIGNED(16, const int8_t, ff_hevc_qpel_filtersT[3][64]) = {
    { -1, -1, -1, -1, -1, -1, -1, -1,  4,  4,  4,  4,  4,  4,  4,  4,//(0)
     -10,-10,-10,-10,-10,-10,-10,-10, 58, 58, 58, 58, 58, 58, 58, 58,
      17, 17, 17, 17, 17, 17, 17, 17, -5, -5, -5, -5, -5, -5, -5, -5,
       1,  1,  1,  1,  1,  1,  1,  1,  0,  0,  0,  0,  0,  0,  0,  0},
    { -1, -1, -1, -1, -1, -1, -1, -1,  4,  4,  4,  4,  4,  4,  4,  4,//(1)
     -11,-11,-11,-11,-11,-11,-11,-11, 40, 40, 40, 40, 40, 40, 40, 40,
      40, 40, 40, 40, 40, 40, 40, 40,-11,-11,-11,-11,-11,-11,-11,-11,
       4,  4,  4,  4,  4,  4,  4,  4, -1, -1, -1, -1, -1, -1, -1, -1},
    {  0,  0,  0,  0,  0,  0,  0,  0,  1,  1,  1,  1,  1,  1,  1,  1,//(2)
      -5, -5, -5, -5, -5, -5, -5, -5, 17, 17, 17, 17, 17, 17, 17, 17,
      58, 58, 58, 58, 58, 58, 58, 58,-10,-10,-10,-10,-10,-10,-10,-10,
       4,  4,  4,  4,  4,  4,  4,  4, -1, -1, -1, -1, -1, -1, -1, -1}
};
*/
int16x8_t filteT_16x8_0, filteT_16x8_1, filteT_16x8_2, filteT_16x8_3, filteT_16x8_4, filteT_16x8_5, filteT_16x8_6, filteT_16x8_7;
int16x8_t result_16x8;
      
filter = ff_hevc_qpel_filtersT[mx - 1];

filteT_16x8_0 = vmovl_s8(vld1_s8(&filter[0]));
filteT_16x8_1 = vmovl_s8(vld1_s8(&filter[8]));
filteT_16x8_2 = vmovl_s8(vld1_s8(&filter[16]));
filteT_16x8_3 = vmovl_s8(vld1_s8(&filter[24]));
filteT_16x8_4 = vmovl_s8(vld1_s8(&filter[32]));
filteT_16x8_5 = vmovl_s8(vld1_s8(&filter[40]));
filteT_16x8_6 = vmovl_s8(vld1_s8(&filter[48]));
filteT_16x8_7 = vmovl_s8(vld1_s8(&filter[56]));

for (y = 0; y < height + QPEL_EXTRA; y++) {
  for ( x = 0; x < width; x += 8 ) {
    // init the output reg
    result_16x8 = vmovq_n_s16(0);
    // (0)
    result_16x8 = vmlaq_s16(result_16x8, vreinterpretq_s16_u16(vmovl_u8(vld1_u8(&src[x-3]))), filteT_16x8_0);
    // (1)
    result_16x8 = vmlaq_s16(result_16x8, vreinterpretq_s16_u16(vmovl_u8(vld1_u8(&src[x-2]))), filteT_16x8_1);
    // (2)
    result_16x8 = vmlaq_s16(result_16x8, vreinterpretq_s16_u16(vmovl_u8(vld1_u8(&src[x-1]))), filteT_16x8_2);
    // (3)
    result_16x8 = vmlaq_s16(result_16x8, vreinterpretq_s16_u16(vmovl_u8(vld1_u8(&src[x]))), filteT_16x8_3);
    // (4)
    result_16x8 = vmlaq_s16(result_16x8, vreinterpretq_s16_u16(vmovl_u8(vld1_u8(&src[x+1]))), filteT_16x8_4);
    // (5)
    result_16x8 = vmlaq_s16(result_16x8, vreinterpretq_s16_u16(vmovl_u8(vld1_u8(&src[x+2]))), filteT_16x8_5);
    // (6)
    result_16x8 = vmlaq_s16(result_16x8, vreinterpretq_s16_u16(vmovl_u8(vld1_u8(&src[x+3]))), filteT_16x8_6);
    // (7)
    result_16x8 = vmlaq_s16(result_16x8, vreinterpretq_s16_u16(vmovl_u8(vld1_u8(&src[x+4]))), filteT_16x8_7);
          
    // store the output data
    vst1q_s16(&tmp[x], result_16x8);
  }
  src += srcstride;
  tmp += MAX_PB_SIZE;
}

说明:
在C实现中,每个结果需要读取包括自身在内的8个输入,乘以相应的系数并累加。最简单直观的实现方法是

output_16x8 = vmulq_s16( vreinterpretq_s16_u16(vmovl_u8(vld1_u8(&src[x-3]))), vmovl_s8(vld1_s8(ff_hevc_qpel_filters[mx - 1])));

这样实现,会使得8个乘积分布在同一个向量寄存器中,需要通过取寄存器的不同元素实现累加,加法部分无法并行。
在C实现中,其数学表示为两个1x8和8x1的矩阵之间的乘法。分析数据间的关系,将矩阵乘法转换为矩阵转置乘法,可以得出前文改写代码的实现。在该实现中,由于滤波器系统固定,因此预先定义了其转置矩阵并扩展。在进行'乘加'操作的过程中,一个循环将8个结果全部计算完毕,使得乘法/加法均实现了并行化。
P.S. 这里,单独设置了8个向量寄存器变量并展开使得代码较长,使用循环+数组的方式也可以得到同样的结果,且代码较短。但是在底层高频函数中,尽量展开循环可以最大化的提升效率。

结语

本文只介绍了使用ARM NEON Intrinsics的原理和基本应用。实际中需要对待优化的函数原理及能使用的资源了解清楚才能使用最有效的方法并行化程序。

上一篇:Arm Neon入门教程1


下一篇:【NEON 】初探