本文主要内容是介绍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
函数的反汇编代码来讲解,为了方便理解简化了代码。
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和汇编代码感觉就像是看天书一样,但是现在至少阅读代码是没什么障碍了。
感觉很多技能就是一个工多手熟的过程,只要坚持去实践和思考,经过一段时间一般都能掌握个大概,但是能做到什么程度就要看很多方面的因素了。
这里的工多手熟是指,得经过不断地实践、查资料和思考,反复迭代的过程,直到某一天看到半年前的觉得很难的东西,看着也不过如此,那就说明你变强了。
而且我觉得写博客输出确实是一个非常好的学习方法,为了写这篇博客去写例子和查资料,也是一个对知识查缺补漏的过程。
参考资料
- [1] https://zhuanlan.zhihu.com/p/61356656
- [2] https://zhuanlan.zhihu.com/p/64025085
- [3]https://static.docs.arm.com/den0018/a/DEN0018A\_neon\_programmers\_guide\_en.pdf
- [4]https://static.docs.arm.com/den0024/a/DEN0024A\_v8\_architecture\_PG.pdf?\_ga=2.27603513.441280573.1589987126-874985481.1557147808
- [5] https://community.arm.com/developer/tools-software/oss-platforms/b/android-blog/posts/arm-neon-programming-quick-reference
- [6] https://gcc.gnu.org/onlinedocs/gcc/Using-Assembly-Language-with-C.html#Using-Assembly-Language-with-C
- [7]https://static.docs.arm.com/ddi0487/fb/DDI0487F\_b\_armv8\_arm.pdf?\_ga=2.262043400.441280573.1589987126-874985481.1557147808
- [8]http://infocenter.arm.com/help/topic/com.arm.doc.dui0965c/DUI0965C\_scalable\_vector\_extension\_guide.pdf
- [9] https://blog.csdn.net/chshplp\_liaoping/article/details/12752749
- [10] https://blog.csdn.net/aliqing777/article/details/50847440
- [11] https://azeria-labs.com/arm-conditional-execution-and-branching-part-6/
- [12] https://www.sciencedirect.com/topics/engineering/conditional-branch
推荐文章
- 移动端arm cpu优化学习笔记第4弹--内联汇编入门(上)
- 移动端arm cpu优化学习笔记第3弹--绑定cpu(cpu affinity)
- 移动端arm cpu优化学习笔记第2弹--常量阶时间复杂度中值滤波
- 移动端arm cpu优化学习笔记第1弹--一步步优化盒子滤波(Box Filter)
更多AI移动端优化的请关注专栏嵌入式AI以及知乎(@梁德澎)。