ARM NEON编程初探——一个简单的BGR888转YUV444实例详解

http://galoisplusplus.coding.me/blog/2017/06/10/use-arm-neon-to-accelerate-bgr888-to-yuv444/

最近在学习ARM的SIMD指令集NEON,发现这方面的资料真是太少了,我便来给NEON凑凑人气,姑且以这篇入门文章来分享一些心得吧。

学习一门新技术,总是有一些经典是绕不开的,对于NEON来说,这份必备的武林秘籍自然就是ARM官方的《NEON Programmer’s Guide》(以下简称Guide)啦。别看这份Guide有四百多页,其实只有一百来页是正文,后面都是供查阅的手册,通读一番还是不难的。所以这里我也就不打算把Guide里的内容翻译过来敷衍了事了。在此我想借一个简单例子,展示我是如何把一个没采用NEON的普通程序改写为NEON程序、中间又是如何debug、如何调优的。当然,作为一枚ARM小白,我接触NEON指令集毕竟也才两周左右时间,错误在所难免,还请各位方家多多指正。

BGR888ToYUV444

在众多并行操作模式(Map, Reduce, Scatter, Stencil等)中,最简单的也许要算Map了,所以我选了一个Map的例子——BGR888转YUV444。这两者都是颜色空间的格式:前者表示一个像素有B、G、R三个颜色通道,每个通道占8-bit,在内存中按照B G R的顺序从高位到低位排列;后者表示一个像素有Y、U、V三个通道,每个通道也是8-bit(444仅指Y、U、V的采样率比值为4:4:4,其他类型的采样率还有YUV422、YUV420),我们也假设它在内存中按照V U Y的顺序从高位到低位排列。如何把BGR888格式转成YUV444呢?根据wiki上的转换公式,我们可以写出如下代码(很显然,这是一一对应,典型的Map模式):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void BGR888ToYUV444(unsigned char *yuv, unsigned char *bgr, int pixel_num)
{
    int i;
    for (i = 0; i < pixel_num; ++i) {
        uint8_t r = bgr[i * 3];
        uint8_t g = bgr[i * 3 + 1];
        uint8_t b = bgr[i * 3 + 2];

        uint8_t y = 0.299 * r + 0.587 * g + 0.114 * b;
        uint8_t u = -0.169 * r - 0.331 * g + 0.5 * b + 128;
        uint8_t v = 0.5 * r - 0.419 * g - 0.081 * b + 128;

        yuv[i * 3] = y;
        yuv[i * 3 + 1] = u;
        yuv[i * 3 + 2] = v;
    }
}

这段代码的意思很简单,我们遍历所有像素,每次把B、G、R三个通道的值从内存中加载进来,再做浮点数乘法和加减法,得到Y、U、V的值,写入相应的内存中。那么,使用NEON可以怎样帮助这段程序跑得更快呢?

前面提到,NEON是一种SIMD(Single Instruction Multiple Data)指令,也就是说,NEON可以把若干源(source)操作数(operand)打包放到一个源寄存器中,对他们执行相同的操作,产生若干目的(dest)操作数,这种方式也叫向量化(vectorization)。这样的话,能打包多少数据同时做运算就取决于寄存器位宽,在ARMv7的NEON unit中,register file总大小是1024-bit,可以划分为16个128-bit的Q-register(Quadword register)或者32个64-bit的D-register(Dualword register),也就是说,最长的寄存器位宽是128-bit(详见Guide第一章)。以上面的R888ToYUV444函数为例,假设我们采用32-bit单精度浮点数float来做浮点运算,那么我们可以 把最多128/32=4个浮点数打包放到Q-register中做SIMD运算,一次拿4个BGR算出4个YUV,从而提高吞吐量,减少loop次数。

(细心的看官可能会问到双精度浮点数double的运算吧?遗憾的是,根据Guide,NEON并不支持double,你可以考虑使用VFP/Vector Floating Point,但VFP并非SIMD单元)。

从浮点运算到整型运算

