找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2788

积分

0

好友

390

主题
发表于 3 小时前 | 查看: 0| 回复: 0

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

CPU性能火焰图示例

今天,我们就来深入探讨火焰图的使用方法,并抽丝剥茧地理解其背后的工作原理。

一、火焰图的使用

为了更好地阐释火焰图的原理,我专门编写了一段演示代码:

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 函数调用了 funcAfuncBfuncC,而 funcA 又调用了 funcDfuncE。最关键的是,这些函数的耗时大头并非自身逻辑,而是它们都调用的一个 CPU 密集型函数 caculate。整个系统的调用栈耗时分布一目了然。

如果要对这个项目进行性能优化,我们该看哪里?虽然图中 funcAfuncB 等函数的柱子很长,但它们的耗时都“贡献”给了子函数。我们真正应该关注的是火焰图最顶部那些又长又平的“山头”,比如这里的 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

perf report 输出示例

2.2 内核工作过程

我们简单了解一下 perf内核 中是如何工作的。

perf 采样的过程大致分为两步:一是调用 perf_event_open 系统调用打开一个事件文件;二是通过 readmmap 等系统调用读取内核采样回来的数据。其整体工作流程如下图所示:

perf 采样内核架构图

其中,perf_event_open 完成了若干关键工作:

  • 创建各种 event 内核对象
  • 创建对应的文件句柄
  • 指定采样数据的处理回调函数

我们看看它的一些关键执行过程。在 perf_event_open 调用的 perf_event_alloc 函数中,会指定溢出(采样完成)时的处理回调,例如 perf_event_output_backwardperf_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_overflowperf_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 脚本

这是因为不同语言、不同性能分析工具的输出格式各不相同,因此需要不同的预处理脚本来解析。

得到 stackcollapse 处理后的数据,就可以开始画图了。flamegraph.pl 脚本的工作原理是:将每一行数据绘制成一“列”,采样次数越多,该列的宽度就越宽。此外,如果在同一调用层级上出现相同的函数名,它们会被合并。举个例子,假设我们有如下输入数据:

funcA;funcB;funcC 2
funcA; 1
funcD; 1

我们可以手工绘制一个简单的示意图来理解:

火焰图合并原理示意图

其中,funcA 因为在两行记录中都出现了,所以它的宽度是 2+1=3。funcD 只出现一次,宽度为1。funcBfuncC 都绘制在 funcA 的上方,由于它们所属的调用栈被采样了2次,因此宽度都为2。

总结

火焰图是分析程序热点函数的利器,是进行 性能优化 的必备技能。我们今天不仅介绍了它的使用方法,更深入剖析了其底层工作原理。火焰图的生成主要分为两大步:采样和渲染。

在采样这一步,核心依赖的是 Linux 内核 提供的 perf_event_open 系统调用。该系统调用在内核中进行了极其复杂的初始化工作,最终与 CPU 硬件协作,通过定时中断来捕获当前执行的函数及其完整的调用链。

在渲染这一步,Brendan Gregg 提供的脚本先对 perf 的原始数据进行聚合和格式化预处理,然后根据处理后的数据渲染成直观的 SVG 图片。函数被采样到的次数越多,在图中对应的矩形就越宽,从而使我们能快速定位 CPU 消耗的瓶颈。

最后需要说明的是,火焰图是基于采样的统计结果,并非百分之百精确的测量,但它足以反映程序的整体性能特征,为我们指明优化方向。如果你想了解更多关于系统级调试和性能优化的知识,欢迎在 云栈社区 与更多开发者交流探讨。




上一篇:干货分享:2026年如何用终端提升生产力?WezTerm、tmux、zsh配置心法
下一篇:大厂视角:为什么说未来只有AI Agent工程师?看看这些真实案例
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-29 21:58 , Processed in 0.265682 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表