梁德澎 · 2020年05月26日

移动端arm cpu优化学习笔记第4弹--内联汇编入门(下)

本文主要内容是介绍ARMv7和v8内联汇编的一些基础知识,并且会结合两个具体例子去看下如何用内联汇编来改写原来的代码。
作者:梁德澎
首发知乎:https://zhuanlan.zhihu.com/p/143328317

前文回顾:移动端arm cpu优化学习笔记第4弹--内联汇编入门(上)

例子二、rgb图转灰度图(rgb2gray)

先来看下C++ 浮点版本的实现:

void rgb2grayFloat(const unsigned char *rgbImage,
                   const int            height,
                   const int            width,
                   unsigned char       *grayImage) {
    int size = height * width;
    const unsigned char *src = rgbImage;

    for (int i = 0; i < size; ++i ) {
        float gray = 0.3 * src[0] + 0.59 * src[1] + 0.11 * src[2];
        gray = std::max(std::min(gray, 255.0f), 0.0f);
        grayImage[i] = static_cast<unsigned char>(gray);
        src += 3;
    }
}

定点版本:

void rgb2gray(const unsigned char *rgbImage,
              const int            height,
              const int            width,
              unsigned char       *grayImage) {
    int size = height * width;
    const unsigned char *src = rgbImage;
    
    uint16_t rW = 77;
    uint16_t gW = 151;
    uint16_t bW = 28;

    for (int i = 0; i < size; ++i ) {
        uint16_t gray = (static_cast<uint16_t>(src[0]) * rW) + 
                        (static_cast<uint16_t>(src[1]) * gW) + 
                        (static_cast<uint16_t>(src[2]) * bW);

        // undo the scale by 256 and write to memory
        gray = gray >> 8;
        gray = std::max(std::min(gray, (uint16_t)255), (uint16_t)0);
        grayImage[i] = static_cast<unsigned char>(gray);
        src += 3;
    }
}

代码很好理解,定点版本就是把系数乘以,转成定点系数,然后最后结果再右移8位即可,看下速度对比,就是算法上从浮点改定点,就可以看到有很可观的提速了。

测试图片大小 (1000, 1777),测试机型华为P30 (Kirin 980),都是绑定大核。

借鉴反汇编代码

有了前车之鉴,我们在改写内联汇编之前先看下反汇编的代码,借鉴编译器的做法。

这里选择armv7的定点版本rgb2gray函数的反汇编代码来讲解,为了方便理解简化了代码。

完整版见:https://github.com/Ldpe2G/ArmNeonOptimization/blob/master/armAssembly/datas/disassemble\_files/assemblyEx2Rgb2Gray\_armeabi-v7a.txt