那么,我们还可以继续提高向量化程度吗?如果我们回头看wiki,我们会发现在早期不支持浮点操作的SIMD处理器中,使用了如下整型运算来把BGR转成YUV:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Refer to: https://en.wikipedia.org/wiki/YUV (Full swing for BT.601)
void BGR888ToYUV444(unsigned char *yuv, unsigned char *bgr, int pixel_num)
{
    int i;
    for (i = 0; i < pixel_num; ++i) {
        uint8_t r = bgr[i * 3];
        uint8_t g = bgr[i * 3 + 1];
        uint8_t b = bgr[i * 3 + 2];

        // 1. Multiply transform matrix (Y′: unsigned, U/V: signed)
        uint16_t y_tmp = 76 * r + 150 * g + 29 * b;
        int16_t u_tmp = -43 * r - 84 * g + 127 * b;
        int16_t v_tmp = 127 * r - 106 * g - 21 * b;

        // 2. Scale down (">>8") to 8-bit values with rounding ("+128") (Y′: unsigned, U/V: signed)
        y_tmp = (y_tmp + 128) >> 8;
        u_tmp = (u_tmp + 128) >> 8;
        v_tmp = (v_tmp + 128) >> 8;

        // 3. Add an offset to the values to eliminate any negative values (all results are 8-bit unsigned)
        yuv[i * 3] = (uint8_t) y_tmp;
        yuv[i * 3 + 1] = (uint8_t) (u_tmp + 128);
        yuv[i * 3 + 2] = (uint8_t) (v_tmp + 128);
    }
}

从这段代码我们不难发现,32-bit的float运算被16-bit的加减、乘法和移位运算所代替。这样的话,我们可以把最多128/16=8个整型数放到Q-register中做SIMD运算,一次拿8个BGR算出8个YUV,把向量化程度再提一倍。使用整型运算还有一个好处:一般而言,整型运算指令所需要的时钟周期少于浮点运算的时钟周期。所以,我以这段代码为基准(baseline),用NEON来加速它。(细心的看官也许已经看到我说法中的纰漏:虽然单个整型指令的周期小于单个相同运算的浮点指令的周期,但整型版本的BGR888ToYUV444比起浮点版本的多了移位和加法的overhead,指令数目是不同的,总的时钟周期不一定就短。Good point! 看官不妨看完本文后也用NEON改写浮点版本练练手,两相比较就不难得出最终的结论啦XD)

NEON Intrinsics

