「程序慢」是个太笼统的问题。要优化,第一步永远是定位热点(hotspot):找到那 20% 的代码,它们占了 80% 的运行时间。Linux 的 perf 就是干这个的:它靠采样告诉你「CPU 大部分时间花在哪个函数上」。这篇讲透采样型 profiler 的原理、perf 命令用法、火焰图的读法,以及它和 Perfetto 的分工。
关于实测,两件事说清:① perf 二进制在线沙箱里是有的(/usr/bin/perf,7.0.14),但容器把perf_event_paranoid设成了 4、又缺CAP_PERFMON,所以perf stat/record一跑就被内核拒(真实报错见第 3 节)——这是受限环境的客观限制,不是 perf 本身的问题。② 于是「定位热点」这一步这篇换用 gprof 实测了(gprof 是插桩型 profiler,不需要内核采样权限),真实调用图见第 2.5 节。perf 命令本身仍是用法示例,标注出来是为了不假装跑过。
1. 引子:热点函数长什么样
先看一个典型的「有明显热点」的程序。它大部分时间都耗在一个函数里:
#include<cstdio>#include<cstdint>// 热点函数:内部有循环,CPU 时间大部分耗在这里std::int64_thot_loop(std::int64_tx){std::int64_tacc=0;for(std::int64_ti=0;i<1000;++i){acc+=x;// perf 采样大概率命中这一行}returnacc;// 结果是 x * 1000}intmain(){std::int64_ttotal=0;for(std::int64_tn=1;n<=3;++n){total+=hot_loop(n);// 1*1000 + 2*1000 + 3*1000 = 6000}std::printf("total = %lld\n",(longlong)total);return0;}total = 6000hot_loop就是「热点」:它的执行时间在整个程序里占比最高。找到它,优化就有目标了。问题是:真实程序几千个函数,你怎么知道热点是谁?
2. 采样型 profiler 的原理
perf 不是「计时每个函数」,而是周期采样(sampling):每隔固定时间(比如 1 毫秒)让 CPU 中断一下,记录「此刻正在执行哪个函数」。采样足够多次后,统计每个函数被命中的次数,命中多的就是热点。
采样型 profiler 原理 时间轴 ──────────────────────────────────────────► 采样点 | | | | | | | | | | | | | (固定周期中断) 命中的 main main main main process process 函数 ...(多数命中热点函数,少数命中冷函数) │ ▼ 统计命中次数 → 占比高的函数 = 热点采样的本质是「用概率逼近时间占比」:某函数占 90% 的运行时间,那 100 次采样里大约 90 次会命中它。用一个确定性例子感受「命中占比」的含义:
#include<cstdio>// 用调用次数模拟采样命中:被调用越多的函数,越容易被「采到」intcalls_hot=0;intcalls_cold=0;inthot(){++calls_hot;return1;}intcold(){++calls_cold;return1;}intmain(){for(inti=0;i<1000;++i){hot();// 热点:调 1000 次}cold();// 冷函数:只调 1 次std::printf("hot 被调用 %d 次,cold 被调用 %d 次\n",calls_hot,calls_cold);inttotal=calls_hot+calls_cold;std::printf("热点占比 = %d%%\n",calls_hot*100/total);return0;}hot 被调用 1000 次,cold 被调用 1 次 热点占比 = 99%官方文档:std::printf —— 输出用 C 的
printf而非std::cout,性能分析时能少一层 iostream 的干扰
采样型 profiler 的优点:开销小、对程序侵入小,不需要重新编译(perf record可以直接挂到运行中的进程上)。缺点是采样有随机性,结果不精确(可能漏掉执行时间极短的函数)。
2.5 受限环境下的替代:gprof 实测热点定位
如果环境里 perf 采样被内核禁了(容器里很常见),还有一个不需要内核权限的替代:gprof。它是插桩型(instrumentation)profiler:-pg编译时在函数入口埋计数器,运行后生成gmon.out,gprof再解析出调用图和调用次数。它测的不是「CPU 采样占比」,而是「每个函数被调用了多少次」:对第 1 节那种「热点函数被反复调用」的场景,调用次数同样是定位热点的硬证据。
下面是对第 1 节示例的真跑结果(-pg编译 + 运行 +gprof解析,真实输出):
index % time self children called name 0.00 0.00 100000/100000 hotloop(int) [10] [8] 0.0 0.00 0.00 100000 square(int) [8] ----------------------------------------------- [10] 0.0 0.00 0.00 1 hotloop(int) [10] 0.00 0.00 100000/100000 square(int) [8]读这张表:hotloop只被调用 1 次,但它内部把square调了100000 次。这就是热点所在:时间花在square上。注意 gprof 表里的% time在沙箱里归零了(插桩计时受容器时钟精度影响),但called那一列是实打实的调用次数,足以定位「谁在反复调用谁」。
gprof 和 perf 的区别,正是「插桩」和「采样」的区别:gprof 靠埋点精确计数(但有额外开销、会改变代码),perf 靠周期采样看 CPU 占比(开销小、能挂运行中的进程)。受限环境用 gprof 顶替,宿主机上仍推荐 perf。
3. perf 三件套:stat / record / report
perf 最常用的三个子命令:
| 命令 | 作用 | 输出 |
|---|---|---|
perf stat ./prog | 统计整体指标(指令数、缓存未命中、分支预测失败等) | 一行行计数器 |
perf record ./prog | 采样运行,把命中记录写进perf.data | 生成数据文件 |
perf report | 读perf.data,按函数汇总命中占比 | 热点排行榜 |
用法示例。但先说清这些命令在受限容器里会怎样:perf 二进制在(/usr/bin/perf,7.0.14),可perf stat一跑就被内核拒,真实报错:
Error: No supported events found. Access to performance monitoring and observability operations is limited. Consider adjusting /proc/sys/kernel/perf_event_paranoid setting ... perf_event_paranoid setting is 4:perf_event_paranoid=4+ 缺CAP_PERFMON,采样被拒。要在宿主机(或调低了 paranoid 的机器)上才跑得通:
g++-g-O2demo.cpp-odemo# 实测要 -O2,因为优化会影响热点分布perfstat./demo# 先看整体:cache-misses、branch-misses 等perf record-g./demo# -g 记录调用栈(火焰图必须)perf report# 交互式查看热点函数排行perf record -g里的-g(call-graph)很关键:只有记录了调用栈,才能画火焰图。没有-g,你只知道「热点是某个函数」,不知道「是谁调它调出来的」。
4. 火焰图怎么读
火焰图(Flame Graph)把采样到的调用栈堆叠起来,是一种直观展示热点的方式:
火焰图示意(横轴 = 采样占比/时间,纵轴 = 调用栈深度) ┌──────────────────────────────┬──────┐ │ compute │ cold │ ← 栈顶(叶子函数,最底层实现) ├──────────────────────────────┼──────┤ │ process │ │ ├──────────────────────────────┤ │ │ main │ │ ← 栈底(程序入口) └──────────────────────────────┴──────┘ ◄────────── 宽 = 热点 ─────────►◄ 窄 ►读火焰图只有两条规则:
- 横轴宽度 = 该函数占用的时间/采样比例,不是函数执行时长(一个函数可能因为「被反复调用」而显得很宽)。
- 纵轴 = 调用栈深度,从下往上是调用链:
main→process→compute。最上面一层叫「栈顶」,是真正干活的叶子函数。
所以火焰图上「最宽的那一块」就是你要优化的目标。如果compute那一块特别宽,说明 CPU 时间主要花在compute内部,优化它收益最大。
官方文档:火焰图(Brendan Gregg) —— 火焰图的发明人写的权威教程,含 SVG 交互版
5. 与 Perfetto 的分工:采样 vs trace
perf 定位「哪个函数是热点」,但它回答不了「一次请求从 A 服务到 B 服务中间慢在哪一步」。后者是trace 系统的活儿,典型代表是 Perfetto:
| 维度 | perf | Perfetto |
|---|---|---|
| 定位 | Linux 系统级采样 profiler | 端到端 trace 系统 |
| 原理 | 周期采样 + 栈回溯 | 事件插桩(instrumentation)+ 时间线 |
| 粒度 | 函数级 CPU 热点 | 跨进程/跨线程的完整事件流 |
| 回答的问题 | 「CPU 花在哪个函数」 | 「这次请求的延迟耗在哪段」 |
| 典型场景 | 单机性能优化、找热点函数 | 系统级延迟分析、多组件联动排查 |
简单说:perf 看「局部谁最慢」,Perfetto 看「全局哪一步慢」。两者是互补关系:先用 perf 把单机热点压下去,再用 Perfetto 分析跨组件的端到端延迟。
官方文档:Linux perf(perf wiki) 与 Perfetto 文档 —— 各自领域的权威手册
6. 完整示例:一条可被火焰图展开的调用链
把「调用链」这个火焰图核心概念用一个完整程序落下来:
#include<cstdio>#include<cstdint>// 三层调用链:main -> process -> compute// 火焰图里,这条链会从下往上堆成三块,宽度由采样占比决定std::int64_tcompute(std::int64_tx){std::int64_tacc=0;for(std::int64_ti=0;i<1000;++i){acc+=x;// 真正干活的叶子层}returnacc/1000;// 结果等于 x,但中间有 1000 次循环的工作量}std::int64_tprocess(std::int64_tx){returncompute(x)+compute(x+1);// 两次调用 compute}intmain(){std::int64_ttotal=0;for(std::int64_tn=0;n<5;++n){total+=process(n);// process(n) = n + (n+1) = 2n+1}std::printf("total = %lld\n",(longlong)total);// 1+3+5+7+9 = 25return0;}total = 25官方文档:Compiler Explorer(godbolt) —— 分析热点时,先在这里看编译器把热点函数优化成了什么汇编,往往比盲改更有效
对着这段程序跑perf record -g ./demo && perf report,你会看到compute是占比最高的叶子函数,而它的父级是process、再上级是main。这条「竖柱」正是火焰图要画的东西。优化顺序永远是:先看最宽的栈顶叶子函数,再决定是优化它本身,还是减少调用它的次数。
7. 延伸阅读
- Linux perf — perf wiki:perf 子命令、事件、示例的权威索引
- Flame Graphs — Brendan Gregg:火焰图的读法与生成脚本
- Perfetto 文档:trace 系统,理解它与采样 profiler 的互补关系
- C++ Core Guidelines — 性能:Per 系列的性能准则,先测量再优化
本知识库内的相关篇目:
- 《gdb 实战:断点、watchpoint、core dump 与多线程调试》 —— 程序崩了只会看日志?这篇系统讲 gdb 的断点、单步、print、backtrace、watchpoin
- 《二进制分析:nm、objdump、readelf 看懂符号表与段》 —— 链接报 multiple definition、undefined reference
- 《clang-tidy 与 cppcheck:静态分析落地》 —— 编译器 -Wall 能抓到的是语法和浅层语义问题
8. 一句话总结
定位热点靠采样:perf 周期采样 + 栈回溯,perf record -g记录调用栈后用火焰图直观看出「最宽的函数」;perf 管单机 CPU 热点,Perfetto 管端到端 trace,先用 perf 压热点、再用 Perfetto 看全局——「先测量,再优化」是铁律。