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)。以下是对这三种栈回溯方案的对比分析。
栈回溯方案 | 无FP | 解释型语言 | 内核版本限制 | 稳定性 | 资源开销 |
perf | 支持,但开销会较大 | 不支持 | 无 | 高 | 中 |
eBPF | 能够支持(eBPF可编程性) | 能够支持 | ≥4.19 | 高 | 低 |
语言级接口 | \ | 支持 | 无 | 中 | 低 |
从上表中我们可以观察到不同方案各自的优势和局限性。
-
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对应的汇编代码:
0000000000401181 <func_c>:
401181: 55 push %rbp
401182: 48 89 e5 mov %rsp,%rbp
401185: bf 04 20 40 00 mov $0x402004,%edi
40118a: e8 a1 fe ff ff callq 401030 <puts@plt>
40118f: b8 00 00 00 00 mov $0x0,%eax
401194: e8 9d ff ff ff callq 401136 <func_d>
401199: 90 nop
40119a: 5d pop %rbp
40119b: c3 retq
下面是函数func_c所携带的eh_frame信息,可以看到对于每个pc地址都有对应的栈回溯方法。比如0x401182,CFA的值为RSP+16,RBP的值为CFA-16,RIP的值CFA-8。这样我们可以算出RBP和RIP,将RIP作为新的PC值,然后通过PC值按照上面的方法,计算出新的RBP和RIP,整个栈回溯可以循环的展开下去。
00000084 0000001c 00000088 FDE cie=00000000 pc=00401181...0040119c
Format: DWARF32
DW_CFA_advance_loc: 1
DW_CFA_def_cfa_offset: +16
DW_CFA_offset: RBP -16
DW_CFA_advance_loc: 3
DW_CFA_def_cfa_register: RBP
DW_CFA_advance_loc: 22
DW_CFA_def_cfa: RSP +8
DW_CFA_nop:
DW_CFA_nop:
DW_CFA_nop:
0x401181: CFA=RSP+8: RIP=[CFA-8]
0x401182: CFA=RSP+16: RBP=[CFA-16], RIP=[CFA-8]
0x401185: CFA=RBP+16: RBP=[CFA-16], RIP=[CFA-8]
0x40119b: CFA=RSP+8: RBP=[CFA-16], RIP=[CFA-8]
解释型语言的栈回溯
栈回溯在解释型语言中需要根据不同语言的特性采取不同的处理方法。本节将重点介绍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]。
2) pc落在0x7fcd64723fe7之后,此时栈里面有fp和pc。故rip = [rsp + 16],rbp = [rsp + 8]。
0x7fcd64723fe0: movl %eax, -0x14000(%rsp)
0x7fcd64723fe7: pushq %rbp
0x7fcd64723fe8: subq $0x50, %rsp
- pc落在函数体
pc落在函数体有两种场景:
1)检查fp是否合理,即fp是否落于sp到sp+frame_size的区间。如果合理,则可以直接利用fp栈回溯,通过fp获取到return address和caller的fp。
2)如果不合理,主要是jvm会偶尔在栈里面添加额外的信息,导致栈的大小超过了codeblob里面保存的大小,因此需要通过启发式算法来寻找合理的pc和fp。具体代码如下:
for (int i = 0; i < HOTSPOT_RA_SEARCH_SLOTS; i++, ui->sp += sizeof(u64)) {
DEBUG_PRINT("jvm: -> %u pc candidate 0x%lx", i, (unsigned long)stack[i]);
if (hotspot_addr_in_codecache(trace->pid, stack[i])) {
DEBUG_PRINT("jvm: -> unwinding complete frame + %d words", i);
*action = UA_UNWIND_REGS;
return ERR_OK;
}
}
-
pc落在epilogue
处理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的性能以及探索其他替代方案。
—— 完 ——