接下来可以着手写NEON代码了。个人推荐新手先别急着一上来就写汇编,NEON提供了intrinsics,其实就是一套可在C/C++中调用的函数API,编译器会代劳把这些intrinsics转成NEON指令(详见Guide的第四章),这样就方便一些。我用NEON intrinsics改写的BGR888ToYUV444函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
void BGR888ToYUV444(unsigned char * __restrict__ yuv, unsigned char * __restrict__ bgr, int pixel_num)
{
    const uint8x8_t u8_zero = vdup_n_u8(0);
    const uint16x8_t u16_rounding = vdupq_n_u16(128);
    const int16x8_t s16_rounding = vdupq_n_s16(128);
    const int8x16_t s8_rounding = vdupq_n_s8(128);

    int count = pixel_num / 16;

    int i;
    for (i = 0; i < count; ++i) {
        // Load bgr
        uint8x16x3_t pixel_bgr = vld3q_u8(bgr);

        uint8x8_t high_r = vget_high_u8(pixel_bgr.val[0]);
        uint8x8_t low_r = vget_low_u8(pixel_bgr.val[0]);
        uint8x8_t high_g = vget_high_u8(pixel_bgr.val[1]);
        uint8x8_t low_g = vget_low_u8(pixel_bgr.val[1]);
        uint8x8_t high_b = vget_high_u8(pixel_bgr.val[2]);
        uint8x8_t low_b = vget_low_u8(pixel_bgr.val[2]);
        int16x8_t signed_high_r = vreinterpretq_s16_u16(vaddl_u8(high_r, u8_zero));
        int16x8_t signed_low_r = vreinterpretq_s16_u16(vaddl_u8(low_r, u8_zero));
        int16x8_t signed_high_g = vreinterpretq_s16_u16(vaddl_u8(high_g, u8_zero));
        int16x8_t signed_low_g = vreinterpretq_s16_u16(vaddl_u8(low_g, u8_zero));
        int16x8_t signed_high_b = vreinterpretq_s16_u16(vaddl_u8(high_b, u8_zero));
        int16x8_t signed_low_b = vreinterpretq_s16_u16(vaddl_u8(low_b, u8_zero));

        // NOTE:
        // declaration may not appear after executable statement in block
        uint16x8_t high_y;
        uint16x8_t low_y;
        uint8x8_t scalar = vdup_n_u8(76);
        int16x8_t high_u;
        int16x8_t low_u;
        int16x8_t signed_scalar = vdupq_n_s16(-43);
        int16x8_t high_v;
        int16x8_t low_v;
        uint8x16x3_t pixel_yuv;
        int8x16_t u;
        int8x16_t v;

        // 1. Multiply transform matrix (Y′: unsigned, U/V: signed)
        high_y = vmull_u8(high_r, scalar);
        low_y = vmull_u8(low_r, scalar);

        high_u = vmulq_s16(signed_high_r, signed_scalar);
        low_u = vmulq_s16(signed_low_r, signed_scalar);

        signed_scalar = vdupq_n_s16(127);
        high_v = vmulq_s16(signed_high_r, signed_scalar);
        low_v = vmulq_s16(signed_low_r, signed_scalar);

        scalar = vdup_n_u8(150);
        high_y = vmlal_u8(high_y, high_g, scalar);
        low_y = vmlal_u8(low_y, low_g, scalar);

        signed_scalar = vdupq_n_s16(-84);
        high_u = vmlaq_s16(high_u, signed_high_g, signed_scalar);
        low_u = vmlaq_s16(low_u, signed_low_g, signed_scalar);

        signed_scalar = vdupq_n_s16(-106);
        high_v = vmlaq_s16(high_v, signed_high_g, signed_scalar);
        low_v = vmlaq_s16(low_v, signed_low_g, signed_scalar);

        scalar = vdup_n_u8(29);
        high_y = vmlal_u8(high_y, high_b, scalar);
        low_y = vmlal_u8(low_y, low_b, scalar);

        signed_scalar = vdupq_n_s16(127);
        high_u = vmlaq_s16(high_u, signed_high_b, signed_scalar);
        low_u = vmlaq_s16(low_u, signed_low_b, signed_scalar);

        signed_scalar = vdupq_n_s16(-21);
        high_v = vmlaq_s16(high_v, signed_high_b, signed_scalar);
        low_v = vmlaq_s16(low_v, signed_low_b, signed_scalar);

        // 2. Scale down (">>8") to 8-bit values with rounding ("+128") (Y′: unsigned, U/V: signed)
        // 3. Add an offset to the values to eliminate any negative values (all results are 8-bit unsigned)

        high_y = vaddq_u16(high_y, u16_rounding);
        low_y = vaddq_u16(low_y, u16_rounding);

        high_u = vaddq_s16(high_u, s16_rounding);
        low_u = vaddq_s16(low_u, s16_rounding);

        high_v = vaddq_s16(high_v, s16_rounding);
        low_v = vaddq_s16(low_v, s16_rounding);

        pixel_yuv.val[0] = vcombine_u8(vqshrn_n_u16(low_y, 8), vqshrn_n_u16(high_y, 8));

        u = vcombine_s8(vqshrn_n_s16(low_u, 8), vqshrn_n_s16(high_u, 8));

        v = vcombine_s8(vqshrn_n_s16(low_v, 8), vqshrn_n_s16(high_v, 8));

        u = vaddq_s8(u, s8_rounding);
        pixel_yuv.val[1] = vreinterpretq_u8_s8(u);

        v = vaddq_s8(v, s8_rounding);
        pixel_yuv.val[2] = vreinterpretq_u8_s8(v);

        // Store
        vst3q_u8(yuv, pixel_yuv);

        bgr += 3 * 16;
        yuv += 3 * 16;
    }

    // Handle leftovers
    for (i = count * 16; i < pixel_num; ++i) {
        uint8_t r = bgr[i * 3];
        uint8_t g = bgr[i * 3 + 1];
        uint8_t b = bgr[i * 3 + 2];

        uint16_t y_tmp = 76 * r + 150 * g + 29 * b;
        int16_t u_tmp = -43 * r - 84 * g + 127 * b;
        int16_t v_tmp = 127 * r - 106 * g - 21 * b;

        y_tmp = (y_tmp + 128) >> 8;
        u_tmp = (u_tmp + 128) >> 8;
        v_tmp = (v_tmp + 128) >> 8;

        yuv[i * 3] = (uint8_t) y_tmp;
        yuv[i * 3 + 1] = (uint8_t) (u_tmp + 128);
        yuv[i * 3 + 2] = (uint8_t) (v_tmp + 128);
    }
}

