Coolbpf 最新特性解读:profiler 功能上线,助力性能分析和优化

图/SysOM 和 Coolbpf 项目架构师毛文安
回顾
在上一篇文章“eNetSTL 网络功能库在龙蜥社区开源了!!!”中,我们介绍了龙蜥系统运维联盟底层 eBPF 采集项目 Coolbpf 新增的网络功能库,受到了广大 eBPF 开发者的喜爱,最近 Coolbpf 也新增了性能分析 profiler 模块,结合 SysOM 和 Coolbpf 项目架构师毛文安于 12 月 6 日在全球 C++及系统软件技术大会上做的分享,本文将为读者进一步介绍 profiler 模块的具体内容。
Coolbpf 的 profiler 模块利用采样技术捕获应用进程的 on-CPU、off-CPU、内存和锁等调用栈信息,并将这些数据以火焰图的形式直观展示。它不仅支持 C/C++、Rust、Go 等编译型语言(这些语言直接编译成机器码),也兼容 Java、Python、LuaJIT 等解释型或高级语言(这些语言不直接编译成机器码)。特别是针对 AI 基础设施训练推理框架(Pytorch、vLMM),能够融合 CPU 的 Python 栈和 GPU 的 kernel 函数延迟并将其绘制在一张火焰图上,可以进行问题的定界和诊断。
技术背景
Coolbpf profiler 主要提供了以下三个核心功能:
1.栈回溯:获取内核态和用户态的详细调用栈信息,此时只是包含调用链及地址信息;
2.符号解析:将调用栈中的内核地址和用户态地址解析为用户易于理解的函数名称;
3.火焰图生成:通过火焰图的形式直观地展示调用栈数据。

接下来,我们将深入探讨栈回溯和符号解析的策略,并详细阐述 Coolbpf profiler 所采纳的具体方案。
栈回溯方案分析
栈回溯主要是获取当前程序的完整调用栈,它是生成火焰图的首要且关键的步骤,这一过程中存在两个主要的技术挑战:
1.无 fp(frame pointer,帧指针)的应用程序:为了优化性能,众多程序选择不保留 fp。这导致我们无法依赖传统的基于 fp 回溯的方法来获取调用栈信息。因此,我们必须转而采用基于 dwarf 的更为复杂的栈回溯技术。
2.Java、Python 等解释型语言的栈回溯:这些解释型或高级编程语言各自拥有独特的栈帧结构。因此,关键在于识别当前运行的程序,并准确解析出相应的栈帧信息。
注:GCC 编译器允许通过-O 参数来设置优化级别,其中-O0 表示不进行优化,而-O3 表示最高级别的优化。当优化级别设置为-O1 时,GCC 编译的程序默认不会保留 FP,而是将其视为一个通用寄存器来使用。不过,我们可以通过在 GCC 中添加-fno-omit-frame-pointer 编译选项,强制编译器为所有函数生成帧指针。
为了应对这两个挑战,Coolbpf profiler 利用 eBPF 的编程灵活性,支持无 fp 的应用以及解释型语言的栈回溯功能。具体细节请参考“栈回溯原理”小节。除了 eBPF,其他主流的栈回溯方案包括 perf 和语言级别的接口(例如 Java 提供的 JVM TI)。以下是对这三种栈回溯方案的对比分析。

