在进行CPU性能优化时,我们常常需要先定位应用程序中的CPU资源究竟消耗在哪些函数上,这样才能做到有的放矢。由《性能之巅》作者 Brendan Gregg 发明的火焰图,就是进行此类分析的绝佳工具。

今天,我们就来深入探讨火焰图的使用方法,并抽丝剥茧地理解其背后的工作原理。
一、火焰图的使用
为了更好地阐释火焰图的原理,我专门编写了一段演示代码:
int main() {
for (i = 0; i < 100; i++) {
if (i < 10) {
funcA();
} else if (i < 16) {
funcB();
} else {
funcC();
}
}
}
完整的源码已托管在 GitHub 上:https://github.com/yanfeizhang/coder-kung-fu/blob/main/tests/cpu/test09/main.c。
下面,我们就用这段代码来实际体验火焰图的生成流程。本节我们先聚焦于“如何使用”,其原理将在后续章节展开。
首先编译并运行程序,同时使用 perf record 进行采样:
# gcc -o main main.c
# perf record -g ./main
执行完毕后,当前目录下会生成一个 perf.data 文件。接下来,我们需要下载 Brendan Gregg 的火焰图生成工具集:
# git clone https://github.com/brendangregg/FlameGraph.git
然后,我们使用 perf script 解析采样数据,并通过管道传递给两个 Perl 脚本进行处理,最终生成 SVG 格式的火焰图:
# perf script | ./FlameGraph/stackcollapse-perf.pl | ./FlameGraph/flamegraph.pl > out.svg
至此,一幅火焰图就生成了。

选择这个简单的 demo 代码是为了让原理更清晰。在上图的火焰中,可以清晰地看到 main 函数调用了 funcA、funcB、funcC,而 funcA 又调用了 funcD 和 funcE。最关键的是,这些函数的耗时大头并非自身逻辑,而是它们都调用的一个 CPU 密集型函数 caculate。整个系统的调用栈耗时分布一目了然。
如果要对这个项目进行性能优化,我们该看哪里?虽然图中 funcA、funcB 等函数的柱子很长,但它们的耗时都“贡献”给了子函数。我们真正应该关注的是火焰图最顶部那些又长又平的“山头”,比如这里的 caculate,它才是真正消耗 CPU 时间的元凶。在实际项目中,拿到火焰图后,也应该从最上层开始,寻找那些最宽的函数进行优化。
另外,实际项目中的函数调用可能非常复杂,很多函数名会被折叠。幸运的是,SVG 格式的图片是交互式的,你可以点击任何一个函数,从而展开并聚焦查看该函数及其子函数的详细火焰图。
怎么样,火焰图的使用是不是挺简单的?不过,要想用得得心应手,我们还需要理解其生成的全过程。
二、perf采样
2.1 perf 介绍
生成火焰图的第一步是对目标进程进行采样。采样工具有多种,这里我们使用的是 perf record。
# perf record -g ./main
上面的命令中,-g 参数表示采样时要记录调用栈信息,./main 表示启动 main 程序并只采样该进程。这只是一个最简单的用法,perf record 的功能其实非常丰富。
- 指定采集事件:可以通过
perf list 查看系统支持的所有事件。默认采集的是 Hardware event 下的 cycles(CPU周期)。如果你想采样 cache-misses(缓存未命中)事件,可以使用 -e 参数。
# perf record -e cache-misses sleep 5
- 指定采样方式:支持两种模式。
-F 指定每秒采样次数(频率采样),-c 指定每发生多少次事件采样一次(事件计数采样)。
# perf record -F 100 sleep 5 // 每秒采样100次
# perf record -c 100 sleep 5 // 每发生100次事件采样一次
- 指定CPU核:
# perf record -C 0,1 sleep 5 // 记录0号和1号CPU
# perf record -C 0-2 sleep 5 // 记录0到2号CPU
- 采集内核调用栈:使用
-a 参数可以采集整个系统(包括内核)的活动。
# perf record -a -g ./main
执行 perf record 后,采样数据会保存在 perf.data 文件中。我们可以用 perf script 命令解析查看其内容,里面记录了每次采样时的完整调用栈信息,行数可能非常多。
......
59848 main 412201 389052.225443: 676233 cycles:u:
59849 55651b8b5132 caculate+0xd (/data00/home/zhangyanfei.allen/work_test/test07/main)
59850 55651b8b5194 funcC+0xe (/data00/home/zhangyanfei.allen/work_test/test07/main)
59851 55651b8b51d6 main+0x3f (/data00/home/zhangyanfei.allen/work_test/test07/main)
59852 7f8987d6709b __libc_start_main+0xeb (/usr/lib/x86_64-linux-gnu/libc-2.28.so)
59853 41fd89415541f689 [unknown] ([unknown])
......
除了 perf script,还可以使用 perf report 以更友好的方式查看和浏览结果。
# perf report -n --stdio