这个函数中大部分都是很常用的NEON intrinsics,看官不妨结合查阅Guide的附录D自行熟悉,这里我仅针对几个点解释一下:

  • 我用vld3q_u8指令从内存一次加载16个像素(也就是uint8_t类型的B、G、R三个通道的数值),将各个通道的16个数值放到一个Q-register中(也就是用了三个Q-register,每个分别存放16个B、16个G和16个R),vst3q_u8的写入操作也是类似的,这充分利用128-bit的带宽,使内存存取(memory access)的次数尽可能少。但是,后边运算的向量化程度其实仍然不变,只能同时进行8个16-bit整型运算,也就是说,对于运算的部分,理想加速比(speedup)是8而非16。(当然,一次从内存加载多少数据是一个design choice,没有绝对的答案,看官也可以尝试别的方式XD)

  • 对于像素个数不能被16整除的情况,可能会有剩下的1到15个像素没被上述NEON代码处理到,这里我把它们称作leftovers,简单粗暴地跑了之前的for循环。其实leftover的情况如果占比高的话,还有更高明的处理方式,请各位看官参见Guide的6.2 Handling non-multiple array lengths一节。

  • 我把Y通道计算的一部分放到一起,稍作解释,其他的很多运算都是类似的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 复制8个值为128的uint16_t类型整数到某个Q-register,该Q-register对应的C变量是u16_rounding
const uint16x8_t u16_rounding = vdupq_n_u16(128);
// 复制8个值为128的uint8_t类型整数到某个D-register,该D-register对应的C变量是scalar
uint8x8_t scalar = vdup_n_u8(76);
// pixel_bgr.val[0]为一个Q-register,16个uint8_t类型的数,对应R通道
// pixel_bgr.val[1]为一个Q-register,16个uint8_t类型的数,对应G通道
// pixel_bgr.val[2]为一个Q-register,16个uint8_t类型的数,对应B通道
uint8x16x3_t pixel_bgr = vld3q_u8(bgr);
// 一个Q-register对应有两个D-register
// 这是拿到对应R通道的Q-register高位的D-register,有8个R值
// 参见Guide中Register overlap一节(这部分内容很重要!)
// 其他如low_r、high_g的情况相似,这里从略
uint8x8_t high_r = vget_high_u8(pixel_bgr.val[0]);
// 对8个R值执行乘76操作
// vmull是变长指令(常以单词末尾额外的L作为标记),源操作数是两个uint8x8_t的向量,目的操作数是uint16x8_t的向量
// 到这一步:Y = R * 76
// low_y的情况类似,从略
high_y = vmull_u8(high_r, scalar);
// 把scalar的8个128改为8个150
scalar = vdup_n_u8(150);
// 执行乘加运算
// 到这一步:Y = R * 76 + G * 150
high_y = vmlal_u8(high_y, high_g, scalar);
// 把scalar的8个150改为8个29
scalar = vdup_n_u8(29);
// 执行乘加运算
// 到这一步:Y = R * 76 + G * 150 + B * 29
high_y = vmlal_u8(high_y, high_b, scalar);
// 到这一步:Y = (R * 76 + G * 150 + B * 29) + 128
high_y = vaddq_u16(high_y, u16_rounding);
// vqshrn_n_u16是变窄指令(常以单词末尾额外的N作为标记,N为Narrow),将uint16x8_t的向量压缩为uint8x8_t
// 到这一步:Y = ((R * 76 + G * 150 + B * 29) + 128) >> 8
// vcombine_u8用于将两个D-register的值组装到一个Q-register中
pixel_yuv.val[0] = vcombine_u8(vqshrn_n_u16(low_y, 8), vqshrn_n_u16(high_y, 8));
  • 看官也许已经注意到了,在前面的BGR888ToYUV444函数中,我并非像上面的代码一样,将一个通道的运算放到一块,而是有意将Y、U、V三个通道的运算打散搅在一起。这是为了尽可能减少data dependency所引起的stall,这也是优化NEON代码需要着重考虑的方向之一。

  • 对NEON稍有了解的细心看官可能会问:乘法和加法所需要的128、76等常数为何不用立即数——例如NEON的vmul_n——而是采用先批量复制常数到向量再做向量运算?这是因为我们很多运算的源操作数是int8x8_tuint8x8_t的向量,vmul_n等API很不幸不支持这种格式。这里也良心提醒看官,写NEON intrinsics或者汇编一定要对照Guide后面的附录列出的格式,否则编译器常常会报一些风马牛不相及的错误,把人往坑里带——我当初可是各种踩坑各种在不相关的地方纠结啊555…

交叉编译及debug

