AArch64架构调用链性能数据采集原理
内容分析:
1. 术语解释
2. Frame Pointer RegisterStack Unwind
3. Dwarf-based Stack Unwind
4. /BRBE/CSRE Stack Unwind
5. Kernel-space Stack Unwind&eBPF Unwinders
本次分享的主题可以帮助到一些场景,比如在原有的平台上迁移到 Arm64 适配或优化的过程中会涉及到性能数据收集的步骤。性能数据收集例如调度栈的收集可以容易获得到相关信息的渠道,但是我遇到在 x86 上做性能优化时,如何获得程序运行时的调度栈的信息恰恰会卡住整个团队的进度。本次分享使用 Arm 平台对调度链进行收集。
本次分享有五部分。第一进行术语的解释,第二部分基于栈寄存器的调用链收集,第三部分基于 Dwarf 调用链的收集,这两部分都是基于软件调用链的收集,第四部分是基于硬件。第五部分在 Linux 内核中获得当下比较流行的基于 eBPF 系统数据收集,收集该调用栈会遇到什么问题或选择。
01.术语解释
首先介绍调用栈收集前介绍一些概念。一个调用栈由一个方法、一个帧组成。在编译原理中成为 Active Record 活动记录。一个帧有起始地址,保持方法的局部变量。也有栈顶地址,通常有一个单独的寄存器进行保存,例如X29是帧的起始地址,X31/XP用来保存栈顶的地址。帧与帧之间如何进行交互呢?通过 Calling Convention 调用约定。从A方法调用到B方法存在一个存参的约定。例如在 Arm 上X0是第一个参数,X1是第二个参数,如果超过X8可能需要通过栈来传输。所以从一个帧到另一个帧存在主动调用和被动调用的关系,主动调用是 Caller,被调用者是 Callee。
当一个事件发生时或者在程序中下一个断点时,需要从事件发生的点回溯到起始的调用方,称为 Stack Unwinder,一层一层解,从 Main方法一层一层调用。该过程为 Stack Unwinder。如图中的例子,该帧有一些参数和局部变量,FP(X29)指向帧的起始,SP(X31)指向栈底。
一层一层解开后就可以进行符号化。从帧到对应的符 Symbolization 。
图中中间是一个个帧, Frame 右侧是内部结构。一帧一帧解开后是地址,从地址到右侧的 Foo()、bar()、baz()是一个符号化过程。
介绍完术语后,介绍如何解开一个帧。
02.Frame Pointer RegisterStack Unwind
如下有一个示例 unwind.c。
在 Main 方法中调用 Foo(),在Foo()中调用Bar(),接着进行编译。
此处使用了一个参数 -fno-omit-frame-pointer,可以看到编号为54的 STP 的指令会将X29和X30保存到栈上。右侧保存X29、X30,同时将58位置上保存X29,更新 SP,指向新的位置。
Main 调用Foo,foo中是如何调用呢?28与2C的位置相同,但4C处返回时会调用上一帧的位置。右侧是在调用 Foo 后的状态,由于没有局部变量,所以保存上一帧的状态。接着调用 Bar。
调用 Bar 时,没有 STP 和X29、X30的寄存器。进入后寄存器的状态会指向该位置,其中会往上指,所以会产生链式的结构,一层一层往上解开栈。但是由于在调用Bar时,并没有保存 Foo 的返回位置,只是 Foo 保存了 Main 的返回位置,所以中间的 Foo 看不到。做 Stack Unwinder 时看不到这个地方。
这是第一个问题,第二个问题是如果将栈底的寄存器省略掉后会完全不保存,无法构建一个链向上回溯。由于它可以作为一个通用的寄存器进行使用,所以程序可以使用通用寄存器,降低将变量存到栈上的机会,提高性能。 Java 测试在一些极限的条件下大概有8%的性能影响。
以上就是性能问题和中间不保存丢失的问题。
还有此处,我们很依赖于保存上下文、栈帧的起始和结束位置。但是如果此时有一个远程的跳转 Black Miss,而代码不在指定的 Catch 中就会发生 Catch Miss。 Catch Miss 指的是代码的起始位置,但是代码的起始位置无法回溯到栈,这一层会被忽略掉,还没有开始构建栈的信息。或者在构建 Return 时就出去就会产生这样的问题。
03.Dwarf-based Stack Unwind
第二个方法基于 Dwarf 信息作为 Stack unwind 的工作。
Dwarf 信息是一个格式标准,不仅使用在 Stack Unwind,在 GDB 中也会大量使用 Dwarf 信息进行 Stack Unwind 。将栈的边界信息或者调试信息存在单独的F文件或 Section 中。如果只做 Stack Unwind,不需要使用 gcc-g 生成 Dwarf 信息。因为 Stack Unwind 是栈的回溯信息,存在 .eh_frame 中。该信息用来做异常处理,例如写 C 代码调用 C++ 就会进行异常处理,所以需要存该信息进行回溯。 .eh_frame section 一定存在,所以可以进行一层层解开。
如果使用 Dwarfdump 命令,Dwarf 出结果后可以看到 Bar 方法下面有一些汇编和条目。其中 CFA=00(r31)位置的 CFA 代表 Canonical Frame Address,是帧的起始位置。R31是栈底位置,R31加上00表示栈底和栈顶在一起。第二条的400698位置,由于我们已经在栈帧上开辟了一个字节空间,CFA 会变成R31加 SP 变成栈的起始位置。最后一条4006b8位置执行Return 指令, Return 之前有 Add 指令,将SP加回16字节,栈顶位置与栈底位置在重复位置,所以偏移为00。虽然没有保存栈的上下文的 Frame Pointer 在栈帧上,但是通过该信息可以准确还原。如果 Exception 出现 Catch Miss 在起始位置也没有关系,可以进行正常回溯。
后面类似,可以通过 Bar方法进行回溯,一层一层往上。
看似该方案很好,但实际上还存在一个问题:速度慢。它是一个基于查表的方式,该表若是全部存下来会非常大。所以提出使用一个状态机或字节码快速描述该表,在执行时使用状态机或字节码去解开。
此处定义了一些字节码,但是存在一些缺陷。
非常慢,而且容易出错。由于是一个状态机,所以也存在安全问题。所以该机制并不完美。
04. /BRBE/CSRE Stack Unwind
之后是一个硬件机制 Arm64 BRBE/CSRE或Intel。
通过硬件机制去记录跳转过程,好处是开销很低并且非常可靠。下面是一个例子。
可以看到 Perf 命令中 any_call 会将跳转的位置罗列出:从哪里跳转到哪里。但是存在一个问题:硬件记录时存储成本太高。通常来讲,栈的深度足够长是不能回溯到起始位置的。
还有 Inline 函数展示不出。
以上介绍了几种能用的方式。
05.Kernel-space Stack Unwind&eBPF Unwinders
最后介绍内核态。
如果在内核上做 Stack Unwinder 有两种方式。第一基于 Frame Pointer Register,第二是 ORC Unwinder。
另外 eBPF 分为两部分。如果解的是内核态的栈,与上述一致。如果解用户态的栈,有很多人无法使用 ORC 技术,会提前基于 Dwarf 信息构建表,通过 eBPF 的 Map 机制将表的信息送入内核,在里面构建状态机进行解。好处是不用将用户态的栈从内核拷到用户态中,可以节省一定效率。所以该机制相对较好,未来还在探索更好的方式。