2.2 内核工作过程
我们简单了解一下 perf 在 内核 中是如何工作的。
perf 采样的过程大致分为两步:一是调用 perf_event_open 系统调用打开一个事件文件;二是通过 read、mmap 等系统调用读取内核采样回来的数据。其整体工作流程如下图所示:

其中,perf_event_open 完成了若干关键工作:
- 创建各种 event 内核对象
- 创建对应的文件句柄
- 指定采样数据的处理回调函数
我们看看它的一些关键执行过程。在 perf_event_open 调用的 perf_event_alloc 函数中,会指定溢出(采样完成)时的处理回调,例如 perf_event_output_backward 或 perf_event_output_forward。
static struct perf_event *
perf_event_alloc(struct perf_event_attr *attr, ...)
{
...
if (overflow_handler) {
event->overflow_handler = overflow_handler;
event->overflow_handler_context = context;
} else if (is_write_backward(event)){
event->overflow_handler = perf_event_output_backward;
event->overflow_handler_context = NULL;
} else {
event->overflow_handler = perf_event_output_forward;
event->overflow_handler_context = NULL;
}
...
}
当 perf_event_open 创建的事件对象开始运行后,硬件上发生的事件就会触发采样。内核注册的相应硬件中断处理函数是 perf_event_nmi_handler。
//file:arch/x86/events/core.c
register_nmi_handler(NMI_LOCAL, perf_event_nmi_handler, 0, “PMI“);
这样,CPU硬件会根据 perf_event_open 时指定的周期发起性能监控中断(PMI),并调用 perf_event_nmi_handler 通知内核进行采样处理。
//file:arch/x86/events/core.c
static int perf_event_nmi_handler(unsigned int cmd, struct pt_regs *regs)
{
ret = x86_pmu.handle_irq(regs);
...
}
该中断处理函数经过 x86_pmu_handle_irq 最终调用到 perf_event_overflow。perf_event_overflow 是一个关键的采样函数,无论是硬件事件还是软件事件采样都会走到这里。它会调用之前在 perf_event_open 时注册的 overflow_handler,我们假设这里用的是 perf_event_output_forward。
void
perf_event_output_forward(struct perf_event *event, ...)
{
__perf_event_output(event, data, regs, perf_output_begin_forward);
}
在 __perf_event_output 中,真正的采样工作开始了。
//file:kernel/events/core.c
static __always_inline int
__perf_event_output(struct perf_event *event, ...)
{
...
// 准备采样数据
perf_prepare_sample(&header, data, event, regs);
// 将数据保存到环形缓冲区中
perf_output_sample(&handle, &header, data, event);
}
如果开启了 PERF_SAMPLE_CALLCHAIN 标志(即我们使用的 -g 参数),那么内核不仅会采集当前正在执行的函数地址(IP寄存器),还会记录下完整的函数调用链。
//file:kernel/events/core.c
void perf_prepare_sample(...)
{
//1.采集IP寄存器,即当前正在执行的函数
if (sample_type & PERF_SAMPLE_IP)
data->ip = perf_instruction_pointer(regs);
//2.采集当前的调用链
if (sample_type & PERF_SAMPLE_CALLCHAIN) {
int size = 1;
if (!(sample_type & __PERF_SAMPLE_CALLCHAIN_EARLY))
data->callchain = perf_callchain(event, regs);
size += data->callchain->nr;
header->size += size * sizeof(u64);
}
...
}
就这样,硬件和 内核 协同工作,完成了函数调用栈的定时采样。随后,perf 工具便可以读取环形缓冲区中的这些数据,进行后续处理。
三、FlameGraph 工作过程
前面我们用 perf script 看到的原始数据是冗长的调用栈列表。
......
59848 main 412201 389052.225443: 676233 cycles:u:
59849 55651b8b5132 caculate+0xd (/data00/home/zhangyanfei.allen/work_test/test07/main)
59850 55651b8b5194 funcC+0xe (/data00/home/zhangyanfei.allen/work_test/test07/main)
59851 55651b8b51d6 main+0x3f (/data00/home/zhangyanfei.allen/work_test/test07/main)
59852 7f8987d6709b __libc_start_main+0xeb (/usr/lib/x86_64-linux-gnu/libc-2.28.so)
59853 41fd89415541f689 [unknown] ([unknown])
......
在绘制火焰图之前,需要对这些数据进行预处理。stackcollapse-perf.pl 脚本的作用就是统计每个唯一调用栈出现的次数,并将整个调用栈压缩为一行。行的前半部分是分号分隔的调用链(从最底层到最顶层),后半部分是该调用栈被采样到的次数。
# perf script | ../FlameGraph/stackcollapse-perf.pl
main;[unknown];__libc_start_main;main;funcA;funcD;funcE;caculate 554118432
main;[unknown];__libc_start_main;main;funcB;caculate 338716787
main;[unknown];__libc_start_main;main;funcC;caculate 4735052652
...
原本 perf script 输出的数万行数据,经过 stackcollapse-perf.pl 预处理后,可能只剩下寥寥数行或数十行,数据量被极大地简化了。在 FlameGraph 项目目录中,你可以看到许多以 stackcollapse 开头的脚本文件。