说到编译和debug,这里再良心提醒几点——如果看官早就知道了,那就权当我这好久没碰过嵌入式开发的小白在碎碎念好了:

  • 一定要注意编译器EABI的版本!一定要注意编译器EABI的版本!一定要注意编译器EABI的版本!EABI指的是Embedded-Application Binary Interface,我在这个白痴问题上浪费了不少时间,一定要把重要事情说三遍…

    • 一个需要注意的地方是gnueabi和androideabi的不一样。我最开始遇到的一个问题就是:把一个编译出来的可执行文件拷到开发板上,设了可执行权限,结果在运行时却出现「No such file or directory」,略诡异啊。用strace追踪发现是exec系统调用报的错,exec会根据可执行文件(在Linux系下是ELF)的.interp段运行相应的loader,由该loader做动态链接,再到可执行文件的入口地址开始执行。其实这时候用readelf -l <program> | grep interpreter查看ELF的loader地址,再看看设备上有没有该文件,基本可以确定EABI有没有用对了,但逗逼如我强行搞了个软链接[捂脸]…后来才发现是在Makefile里用gnueabi系的编译器编译Android程序,但其实编译Android程序还是直接用ndk才是王道啊!

    • 另一个需要注意的是float-abi。gcc中有一个选项-mfloat-abi,可选soft/softfp/hardfpsoft和后两者的区别是编译器不会生成浮点指令,浮点操作完全由软件实现;相比之下,hardfp走了另一个极端,浮点操作完全由硬件实现,效率最高;softfp则是走中间路线,它有着和hardfp完全不一样的calling convention——hardfp通过VFP来传浮点参数,而softfp还是通过整型寄存器来传参,和soft是一致的。因此,softfp编译的程序和hardfp编译的程序是互不兼容的,如果你把它们链接到一块,会出现「uses VFP register arguments, output does not」的报错。

  • 需要使用正确的编译参数才能支持NEON。如果前面提到的-mfloat-abi指定了soft,那么就不支持NEON了。另外还常常需要-mfpu=neon来指定FPU(Floating-Point Unit)是NEON Unit。具体的编译参数详见Guide第二章。

  • 可以使用gdb远程调试ARM设备上的程序:

1.在设备上启动gdbserver(假设在6666端口吧,哈哈我比较喜欢6666):

1
2
3
# <program>是程序名
# <arg>是程序参数
$ gdbserver localhost:6666 <program> <arg>

2.在连接设备的主机(host)上启动对应EABI的gdb

1
2
3
4
$ adb forward tcp:6666 tcp:6666
# <program>是程序名
$ gdb <program>
(gdb) target remote :6666

3.和gdbserver连接上后,就可以在主机端的gdb执行各种debug操作了。对于NEON程序,我们常常想查看NEON Unit的register file,可以采用下面的命令:

1
(gdb) info all-registers

NEON汇编