00000000 <_Z8rgb2grayPKhiiPh>:
    // void rgb2gray(const unsigned char *rgbImage,
    //               const int            height,
    //               const int            width,
    //               unsigned char       *grayImage) {
    // 函数前四个参数,会按顺序被放入寄存器 r0-r3
    // 剩下会采用压栈的方式保存
   0: b5b0       push {r4, r5, r7, lr}
   2: af02       add r7, sp, #8
   // 下面代码对应 int size = height * width;
   // 所以 r1 表示 size 这个参数
   4: 4351       muls r1, r2
   // 地址6-8判断 size 是否小于1
   // 如果是,则把之前压栈保存的寄存器出栈
   // 函数返回
   6: 2901       cmp r1, #1
   8: bfb8       it lt
   a: bdb0       poplt {r4, r5, r7, pc}
   // 地址 c-e是判断 r1是否小于15
   // 如果是则跳转到地址80
   c: 290f       cmp r1, #15
   e: d937       bls.n 80 <_Z8rgb2grayPKhiiPh+0x80>
   // 地址 10-18,首先让lr保存size的低4位内容
   // 然后让 size 减去 lr ,结果放到 ip 寄存器
   // 从这里可以猜出来优化的思路
   // 就类似 ip = r1 >> 4
   //       lr = r1 - (ip << 4)
   // 循环体生成16个灰度值元素
   // 所以如果 size 等于15也跳到80地址
   // 尾部数据处理部分
  10: f001 0e0f  and.w lr, r1, #15
  14: ebb1 0c0e  subs.w ip, r1, lr
  18: d032       beq.n 80 <_Z8rgb2grayPKhiiPh+0x80>
  ......
  // 地址 2a-76 就是循环体了
  // 可以看到 rgb 各自系数拷贝8份分别放到 
  // d16-d18三个64bit寄存器中 
  // 因为本身没超8bit所以用了64bit来存
  2a: eb0c 024c  add.w r2, ip, ip, lsl #1
  2e: ffc1 0e17  vmov.i8 d16, #151 ; 0x97
  32: efc1 1e1c  vmov.i8 d17, #28 ; 0x1c
  36: 4402       add r2, r0
  38: efc4 2e1d  vmov.i8 d18, #77 ; 0x4d
  // r4用在循环体中的终止判断初始化为ip
  3c: 4664       mov r4, ip
  // 把输出地址 r3(grayImage)赋值给r5
  3e: 461d       mov r5, r3
  // 地址40和46,连续加载 rgb 16个8bit数据
  // 放到 d20-d25 寄存器中
  // 这里值得注意的是 vld3 这个指令
  // 这个指令在加载数据的时候会对数据作解交织,
  // 可以理解就是专门为了rgb这种交织数据类型设计的,
  // 前8个r的值会放到d20,g会放到d22,b会放到d24
  // 后8个r的值会放到d21,g会放到d23,b会放到d25
  40: f960 450d  vld3.8 {d20,d22,d24}, [r0]!
  44: 3c10       subs r4, #16
  46: f960 550d  vld3.8 {d21,d23,d25}, [r0]!
  // 后面代码就很好理解了
  // rgb各自乘以权值然后累加
  // 注意的是因为两个uint8相乘
  // 结果需要用16bit来存
  // 所以可以看到52-66的向量指令后面都加了 "l"
  // 表示的是长指令的意思,也就是结果的位宽
  // 比输入操作数位宽要长
  4a: ffc7 cca0  vmull.u8 q14, d23, d16
  4e: ef69 31b9  vorr d19, d25, d25
  52: ffc6 eca0  vmull.u8 q15, d22, d16
  56: ff83 0ca1  vmull.u8 q0, d19, d17
  5a: ef68 31b8  vorr d19, d24, d24
  5e: ffc5 c8a2  vmlal.u8 q14, d21, d18
  62: ff83 2ca1  vmull.u8 q1, d19, d17
  66: ffc4 e8a2  vmlal.u8 q15, d20, d18
  // 下面的指令除了做结果的累加,还做了以下的操作
  // gray = gray >> 8;
  // gray = std::max(std::min(gray, (uint16_t)255), (uint16_t)0);
  // 因为结果的位宽比输入操作数短,所以指令末尾加了"n"
  // 表示的是窄指令
  // h表示只保留结果高一半bit的内容,16bit的高一半
  // 相当于是右移8位的结果
  6a: efcc 5480  vaddhn.i16 d21, q14, q0
  6e: efce 4482  vaddhn.i16 d20, q15, q1
  72: f945 4a0d  vst1.8 {d20-d21}, [r5]!
  76: d1e3       bne.n 40 <_Z8rgb2grayPKhiiPh+0x40>
  ......
  bc: bdb0       pop {r4, r5, r7, pc}

所以编译器优化的思路就是,

循环体内处理16个灰度值的计算,通过高效加载数据的指令"vld3",还有前半8个灰度的计算指令和后半8个灰度计算的指令交叉,减少相邻指令间的数据依赖达到更好的加速效果。

armv8的汇编代码思路也类似,具体可以看github上的反汇编文件:

https://github.com/Ldpe2G/ArmNeonOptimization/tree/master/armAssembly/datas/disassemble\_files

这里就不展开分析了。

armv7 和 v8汇编

看懂了反汇编代码的并行思路之后,我们自己来改写一版:

void rgb2grayAssembly(const unsigned char *rgbImage,
                      const int            height,
                      const int            width,
                      unsigned char       *grayImage) {
    int size = height * width;
    const unsigned char *src = rgbImage;
    unsigned char *dst = grayImage;

    int neonLen = size >> 4;
    int remain  = size - (neonLen << 4); 

#ifdef __aarch64__  // armv8
    __asm__ volatile(
        // [rW, rW, rW, rW, rW, rW, rW, rW]
        "movi   v0.8b, #77                          \n"
        // [gW, gW, gW, gW, gW, gW, gW, gW]
        "movi   v1.8b, #151                         \n"
        // [bW, bW, bW, bW, bW, bW, bW, bW]
        "movi   v2.8b, #28                          \n"
        
        "0:                                         \n"

        "prfm  pldl1keep, [%[src], #512]            \n"

        // load [rgb,rgb,rgb,rgb,rgb,rgb,rgb,rgb,rgb,rgb,rgb,rgb,rgb,rgb,rgb,rgb] 
        // into 
        // [r,r,r,r,r,r,r,r,r,r,r,r,r,r,r,r] 
        // [g,g,g,g,g,g,g,g,g,g,g,g,g,g,g,g]
        // [b,b,b,b,b,b,b,b,b,b,b,b,b,b,b,b]
        "ld3 {v3.16b-v5.16b}, [%[src]], #48      \n"

        // reverse lower and higher half 64bit of v register 
        "ext v6.16b, v3.16b, v3.16b, #8          \n"
        "ext v7.16b, v4.16b, v4.16b, #8          \n"
        "ext v8.16b, v5.16b, v5.16b, #8          \n"

        // [r,r,r,r,r,r,r,r] * [rW,rW,rW,rW,rW,rW,rW,rW]
        // bitwidth of results(uint16_t) are 
        // wider than elements in inputs(uint8_t)
        "umull v9.8h,  v3.8b, v0.8b                \n"
        "umull v10.8h, v6.8b, v0.8b                \n"

        // [g,g,g,g,g,g,g,g] * [gW,gW,gW,gW,gW,gW,gW,gW]
        "umull v11.8h, v4.8b, v1.8b                \n"

        // r*rW + [b,b,b,b,b,b,b,b] * [bW,bW,bW,bW,bW,bW,bW,bW]
        "umlal v9.8h,  v5.8b, v2.8b                \n"

        // [g,g,g,g,g,g,g,g] * [gW,gW,gW,gW,gW,gW,gW,gW]
        "umull v12.8h, v7.8b, v1.8b                \n"

        // r*rW + [b,b,b,b,b,b,b,b] * [bW,bW,bW,bW,bW,bW,bW,bW]
        "umlal v10.8h, v8.8b, v2.8b                \n"

        // writes the most significant half of the 
        // add result to the lower half of 
        // the v13 register and clears its upper half
        "addhn v13.8b,  v9.8h,  v11.8h             \n"

        // writes the most significant half of the 
        // add result to the upper half of 
        // the v13 register without affecting the other bits
        "addhn2 v13.16b, v10.8h, v12.8h             \n"

        "subs    %[neonLen], %[neonLen], #1         \n"

        "st1    {v13.16b}, [%[dst]], #16            \n"

        "bgt        0b                              \n"

        :[src]        "=r"(src),
         [dst]        "=r"(dst),
         [neonLen]    "=r"(neonLen)
        :[src]        "0"(src),
         [dst]        "1"(dst),
         [neonLen]    "2"(neonLen)
        :"cc", "memory", "v0", "v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8", "v9", "v10", "v11", "v12", "v13"
    );
#else   // armv7
    __asm__ volatile(
        // [rW, rW, rW, rW, rW, rW, rW, rW]
        "vmov.u8   d0, #77                  \n"
        // [gW, gW, gW, gW, gW, gW, gW, gW]
        "vmov.u8   d1, #151                 \n"
        // [bW, bW, bW, bW, bW, bW, bW, bW]
        "vmov.u8   d2, #28                  \n"

        "0:                                 \n"

        "pld        [%[src], #512]          \n"

        // load [rgb,rgb,rgb,rgb,rgb,rgb,rgb,rgb] into 
        // [r,r,r,r,r,r,r,r], [g,g,g,g,g,g,g,g], [b,b,b,b,b,b,b,b]
        "vld3.u8   {d3,d4,d5}, [%[src]]!    \n"
        // load next [rgb,rgb,rgb,rgb,rgb,rgb,rgb,rgb] into 
        // [r,r,r,r,r,r,r,r], [g,g,g,g,g,g,g,g], [b,b,b,b,b,b,b,b]
        "vld3.u8   {d6,d7,d8}, [%[src]]!    \n"

        // [r,r,r,r,r,r,r,r] * [rW,rW,rW,rW,rW,rW,rW,rW]
        // bitwidth of results(uint16_t) are 
        // wider than elements in inputs(uint8_t)
        "vmull.u8 q5, d3, d0              \n"
        "vmull.u8 q6, d6, d0              \n"

        // [g,g,g,g,g,g,g,g] * [gW,gW,gW,gW,gW,gW,gW,gW]
        // bitwidth of results(uint16_t) are 
        // wider than elements in inputs(uint8_t)
        "vmull.u8 q7, d4, d1              \n"
        "vmull.u8 q8, d7, d1              \n"

        // r*rW + [b,b,b,b,b,b,b,b] * [bW,bW,bW,bW,bW,bW,bW,bW]
        "vmlal.u8 q5, d5, d2              \n"
        "vmlal.u8 q6, d8, d2              \n"

        // (r*rW + g*gW + b*bW) >> 8
        "vaddhn.u16 d18, q5, q7             \n"
        "vaddhn.u16 d19, q6, q8             \n"

        "subs       %[neonLen], #1          \n"

        "vst1.u8   {d18-d19}, [%[dst]]!     \n"

        "bgt        0b                      \n"

        :[src]          "=r"(src),
         [dst]          "=r"(dst),
         [neonLen]      "=r"(neonLen)
        :[src]          "0"(src),
         [dst]          "1"(dst),
         [neonLen]      "2"(neonLen)
        :"cc", "memory", "q0", "q1", "q2", "q3", "q4", "q5", "q6", "q7", "q8", "q9", "q10"
    );
#endif
    ......
}