这是因为不同语言、不同性能分析工具的输出格式各不相同,因此需要不同的预处理脚本来解析。
得到 stackcollapse 处理后的数据,就可以开始画图了。flamegraph.pl 脚本的工作原理是:将每一行数据绘制成一“列”,采样次数越多,该列的宽度就越宽。此外,如果在同一调用层级上出现相同的函数名,它们会被合并。举个例子,假设我们有如下输入数据:
funcA;funcB;funcC 2
funcA; 1
funcD; 1
我们可以手工绘制一个简单的示意图来理解:

其中,funcA 因为在两行记录中都出现了,所以它的宽度是 2+1=3。funcD 只出现一次,宽度为1。funcB 和 funcC 都绘制在 funcA 的上方,由于它们所属的调用栈被采样了2次,因此宽度都为2。
总结
火焰图是分析程序热点函数的利器,是进行 性能优化 的必备技能。我们今天不仅介绍了它的使用方法,更深入剖析了其底层工作原理。火焰图的生成主要分为两大步:采样和渲染。
在采样这一步,核心依赖的是 Linux 内核 提供的 perf_event_open 系统调用。该系统调用在内核中进行了极其复杂的初始化工作,最终与 CPU 硬件协作,通过定时中断来捕获当前执行的函数及其完整的调用链。
在渲染这一步,Brendan Gregg 提供的脚本先对 perf 的原始数据进行聚合和格式化预处理,然后根据处理后的数据渲染成直观的 SVG 图片。函数被采样到的次数越多,在图中对应的矩形就越宽,从而使我们能快速定位 CPU 消耗的瓶颈。
最后需要说明的是,火焰图是基于采样的统计结果,并非百分之百精确的测量,但它足以反映程序的整体性能特征,为我们指明优化方向。如果你想了解更多关于系统级调试和性能优化的知识,欢迎在 云栈社区 与更多开发者交流探讨。