虽然有了NEON intrinsics程序,但编译器很笨很笨的生成的汇编代码常常不尽如人意。要想发挥NEON指令集的最大威力,我们还需练就优化汇编这一上乘武功。好在有了intrinsics代码,我们可以“偷看”编译器或objdump-S生成的“答案”,无需自己从头写汇编代码。当然,一开始看-O3优化过的汇编代码还是有点懵逼的,我个人比较偏好先看-O0(竟然和intrinsics的相差无几!)和-O1版的,以此为基准,再看看-O2-O3,猜猜做了哪些优化,加以借鉴。本着这一思路,我用汇编又改写了一版BGR888ToYUV444,其中大部分汇编指令是由之前的intrinsics代码演化过来的,所以我这里也在注释标上了对应的intrinsics代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
void BGR888ToYUV444(unsigned char * __restrict__ yuv, unsigned char * __restrict__ bgr, int pixel_num)
{
    int count = pixel_num / 16;

    asm volatile(
            // const uint16x8_t u16_rounding = vdupq_n_u16(128);
            // const int16x8_t s16_rounding = vdupq_n_s16(128);
            "VMOV.I16 q3,#0x80\t\n"
            // const int8x16_t s8_rounding = vdupq_n_s8(128);
            "VMOV.I8  q4,#0x80\t\n"

            // i = 0
            "MOV      r2,#0\t\n"

            "LOOP: \t\n"

            // uint8x16x3_t pixel_bgr = vld3q_u8(bgr);
            "ADD      r12,r1,#0x18\t\n"
            "VLD3.8   {d0,d2,d4},[r1]\t\n"
            "VLD3.8   {d1,d3,d5},[r12]\t\n"

            // // bgr += 3 * 16;
            // "ADD      r1,r1,#0x30\t\n"

            // // i++
            // "ADD      r2,r2,#1\t\n"

            // int16x8_t signed_high_r = vreinterpretq_s16_u16(vmovl_u8(high_r));
            // int16x8_t signed_low_r = vreinterpretq_s16_u16(vmovl_u8(low_r));
            // int16x8_t signed_high_g = vreinterpretq_s16_u16(vmovl_u8(high_g));
            // int16x8_t signed_low_g = vreinterpretq_s16_u16(vmovl_u8(low_g));
            // int16x8_t signed_high_b = vreinterpretq_s16_u16(vmovl_u8(high_b));
            // int16x8_t signed_low_b = vreinterpretq_s16_u16(vmovl_u8(low_b));
            "VMOVL.U8 q5,d0\t\n"
            "VMOVL.U8 q6,d1\t\n"
            "VMOVL.U8 q7,d2\t\n"
            "VMOVL.U8 q8,d3\t\n"
            "VMOVL.U8 q9,d4\t\n"
            "VMOVL.U8 q10,d5\t\n"

            // uint8x8_t scalar = vdup_n_u8(76);
            // high_y = vmull_u8(high_r, scalar);
            // low_y = vmull_u8(low_r, scalar);
            "VMOV.I8  d26,#0x4c\t\n"
            "VMULL.U8 q11,d0,d26\t\n"
            "VMULL.U8 q12,d1,d26\t\n"

            // scalar = vdup_n_u8(150);
            // high_y = vmlal_u8(high_y, high_g, scalar);
            // low_y = vmlal_u8(low_y, low_g, scalar);
            "VMOV.I8  d26,#0x96\t\n"
            "VMLAL.U8 q11,d2,d26\t\n"
            "VMLAL.U8 q12,d3,d26\t\n"

            // scalar = vdup_n_u8(29);
            // high_y = vmlal_u8(high_y, high_b, scalar);
            // low_y = vmlal_u8(low_y, low_b, scalar);
            "VMOV.I8  d26,#0x1d\t\n"
            "VMLAL.U8 q11,d4,d26\t\n"
            "VMLAL.U8 q12,d5,d26\t\n"

            // int16x8_t signed_scalar = vdupq_n_s16(-43);
            // high_u = vmulq_s16(signed_high_r, signed_scalar);
            // low_u = vmulq_s16(signed_low_r, signed_scalar);
            "VMVN.I16 q1,#0x2a\t\n"
            "VMUL.I16 q13,q5,q1\t\n"
            "VMUL.I16 q14,q6,q1\t\n"

            // signed_scalar = vdupq_n_s16(127);
            // high_v = vmulq_s16(signed_high_r, signed_scalar);
            // low_v = vmulq_s16(signed_low_r, signed_scalar);
            "VMOV.I16 q2,#0x7f\t\n"
            "VMUL.I16 q15,q5,q2\t\n"
            "VMUL.I16 q0,q6,q2\t\n"

            // signed_scalar = vdupq_n_s16(-84);
            // high_u = vmlaq_s16(high_u, signed_high_g, signed_scalar);
            // low_u = vmlaq_s16(low_u, signed_low_g, signed_scalar);
            "VMVN.I16 q1,#0x53\t\n"
            "VMLA.I16 q13,q7,q1\t\n"
            "VMLA.I16 q14,q8,q1\t\n"

            // signed_scalar = vdupq_n_s16(-106);
            // high_v = vmlaq_s16(high_v, signed_high_g, signed_scalar);
            // low_v = vmlaq_s16(low_v, signed_low_g, signed_scalar);
            "VMVN.I16 q2,#0x69\t\n"
            "VMLA.I16 q15,q7,q2\t\n"
            "VMLA.I16 q0,q8,q2\t\n"

            // signed_scalar = vdupq_n_s16(127);
            // high_u = vmlaq_s16(high_u, signed_high_b, signed_scalar);
            // low_u = vmlaq_s16(low_u, signed_low_b, signed_scalar);
            "VMOV.I16 q1,#0x7f\t\n"
            "VMLA.I16 q13,q9,q1\t\n"
            "VMLA.I16 q14,q10,q1\t\n"

            // signed_scalar = vdupq_n_s16(-21);
            // high_v = vmlaq_s16(high_v, signed_high_b, signed_scalar);
            // low_v = vmlaq_s16(low_v, signed_low_b, signed_scalar);
            "VMVN.I16 q2,#0x14\t\n"
            "VMLA.I16 q15,q9,q2\t\n"
            "VMLA.I16 q0,q10,q2\t\n"

            // high_y = vaddq_u16(high_y, u16_rounding);
            // low_y = vaddq_u16(low_y, u16_rounding);
            "VADD.I16 q11,q11,q3\t\n"
            "VADD.I16 q12,q12,q3\t\n"

            // high_u = vaddq_s16(high_u, s16_rounding);
            // low_u = vaddq_s16(low_u, s16_rounding);
            "VADD.I16 q13,q13,q3\t\n"
            "VADD.I16 q14,q14,q3\t\n"

            // high_v = vaddq_s16(high_v, s16_rounding);
            // low_v = vaddq_s16(low_v, s16_rounding);
            "VADD.I16 q15,q15,q3\t\n"
            "VADD.I16 q0,q0,q3\t\n"

            // pixel_yuv.val[0] = vcombine_u8(vqshrn_n_u16(low_y, 8), vqshrn_n_u16(high_y, 8));
            "VQSHRN.U16 d10,q11,#8\t\n"
            "VQSHRN.U16 d11,q12,#8\t\n"

            // u = vcombine_s8(vqshrn_n_s16(low_u, 8), vqshrn_n_s16(high_u, 8));
            "VQSHRN.S16 d12,q13,#8\t\n"
            "VQSHRN.S16 d13,q14,#8\t\n"

            // v = vcombine_s8(vqshrn_n_s16(low_v, 8), vqshrn_n_s16(high_v, 8));
            "VQSHRN.S16 d14,q15,#8\t\n"
            "VQSHRN.S16 d15,q0,#8\t\n"

            // u = vaddq_s8(u, s8_rounding);
            // v = vaddq_s8(v, s8_rounding);
            "VADD.I8  q6,q6,q4\t\n"
            "VADD.I8  q7,q7,q4\t\n"

            // vst3q_u8(yuv, pixel_yuv);
            "ADD      r12,r0,#0x18\t\n"
            "VST3.8   {d10,d12,d14},[r0]\t\n"
            "VST3.8   {d11,d13,d15},[r12]\t\n"
            // bgr += 3 * 16;
            // yuv += 3 * 16;
            "ADD      r1,r1,#0x30\t\n"
            "ADD      r0,r0,#0x30\t\n"
            // i++
            "ADD      r2,r2,#1\t\n"
            // i < count
            "CMP      r2,r3\t\n"
            "BLT      LOOP\t\n"
            : [r0] "+r" (yuv), [r1] "+r" (bgr), [r3] "+r" (count)
            :
            : "r2", "memory", "d0", "d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9", "d10", "d11", "d12", "d13", "d14", "d15", "d16", "d17", "d18", "d19", "d20", "d21", "d22", "d23", "d24", "d25", "d26", "d27", "d28", "d29", "d30", "d31"
            );


    // Handle leftovers
    int i;
    for (i = count * 16; i < pixel_num; ++i) {
        uint8_t r = bgr[i * 3];
        uint8_t g = bgr[i * 3 + 1];
        uint8_t b = bgr[i * 3 + 2];

        uint16_t y_tmp = 76 * r + 150 * g + 29 * b;
        int16_t u_tmp = -43 * r - 84 * g + 127 * b;
        int16_t v_tmp = 127 * r - 106 * g - 21 * b;

        y_tmp = (y_tmp + 128) >> 8;
        u_tmp = (u_tmp + 128) >> 8;
        v_tmp = (v_tmp + 128) >> 8;

        yuv[i * 3] = (uint8_t) y_tmp;
        yuv[i * 3 + 1] = (uint8_t) (u_tmp + 128);
        yuv[i * 3 + 2] = (uint8_t) (v_tmp + 128);
    }
}