从上表中我们可以观察到不同方案各自的优势和局限性。
Perf 作为一个历史悠久的性能分析工具,它支持所有版本的内核,但在处理动态语言方面表现不足。此外,当使用基于 dwarf 的栈回溯时,由于需要将整个用户态栈空间输出到用户态程序,这会导致较大的资源消耗。
eBPF 的可编程性为新型栈回溯方案开辟了广阔的可能性,尤其是在支持动态语言栈回溯方面,通过分析代码运行时信息,能够完整解析出 Java、Python 等动态语言的调用栈,充分展现了 eBPF 的灵活性。唯一的限制是它对内核版本有一定的要求。
至于语言级别的采样工具,如 async-profiler,它们能够利用 JVM 提供的接口来收集栈信息,不依赖于内核版本,且资源消耗较低。然而,由于它们是进程级别的侵入式采样,存在极低概率导致业务应用崩溃,因此在稳定性方面存在不足。
对于 Coolbpf 而言,其设计目标是能在生产环境中持续稳定运行。因此,在确保功能完整性的基础上,稳定性被视为最重要的因素。为了实现这一目标,Coolbpf 集成了三种不同的方案,以便在各种场景下都能提供完善的功能支持。在底层的决策逻辑中,eBPF 被设定为最优先选项,其次是 perf,最后是语言级接口。下面的图表展示了根据不同编程语言和内核版本选择的栈回溯方案。

符号解析方案
符号解析主要是将对应的地址转换成函数名,一般地,对于编译型语言的应用可以通过查找 elf 文件的符号表即可完成,对于解释型语言需要从进程内存中读取符号。这些是符号解析所需要解决的技术问题。从架构方案来看,存在两种方案选择:

从上表中可以观察到,本地符号解析依赖较少,由于需要进行符号缓存以加速查找速度,这会导致较大的内存占用。此外,由于大多数业务应用在生产环境中部署时不包含 debuginfo,可能会出现符号缺失,进而影响符号的准确性。相比之下,远程符号解析的部署依赖会多些,例如依赖网络传输调用栈信息,但它不需要在业务机器上缓存符号,因此内存占用较低。同时,远程解析可以从类似 yum 源的地方下载应用的 debuginfo 包,以获得更完整的符号信息。
本地解析更适合于单台机器的性能剖析,而远程解析更适合于集群和大规模部署,能够显著降低整体开销。例如,如果集群内部署的是同一版本的 MySQL 应用,那么只需建立一个全局的符号缓存,从而减少资源消耗。
鉴于本地和远程符号解析各有优势,profiler 同时支持这两种方案。更多详细信息,请参见“符号解析”小节。
整体架构
由于远程符号解析和集成的语言级接口,例如 async-profiler、py-spy 等,尚未公开源代码,本节将重点介绍 eBPF、perf 以及本地符号解析的整体架构方案。同时,我们将展示在阿里云控制台上的前端界面效果图。
系统架构
下面的架构图是由底层组件 Coolbpf profiler 至前端的完整系统结构,它由三个主要部分组成:Sysom 前端、Sysom Agent 和 Coolbpf profiler,其中 SysOM 是智能运维平台,Coolbpf 是 eBPF 采集工具,Sysom Agent 负责启动 Coolbpf 功能及数据通信。下面是对每个部分的详细介绍:
1.Sysom 前端:这是用户与系统交互的界面,提供了性能分析的可视化功能。包含三个主要功能模块:
热点分析:分析并展示程序中性能瓶颈的热点区域。
热点对比:允许用户比较不同实例、不同时间点或不同条件下的性能热点变化。
CPU&GPU 热点图:提供 CPU 和 GPU 的性能热点图,帮助用户识别 GPU 性能问题。
2.Sysom Agent:作为中间层,Sysom Agent 负责收集和处理性能数据,并将结果发送到前端。包含四个热点模块:
OnCpu 热点:检测 CPU 上的热点问题。
OffCpu 热点:检测进程为什么被阻塞。
内存热点:识别内存使用中的热点区域。
锁热点:分析并报告锁竞争导致的性能问题。
3.Coolbpf profielr:这是底层的通用性能分析库,为 Sysom Agent 提供支持。包含两个主要部分:
eBPF&perf 栈回溯:①利用 eBPF 技术在内核态进行调用栈的捕获和分析,支持多种编程语言,如 C/C++/Rust/GoLang,以及 Java/Python/Luajit;②使用 perf 工具进行调用栈的捕获,这包括原生代码的符号解析和基于 perf 的 C/C++/Rust/GoLang 的调用栈分析。
用户态符号解析:处理用户态程序的符号信息,包括编译型程序的符号表和解释型或高级语言运行时符号。