速度对比与分析

最后来看下改了普通C++和内联汇编速度的对比:

可以看到通过偷师编译器,至少可以做到不比编译器差的程度。

测试图片大小 (1000, 1777),测试机型华为P30 (Kirin 980),都是绑定大核。

总结

本文通过两个实际例子,介绍了如何改写arm内嵌汇编的一些相关知识,希望读者看完之后,对于如何改写汇编能有个大概的思路。

其实对于优化,文档代码看的再多也比不上动手去实践。

回想起自己一年多前刚开始做移动端优化的时候,看neon intrinsic和汇编代码感觉就像是看天书一样,但是现在至少阅读代码是没什么障碍了。

感觉很多技能就是一个工多手熟的过程,只要坚持去实践和思考,经过一段时间一般都能掌握个大概,但是能做到什么程度就要看很多方面的因素了。

这里的工多手熟是指,得经过不断地实践、查资料和思考,反复迭代的过程,直到某一天看到半年前的觉得很难的东西,看着也不过如此,那就说明你变强了。

而且我觉得写博客输出确实是一个非常好的学习方法,为了写这篇博客去写例子和查资料,也是一个对知识查缺补漏的过程。

参考资料



推荐文章


更多AI移动端优化的请关注专栏嵌入式AI以及知乎(@梁德澎)。
推荐阅读
关注数
16356
内容数
1226
嵌入式端AI,包括AI算法在推理框架Tengine,MNN,NCNN,PaddlePaddle及相关芯片上的实现。欢迎加入微信交流群,微信号:aijishu20(备注:嵌入式)
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息