GCC内联汇编

这里我用到了GCC内联汇编,其基本结构如下:

1
2
3
4
asm [volatile] ( AssemblerTemplate
                 : OutputOperands
                 [ : InputOperands
                 [ : Clobbers ] ])

其中,关键字volatile表示禁止编译器对这段汇编代码进行优化以避免编译器优化——你想啊,要是信任编译器优化效果的话,我们也不必手撸汇编了,不是么XD AssemblerTemplate自然是汇编代码部分了。OutputOperandsInputOperands是指定输出和输入的操作数,前面的代码在OutputOperands一栏中指定了输出和输入(指针yuv可读写,存放于r0寄存器;指针bgr可读写,存放于r1寄存器;变量count可读写,存放于r3寄存器):

1
2
        : [r0] "+r" (yuv), [r1] "+r" (bgr), [r3] "+r" (count)
        :

Clobbers一栏用来告诉编译器这段内联汇编代码会改变的操作数。如果操作数是在寄存器,编译器会做保护,在执行内联汇编代码前save它的值,在执行内联汇编代码后restore;如果操作数是在内存、编译器在执行内联汇编代码前曾把内存的值cache在寄存器的话,编译器会把寄存器的值flush回内存,再执行内联汇编代码,下次直接从内存读取,从而保证操作数数值的正确性。在上一段代码中,我们会修改的操作数是循环中的自增变量i所在的寄存器r2、内存和32个D寄存器(这32个D寄存器我才懒得一个个敲呢,当然是录了个vim宏啦XD):