前端展示
本节将重点介绍热点分析和热点对比前端界面使用方法。
1.热点分析
热点分析的大致步骤如下:
1)参数选择:依次选择实例 ID、进程名、热点类型及时间范围。最后点击“执行热点追踪”按钮。需要注意的是热点类型是动态的,也就是会根据当前时间段该进程包含哪些热点类型来进行渲染,比如只包含 OnCpu,那么热点类型下拉列表就只有 OnCpu。

2)OnCpu 热点:我们选择 OnCpu 后,就会立即渲染出如下图所示的 OnCpu 的火焰图;

3)内存热点:我们选择“内存”后,就会立即渲染出如下图所示内存占用的火焰图;

2.热点对比
热点对比对于分析正常环境和异常环境是一大杀器,能够精准的分析出差异,进而确定问题根因。使用步骤大致如下:
1)参数选择:相比热点分析只需要选择一个机器实例,热点对比功能则需要两个机器实例,参数选择完毕后,点击“执行对比分析”按钮,则可触发生成对比火焰图。

2)内存差分火焰图:下图是内存热力类型的差分火焰图,由于我们选择的机器实例、进程、时间段都是一致的,所以差分火焰图最后呈现都是灰色,表示不存在热点差异。

GPU 火焰图
上面架构图中展示的 CPU&GPU 热点图,这是我们目前正在积极推进的事情,我们称之为 CPU&GPU 荣融合火焰图,如下图所示。其核心工作机制是将 GPU 核函数与 CPU 进程调用栈进行匹配和融合,共同展示在一张火焰图上。图中带有“GPU:”前缀的条目代表 GPU 的核函数,火焰图方格的宽度表示 GPU 核函数执行的时间长度,单位是纳秒。


栈回溯原理
在“技术背景”部分,我们讨论了 Coolbpf profiler 如何利用 eBPF 技术,不仅能够实现对无 fp 编译型程序的栈回溯,也支持 Java、Python 等解释型或高级语言的栈回溯。对于 3.10 版本的内核,profiler 同样能够通过 perf 工具来完成栈回溯任务。由于篇幅所限,本节将首先介绍基于 fp 的栈回溯方法,随后阐述我们如何利用 eBPF 实现基于 dwarf 的栈回溯。最后,我们将以 Java 栈回溯为例,详细说明如何处理 Java 中的解释执行代码以及 JIT 后代码的栈回溯问题。
基于 fp 的栈回溯
在介绍基于 dwarf 的栈回溯之前,先了解下传统的基于 fp 的栈回溯。在 x86-64 架构中,fp 通常指的是 rbp 寄存器。下图是 x86_64 的栈帧结构:

可以看出,通过访问 rbp+8 可以获取到返回地址(rip),而 rbp 寄存器本身则存储着上一个函数的 rbp 值。利用上一个函数的 rip 和 rbp,我们可以追溯到上上个函数的 rip 和 rbp。以下是一个简单的伪代码示例来说明这个过程。这里的 pt_regs 指的是内核中的 struct pt_regs 结构体,它包含了 eBPF 程序触发时的参数,记录了中断发生时的寄存器状态。

