一、 Arm架构寄存器体系熟悉
基于arm neon 实现的代码有 intrinsic 和inline assembly 两种实现。
1.1 通用寄存器
arm v7 有 16 个 32-bit 通用寄存器,用 r0-r15 表示。
arm v8 有 31 个 64-bit 通用寄存器,用 x0-x30 表示,和 v7 不一样的是,这 31 个寄存器也可以作为 32-bit 寄存器来用,用 w0-w30 表示,其中 wn 是 xn 的低 32 位,如下图所示:
函数前四个参数,会按顺序被放入寄存器 r0-r3(w0-w3), 剩下会采用压栈的方式保存
寄存器寄存器 | 别名 | 用途 |
r0 | a1 | 第一个函数参数: Scratch 寄存器 |
r1 | a2 | 第二个函数参数: Scratch 寄存器 |
r2 | a3 | 第三个函数参数: Scratch 寄存器 |
r3 | a4 | 第四个函数参数: Scratch 寄存器 |
r4 | v1 | 寄存器变量 |
r5 | v2 | 寄存器变量 |
r6 | v3 | 寄存器变量 |
r7 | v4 | 寄存器变量 |
r8 | v5 | 寄存器变量 |
r9 | v6 rfp | 寄存器变量 实际的帧指针 |
r10 | sl | 栈接线 |
r11 | fp | 参数指针 |
r12 | ip | 临时 |
r13 | sp | 栈指针 |
r14 | lr | 连接寄存器 |
r15 | pc | 程序计数 |
1.2 向量寄存器
armv7 包含 16 个 128-bit 向量寄存器,用 q0-q15 表示,其中每个 q 寄存器又可以拆分成两个 64-bit 向量寄存器来用,用 d0-d31 来表示。
armv8 则有更多的向量寄存器,32 个 128-bit 向量寄存器,用 v0-v31 来表示。
每个 128-bit 向量寄存器可以当做:
包含 2 个 64-bit 元素的向量寄存器来用,表达形式是 vn.2d;
包含 4 个 32-bit 元素的向量寄存器来用,表达形式是 vn.4s;
包含 8 个 16-bit 元素的向量寄存器来用,表达形式是 vn.8h;
包含 16 个 8-bit 元素的向量寄存器来用,表达形式是 vn.16b;
或者每个向量寄存器也可以只用低 64-bit:
1 个 64-bit 元素的向量寄存器来用,表达形式是 vn.1d;
2 个 32-bit 元素的向量寄存器来用,表达形式是 vn.2s;
4 个 16-bit 元素的向量寄存器来用,表达形式是 vn.4h;
8 个 8-bit 元素的向量寄存器来用,表达形式是 vn.8b;
利用指令集加速,无一例外地要利用专用寄存器这种在 CPU 上稀少、宝贵的资源。专用寄存器用少了 CPU 的性能不能充分发挥,用多了则会产生寄存器溢出 (Register Spilling)(https://blog.csdn.net/qq_41112170/article/details/90286091) 这种对性能有明显负面影响的问题。因此,我们至少需要了解在编写 Neon 代码时,有多少个专用寄存器可供利用。
二、内联汇编
2.1 基础写法
__asm__ qualifiers ( // 汇编代码部分
: OutputOperands //在内联汇编代码中被修改的变量列表
: InputOperands //在内联汇编代码中用到的变量列表
: Clobbers //在内联汇编代码中用到的寄存器列表 );
qualifiers
:一般是用 volatile
修饰词 ,关键字__volatile__:也可以写“volatile”,理由同上;__volatile__是可选的,作用是禁止编译器对后面汇编指令再进行优化。一般自己写的汇编,考虑性能,已经做过优化,编译器再优化的话,可能效果反而更差,所以通常还是带上这个关键字;
括号里:是真正的汇编代码,主要有四部分组成,第一部分是具体的汇编代码,是必须的;其他三个为辅助参数,可选;各部分之间用冒号“:”分割,即使参数为空,也要加冒号;
OutputOperands
:在内联汇编中会被修改的变量列表,变量之间用','隔开, 每个变量的格式是:[asmSymbolicName] "constraint"(cvariablename)
cvariablename
:表示变量原来的名字;asmSymbolicName
:表示变量在内联汇编代码中的别名,一般和 cvariablename 一样,在汇编代码中就可以通过%[asmSymbolicName]
去使用该变量;constraint
: 一般填=r
,具体解释见文档[6
]InputOperands
:在内联汇编中用到的所有变量列表,变量之间用','隔开, 每个变量的格式是:[asmSymbolicName] "constraint"(cexpression)
和输出不一样地方是,首先要按OutputOperands
列表的顺序再列一遍,但是constraint
用数字代替从0
开始,然后才是写其他只读变量,只读变量constraint
填r
。Clobbers
: 一般是"cc", "memory"
开头,然后接着填内联汇编中用到的通用寄存器和向量寄存器"cc"
表示内联汇编代码修改了标志寄存器;"memory"
表示汇编代码对输入和输出操作数执行内存读取或写入操作(读写参数列表之一的变量指向的内存);输入列表 (
"r" (some_input)
): 这表明some_input
是一个输入操作数,它的值在汇编执行前被读取。"r"
约束表示some_input
被存储在某个寄存器中,具体哪个寄存器由编译器决定。输出列表 (
"+r" (result)
): 这表明result
是一个输出操作数,它的值在汇编执行后被写回。"+"
约束表示result
既可以作为输入也可以作为输出,汇编代码可以读取它的初始值,并在执行过程中更新它的值。
约束说明:
"r"
:将值放入任意一个可用的寄存器中。"+r"
:将值放入任意一个可用的寄存器中,并且该寄存器在操作后还会被写回,即它既可以作为输入也可以作为输出。"+w"
:类似于"+r"
,但表示该值在汇编代码中可能会被修改,并且修改后的值需要写回原始变量。"m"
:表示该值应该被加载到内存地址中,通常与指针一起使用。
asm("mov %0,%1"
:"+r"(val1)
:"r"(val2)
:);
由上面对指令语法的描述进行分析:
输出操作数为 val1,属性为 "=r"。
输入操作数为 val2,属性为 "r"
code 部分为 mov %1,%0,
%0 表示输入输出列表中的第一个操作数,
%1 表示操作数列表中提供的第二个操作数,以此类推,这条汇编指令很明显就是将第二个操作数(val2)赋值给第一个操作数(val1),所以最后的结果为 val1 = 222. 。
int x=10, y; __asm__ ("mov %[in],%[out]" : [out]"=r"(y) : [in]"r"(x) : );
如果指定了别名的话,那在汇编模板中,引用该变量,就可以使用别名,增加可读性,
2.2 操作符含义
"=" 表示只写,通常用于所有输出操作数的属性
"+" 表示读写,只能被列为输出操作数的属性,否则编译会报错。
& :只能用作输出
限定符 |
ARM指令集含义 |
r |
通用寄存器 |
f |
浮点寄存器 |
m |
内存地址 |
为保持寄存器,内存数据一致性,提供三个类型
类型 | 作用 |
r0…r15 | 告诉编译器汇编代码中修改了寄存器r0…r15 (v8 是x, v) |
cc | 告诉编译器汇编代码会导致CPU状态位的改变 |
memory | 告诉编译器汇编代码会读取或修改内存中某个地址存放的值 |
三、样例分析
对于刚入门优化的同学,改写汇编最好先从 C++ 改写 intrinsic 开始,然后再根据 intrinsic 的代码去改写汇编,一般 intrinsic 的指令和汇编指令都能对应的上,当然高手可以直接跳过去写汇编,但是对于新手建议还是一步步来。
而且比较重要的一点是,我认为 算法上的改进更为重要,假设你 C++ 算法层面代码已经定下来了,对于性能还想有更进一步的提升,那么可以尝试去写 neon 汇编(内联或者纯汇编),但不是说汇编是万能的,这个和你的优化经验还有算法本身的复杂度有很大关系,可能你吭哧坑次改完,发现还做了负优化,因为编译器本身也会做向量化。
3.1 两个数组加权和
第一个例子是两个数组对应元素加权和,例子足够简单,方便讲解改写汇编的一些思路。 下面代码为了可读性会相应的作简.
3.1.1 c++ 实现
bool arrWeightedAvg(const float *arr1,
const float arr1Weight,
const float *arr2,
const float arr2Weight,
const int len,
float *resultArr) {
for (int i = 0; i < len; ++i) {
resultArr[i] = arr1[i] * arr1Weight + arr2[i] * arr2Weight;
}
return true;
}
3.1.2 改 intrinsic
对于 intrinsic 代码是兼容 armv7 和 v8 的,所以不同架构之间迁移也方便,不需要改代码:
bool arrWeightedAvgIntrinsic(const float *arr1,
const float arr1Weight,
const float *arr2,
const float arr2Weight,
const int len,
float *resultArr) {
int neonLen = len >> 2;
int remain = len - (neonLen << 2);
// 这里向量化主要思路是循环内每次
// 处理4个元素的加权和
// 所以neonLen是数组长度len除4
// 而剩下的尾部元素按正常处理
float *resultArrPtr = resultArr;
const float *arr1Ptr = arr1;
const float *arr2Ptr = arr2;
// 因为一次处理4个元素
// 所以权值要拷贝4份放到
// 一个float32x4_t类型变量中
// 也相当于是128-bit向量寄存器
float32x4_t arr1Wf4 = vdupq_n_f32(arr1Weight);
float32x4_t arr2Wf4 = vdupq_n_f32(arr2Weight);
for (int i = 0; i < neonLen; ++i) {
// 分别读4个数组元素
float32x4_t arr1f4 = vld1q_f32(arr1Ptr);
float32x4_t arr2f4 = vld1q_f32(arr2Ptr);
// eltwise乘法
arr1f4 = vmulq_f32(arr1f4, arr1Wf4);
arr2f4 = vmulq_f32(arr2f4, arr2Wf4);
// eltwise加法
float32x4_t resultf4 = vaddq_f32(arr1f4, arr2f4);
// 写结果
vst1q_f32(resultArrPtr, resultf4);
arr1Ptr += 4;
arr2Ptr += 4;
resultArrPtr += 4;
}
// 处理尾部元素
for (; remain > 0; remain --) {
*resultArrPtr = (*arr1Ptr) * arr1Weight + (*arr2Ptr) * arr2Weight;
resultArrPtr ++;
arr1Ptr ++;
arr2Ptr ++;
}
return true;
}
3.1.3 arm v7 内联汇编
bool arrWeightedAvgIntrinsic(const float *arr1,
const float arr1Weight,
const float *arr2,
const float arr2Weight,
const int len,
float *resultArr) {
int neonLen = len >> 2;
int remain = len - (neonLen << 2);
// 这里向量化主要思路是循环内每次
// 处理4个元素的加权和
// 所以neonLen是数组长度len除4
// 而剩下的尾部元素按正常处理
float *resultArrPtr = resultArr;
const float *arr1Ptr = arr1;
const float *arr2Ptr = arr2;
// 因为一次处理4个元素
// 所以权值要拷贝4份放到
// 一个float32x4_t类型变量中
// 也相当于是128-bit向量寄存器
float32x4_t arr1Wf4 = vdupq_n_f32(arr1Weight);
float32x4_t arr2Wf4 = vdupq_n_f32(arr2Weight);
for (int i = 0; i < neonLen; ++i) {
// 分别读4个数组元素
float32x4_t arr1f4 = vld1q_f32(arr1Ptr);
float32x4_t arr2f4 = vld1q_f32(arr2Ptr);
// eltwise乘法
arr1f4 = vmulq_f32(arr1f4, arr1Wf4);
arr2f4 = vmulq_f32(arr2f4, arr2Wf4);
// eltwise加法
float32x4_t resultf4 = vaddq_f32(arr1f4, arr2f4);
// 写结果
vst1q_f32(resultArrPtr, resultf4);
arr1Ptr += 4;
arr2Ptr += 4;
resultArrPtr += 4;
}
// 处理尾部元素
for (; remain > 0; remain --) {
*resultArrPtr = (*arr1Ptr) * arr1Weight + (*arr2Ptr) * arr2Weight;
resultArrPtr ++;
arr1Ptr ++;
arr2Ptr ++;
}
return true;
}
3.1.4 armv8 内联汇编
#ifdef __aarch64__ // armv8
__asm__ volatile(
"mov x0, %[arr1Weight] \n" // 将weight1的值移动到通用寄存器x0中。
"dup v0.4s, w0 \n" //w0是x0的低32位, 复制值到向量寄存器v0中,当成4*32来使用。
"mov x1, %[arr2Weight] \n"
"dup v1.4s, w1 \n"
"0: \n" //循环结束条件,小于0.
"prfm pldl1keep, [%[arr1Ptr], #128] \n" //预读取arr1地址开始的128bit 数据,就是4个32bit的数据。
"ld1 {v2.4s}, [%[arr1Ptr]], #16 \n" // 将数据加载到v2 向量寄存器中, 并且地址自增16个字节。
"prfm pldl1keep, [%[arr2Ptr], #128] \n"
"ld1 {v3.4s}, [%[arr2Ptr]], #16 \n"
"fmul v4.4s, v2.4s, v0.4s \n" //数组1和权重相乘。保存在v4 寄存器中。
"fmul v5.4s, v3.4s, v1.4s \n" // 数据2和权重相乘,保存在v5 寄存器中。
"fadd v6.4s, v4.4s, v5.4s \n" //将寄存器v4, v5的值相加, 保存在v6寄存器中。
"subs %[neonLen], %[neonLen], #1 \n" // 对应 neonLen-- sub指令后面加个s表示会更新条件flag
"st1 {v6.4s}, [%[resultArrPtr]], #16 \n" //将寄存器v6的结果写入到目的地址resultarrptr, 地址自增16字节。(4个数,一个数四字节)
"bgt 0b \n" //b跳转指令, gt 判断是不是大于0条件判断, 大于0, 跳转到0的位置。
:[arr1Ptr] "+r"(arr1Ptr),
[arr2Ptr] "+r"(arr2Ptr),
[resultArrPtr] "+r"(resultArrPtr),
[neonLen] "+r"(neonLen)
:[arr1Weight] "r"(arr1Weight),
[arr2Weight] "r"(arr2Weight)
:"cc", "memory", "x0", "x1", "v0", "v1", "v2", "v3", "v4", "v5", "v6", "v7"
);
反编译系统的编译文件,进行汇编代码对比,学习。
./llvm-objdump -d /home/lsq/wind/wind_develop_my/wind/build_android/src/backend/cpu/CMakeFiles/Wi
ndCPU.dir/kernel/neon/matmul_quant_test.cc.o
内联汇编的目的是进行汇编指令的优化,尽可能的直接操作寄存器,利用新特性,进行代码的加速。更多的指令需要查找官方文档进行学习。
3.2 汇编指令对应的机器码生成
.inst 0x4e80a4d8
是一个汇编指令,用于在 ARM 架构中直接插入机器码。这个指令的格式是 .inst <机器码>
,其中 <机器码>
是一个 32 位或 64 位的十六进制值,表示一条机器指令。
具体来说,0x4e80a4d8
是一个 32 位的机器码。为了理解这个机器码是如何编码的,我们需要查看 ARMv8 指令集的文档,特别是 NEON 指令集的文档。
3.2.1 使用 LLVM 工具
可以安装 llvm
工具链,然后运行如下命令:、
echo "smmla v16.4s, v4.16b, v0.16b" | llvm-mc -arch=aarch64 -mattr=+neon,+i8mm -show-encoding
这将会输出汇编指令对应的机器码。如果没有安装 llvm-mc
工具,可以参考以下汇编器指令来生成机器码。
3.2.2 使用 GNU 汇编器
你可以使用 arm-none-eabi-as
工具来编译汇编代码并生成机器码。下面是一个示例:
echo ".arch armv8-a; smmla v16.4s, v4.16b, v0.16b" | arm-none-eabi-as -o - -a -
3.2.3 在线工具
https://armconverter.com/
3.2.4 反编译编译产物
./llvm-objdump -d /home/lsq/wind/wind_develop_my/wind/build_android/src/backend/cpu/CMakeFiles/WindCPU.dir/kernel/neon/matmul_quant_test.cc.o 反汇编结果:
".inst 0x4e80a490 \n" // smmla v16.4s, v4.16b, v0.16b //v0_01s
// *y0_0
".inst 0x4e81a4b5 \n" // smmla v21.4s, v5.16b, v1.16b //v0_0hs
// *y0_1
".inst 0x4e82a4da \n" // smmla v26.4s, v6.16b, v2.16b //v0_1ls
// *y0_2
".inst 0x4e83a4ff \n" // smmla v31.4s, v7.16b, v3.16b// v0_1hs
四、附录
https://medium.com/@warmap_/%E8%BD%AC-%E5%A6%82%E4%BD%95%E5%9C%A8c%E6%88%96c-%E4%BB%A3%E7%A0%81%E4%B8%AD%E5%B5%8C%E5%85%A5arm%E6%B1%87%E7%BC%96%E4%BB%A3%E7%A0%81-a3704e164de8
http://giantpandacv.com/project/%E9%83%A8%E7%BD%B2%E4%BC%98%E5%8C%96/AI%20%E7%A7%BB%E5%8A%A8%E7%AB%AF%E7%AE%97%E6%B3%95%E4%BC%98%E5%8C%96/%E7%A7%BB%E5%8A%A8%E7%AB%AFarm%20cpu%E4%BC%98%E5%8C%96%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/%E7%A7%BB%E5%8A%A8%E7%AB%AFarm%20cpu%E4%BC%98%E5%8C%96%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%E7%AC%AC4%E5%BC%B9--%E5%86%85%E8%81%94%E6%B1%87%E7%BC%96%E5%85%A5%E9%97%A8/