1
        : "r2", "memory", "d0", "d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9", "d10", "d11", "d12", "d13", "d14", "d15", "d16", "d17", "d18", "d19", "d20", "d21", "d22", "d23", "d24", "d25", "d26", "d27", "d28", "d29", "d30", "d31"

更多关于GCC内联汇编的内容可参考:

从intrinsics到汇编

把intrinsics代码改成汇编的一个关键之处,是如何把C/C++里的变量名高效对应到寄存器。我在用gcc生成汇编代码时就发现,编译器对我这段intrinsics代码使用的实际Q-register超过16个,所以需要不断save/restore寄存器的值来重复使用寄存器。其实我们可以通过巧妙的排布寄存器来减少不必要的save/restore,这也是我写NEON汇编最开始想优化的一个点。在上面的BGR888ToYUV444程序中,我用Q0、Q1、Q2分别保存R、G、B的三个uint8x16_t向量,用Q3保存值为128的int16x8_t向量常量,用Q4保存值为128的int8x16_t向量常量。首先计算Y通道,把中间计算结果的两个uint16x8_t向量分别存到Q11和Q12。这个时候要计算U和V通道了,需要把数值从uint8_t类型转成int16_t,所以我把R通道的D0拷到Q5、D1拷到Q6(还记得Q0寄存器就是由D0和D1组成的吧?),把G通道的D2拷到Q7、D3拷到Q8,把R通道的D4拷到Q9、D5拷到Q10,之后便不再需要用Q0、Q1、Q2来保存R、G、B了。我们再把U通道中间计算结果的两个int16x8_t向量分别存到Q13和Q14,把V通道中间计算结果的两个int16x8_t向量分别存到Q15和Q0(还记得Q0可以被覆写吗?),这样就完美把16个Q寄存器/32个D寄存器都用了个遍,不需要额外save/restore。

如何debug汇编代码

写程序总是绕不开debug问题?那么如何debug NEON汇编呢?可以在汇编代码中使用断点指令BRK,这样在gdb中就能在断点处停下来,执行相关debug操作了。要让程序继续运行只需增加PC跳过BRK即可。

另外,有一个图形化的网页调试器值得安利一下,这对于新手来说是很用户友好很实用的工具。

继续优化

个人认为,在写intrinsics程序的阶段就要力求用尽可能少的intrinsics来实现功能,这样能保证生成的汇编指令尽可能精简。在指令足够精简之后,一项最重要的工作就是instruction reordering/scheduling,减少诸如RAW、WAW等memory dependency引起的stall。

这个时候你需要对每条指令重要阶段的时钟周期了然于心。如何查到相关资料呢?你可以到ARM 信息中心,查你所用的ARM处理器的Technical Reference Manual,其中就有一章Instruction Cycle Timing。另外,WebShaker为Cortex A8做了一个网页版Cycle Counter for Cortex A8,如果你在使用这一处理器,这是一个很值得使用和参考的工具。

最后,对于实际处理器的NEON汇编调优,考虑到ARM指令和NEON指令走的是不同流水线、dual issue(详见Guide第五章)等等因素,如何达到最优scheduling仍是很有挑战性的问题。个人觉得,像我这种小白,还是需要有intruction-level profiler来定位性能热点进行优化,可惜目前为止尚未找到趁手的工具。另外,以前我在玩CUDA时,有不少不错的tutorial(CUDA玩家想必都会看过的要算Mark Harris的parallel reduction了)给出了step-by-step的优化思路、指标和方法,对新手快速上手CUDA、并迅速应用到新问题上很有帮助。但ARM在这方面确实很欠缺,即使是Guide里的三章Examples,也多半是告诉了最终答案而没有思路和方法。如果有ARM大神知道如何做step-by-step性能分析和优化,还望多多赐教。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值