基于 dwarf 栈回溯
无 fp 的应用程序的 rbp 寄存器不再作为特殊寄存器来存放帧指针,而是作为通用寄存器。这样的话就没办法通过 rbp 来获取到上一层函数的 fp 以及返回地址。不过,我们可以通过 elf 文件中 eh_frame 段保存的信息来实现基于 dwarf 的栈回溯。
eh_frame 节中的信息是基于 DWARF ()调试格式的,它包括了调用帧信息(CFI - Call Frame Information),这些信息由一系列编码的指令序列组成。CFI 记录了函数的栈帧布局,包括栈的大小、寄存器的保存位置等。这样,当异常发生时,运行时系统可以使用这些信息来逐层遍历栈帧,找到异常处理程序或进行堆栈回溯。
eh_frame 节中的每个 CFI 记录通常包括一个通用信息入口(CIE - Common Information Entry)和一个或多个帧描述入口(FDE - Frame Description Entry)。CIE 包含了用于解释 FDE 的通用信息,而 FDE 包含了特定函数的栈帧展开信息。这些信息包括函数的起始地址、地址范围、栈帧大小和寄存器的保存位置等。CFA (Canonical Frame Address, which is the address of %rsp in the caller frame),CFA 就是上一级调用者的堆栈指针。
下面是一个具体的例子。下面的汇编代码是函数 func_c 对应的汇编代码:
下面是函数 func_c 所携带的 eh_frame 信息,可以看到对于每个 pc 地址都有对应的栈回溯方法。比如 0x401182,CFA 的值为 RSP+16,RBP 的值为 CFA-16,RIP 的值 CFA-8。这样我们可以算出 RBP 和 RIP,将 RIP 作为新的 PC 值,然后通过 PC 值按照上面的方法,计算出新的 RBP 和 RIP,整个栈回溯可以循环的展开下去。
解释型语言的栈回溯
栈回溯在解释型语言中需要根据不同语言的特性采取不同的处理方法。本节将重点介绍 Java 语言的栈回溯的主要步骤。下图展示了 Java 栈回溯的大致流程:
1.根据 pc 找到对应的 CodeBlob(CodeBlob 是 Java 虚拟机中的一个概念,它是 HotSpot 虚拟机中用于表示代码的一块内存区域);
2.根据 CodeBlob 的类型,确定不同的栈回溯方法;
3.根据不同的栈回溯方法(栈回溯细节见下文),确定上一层栈的 pc,sp,fp 等值;
4.根据新的 pc 进行下一次栈回溯,以此往复。

CodeBlob 主要包括四种类型,接下来让我们一起看看每一种类型的栈回溯方法。
nmethod 栈回溯
在 Java 虚拟机中,nmethod 是一个特定的术语,它代表了即时编译器(Just-In-Time Compiler,JIT)编译后的本地机器代码。nmethod 栈回溯主要需要考虑三种场景:
1.pc 落在 prologue;
2.pc 落在函数体;
3.pc 落在 epilogue。
pc 落在 prologue
下面是 java 生成的 prologue 样例。pc 落在 prologue 可以再分成两种场景:
1)pc 落在 0x7fcd64723fe7 之前,此时栈里面没有 fp,只有 pc。故 rip = [rsp + 8]。
pc 落在 0x7fcd64723fe7 之后,此时栈里面有 fp 和 pc。故 rip = [rsp + 16],rbp = [rsp + 8]。
pc 落在函数体
pc 落在函数体有两种场景:
1)检查 fp 是否合理,即 fp 是否落于 sp 到 sp+frame_size 的区间。如果合理,则可以直接利用 fp 栈回溯,通过 fp 获取到 return address 和 caller 的 fp。
2)如果不合理,主要是 jvm 会偶尔在栈里面添加额外的信息,导致栈的大小超过了 codeblob 里面保存的大小,因此需要通过启发式算法来寻找合理的 pc 和 fp。具体代码如下:
处理 epilogue 部分相对复杂,因为 JVM 会生成多种不同类型的 epilogue 代码。epilogue 的处理需要逐个案例分析,主要任务是修正 rsp 寄存器的值。由于 epilogue 中 JIT 可能会插入额外的字节,这会导致 rsp 值的变化。我们之前已经与社区合作,梳理了 epilogue 的各种类型,具体内容可以参见 GitHub 上的讨论:open-telemetry/opentelemetry-ebpf-profiler#136。
interpreter 栈回溯
Java interpreter 有自己的栈帧格式,不遵循 x86_64 的栈帧格式。相比于 x86_64,其保存的信息更加完整。具体如下图所示:

