arm 内联汇编基础

一、 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), 剩下会采用压栈的方式保存

寄存器寄存器别名用途
r0a1第一个函数参数: Scratch 寄存器
r1a2第二个函数参数: Scratch 寄存器
r2a3第三个函数参数: Scratch 寄存器
r3a4第四个函数参数: Scratch 寄存器
r4v1寄存器变量
r5v2寄存器变量
r6v3寄存器变量
r7v4寄存器变量
r8v5寄存器变量
r9v6 rfp寄存器变量 实际的帧指针
r10sl栈接线
r11fp参数指针
r12ip临时
r13sp栈指针
r14lr连接寄存器
r15pc程序计数

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开始,然后才是写其他只读变量,只读变量constraintr

  • 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/

  • 26
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值