已知 pc 得到 fp,依据 fp 的值以及上一层 fp、return pc 的在栈帧内的偏移,很容易获取上一层 fp 和 return pc 的值。
vtable
在 Java 虚拟机中,vtable(虚方法表)是一种实现多态的机制。它与类的继承和接口实现有关,允许子类重写父类的方法,或者实现接口的方法。vtable 是在类加载时由 JVM 创建的,并且每个类或接口的实例都有一个指向其 vtable 的引用。vtable 的栈帧比较简单,只包含了 return address。因此,vtable 这里没要复杂的逻辑就可以获取到 return address。
codeblob 其它类型
codeblob 还有很多其它类型,不过不需要特殊处理。因为其他类型不会像 nmethod 一样有复杂的 prologue、epilogue 以及处理 jvm 在 JIT 往栈里面添加额外的数据的场景。因此,直接通过 sp 和 codeblob 里面的 frame_size,直接到栈底。从栈底可以依次取出 return address 和 pc。
Python 的处理
前面介绍了 Java 的栈回溯方法,下面简单介绍一下 Python 的栈回溯方法。Python 有自己的栈帧格式,在介绍 Python 推栈原理之前,先介绍下描述 Python 栈帧的关键结构体 struct _frame,它有两个比较重要的成员,f_back 和 f_code,下面是对这个数据结构中每个成员的解释:
1.struct _frame *f_back:指向上一个栈帧的指针,用于链接当前栈帧与调用者的栈帧。如果当前栈帧是调用栈的最顶层,则此值为 NULL。2.PyCodeObject *f_code:指向当前正在执行的代码对象(PyCodeObject),包含了字节码、指令等信息。
Python 栈帧通过 f_back 变量进行关联。每个栈帧通过 f_back 指针与其父栈帧相连,形成了一个调用链。当一个函数 A 调用另一个函数 B 时,B 函数的栈帧的 f_back 会指向 A 函数的栈帧。这样,解释器可以通过 f_back 指针逐个回溯到调用栈的顶部。如下图所示。

符号解析
在“技术背景”小节介绍到本地、远端两个符号解析方案,并分析了各自的优劣。本节将依据具体的实现,先介绍如何从编译型和解释型的程序中提取符号,最后介绍下 Coolbpf profiler 本地解析的方案。
提取符号
1)编译型
编译型语言编译时会生成一个 elf 格式的文件,而程序的符号就存储在两个段中,分别是:.symtab 段保存了所有的符号信息,包括动态链接符号信息。这个段包含了目标文件中定义的所有符号,无论是全局的还是局部的;
.dynsym 段保存了与动态链接相关的导入导出符号,通常是 .symtab 的一个子集,并且只包括那些在动态链接过程中需要的符号。
2)解释型或高级语言
对于解释型语言,需要根据特定语言的特性来提取关键信息。以 Java 为例,需要通过程序计数器(pc)定位到对应的函数的 CodeBlob 对象,而 CodeBlob 对象中包含了该函数的符号信息。
本地符号解析
下图是本地符号解析的流程。每一层栈记录的是 FileID 和 Addr,首先根据 FileID 去查找该文件对应的符号表,然后根据 Addr 和有序的符号表进行二分查找,找到具体的函数符号。

总结和展望
本文介绍了 Coolbpf 性能分析模块中的 profiler 功能。我们详细分析了栈回溯方案,并探讨了使用 eBPF 实现对无 fp 的应用和解释型语言的应用进行栈回溯的方法。接着,我们介绍了符号解析的两种方案:本地解析和远程解析,并分析了各自的优缺点。在实现了栈回溯和符号解析之后,我们不仅可以获取 CPU 热点信息,还能获取内存和锁等其他热点信息。然而,由于内存和锁热点的分析依赖于 uprobe,其性能较差,无法满足常态化部署对低资源消耗的要求。因此,未来的重要研究方向之一是优化 uprobe 的性能以及探索其他替代方案。
版权声明: 本文为 InfoQ 作者【OpenAnolis小助手】的原创文章。
原文链接:【http://xie.infoq.cn/article/dc33e8fc20db90ec4521b4a03】。文章转载请联系作者。
评论