cpu-demo — perf record 实战与火焰图分析全记录
本文是一份真实跑出来的实验记录:在 CentOS 7 云服务器上对
cpu-demo三种场景逐一perf record采样、用 Brendan Gregg 的 FlameGraph 工具生成 SVG 火焰图、回传到本地站点,并逐图讲解怎么读。文中所有数字均为本次实测值(2026-09-08,4 vCPU / 3.7GB 的 CentOS 7 虚拟机)。配套源码:
main.cpp、Makefile(靶子程序说明见 README,perf stat计数分析见 analysis-stat.md)。
一、实验环境与前提
| 项 | 实测值 |
|---|---|
| 系统 | CentOS Linux 7(内核 3.10.0-1160),x86_64 |
| CPU / 内存 | 4 vCPU / 3.7 GB(云虚拟机) |
| 编译器 | g++ (GCC) 4.8.5 |
| perf | perf version 3.10.0-1160(系统自带) |
kernel.perf_event_paranoid | -1(普通用户可对自己的进程采样,无需 root) |
kernel.kptr_restrict | 1(普通用户看不到内核符号名,后文详述影响) |
| 硬件 PMU | 无(虚拟机不暴露 cycles/instructions 硬件事件,perf 自动回退到软件事件 cpu-clock) |
三个环境要点直接决定了后面的图长什么样:
- perf record 通常不需要 root:
perf_event_paranoid=-1(或多数发行版默认的 1/2)时,普通用户就能对自己启动的进程做-p PID采样。本文全部命令以普通用户执行;报权限错时再sudo。 - 内核符号需要 root 或 kptr_restrict=0:本次非 root 录制,模式 2 的内核栈全部显示为
[unknown]和0xffffffff91xxxxxx地址——这本身就是重要教学案例(见 第五节)。 - 虚拟机没有硬件计数器:
perf stat -e cycles会报<not supported>,但perf record默认回退到cpu-clock软件事件(基于高精度定时器的周期采样),做火焰图完全够用。
二、完整复现步骤(本地 → 服务器 → 出图 → 回传)
步骤 1:本地准备源码
实验程序就是本目录的 main.cpp(三种场景:用户态忙循环 / syscall 风暴 / 空闲)。Makefile 默认用 -O0 -g 编译:
cd demos/cpu-demo
make # 产出 cpu_demo(-O0 -g,保留函数符号与行号)
# 注意:Makefile 里是 -std=c++17;在老系统(如 GCC 4.8)上请降到 -std=c++11:
# g++ -O0 -g -std=c++11 -pthread -o cpu_demo main.cpp
-g是火焰图能看到函数名的前提;-O0保证热循环不被优化改写,图与源码一一对应。
步骤 2:上传源码到服务器并编译
# 本地(Windows PowerShell / 任意带 ssh 的终端)
scp -i ~/.ssh/id_ed25519 demos/cpu-demo/main.cpp demos/cpu-demo/Makefile \
<user>@<server>:/tmp/
ssh <user>@<server>
# —— 以下在服务器上 ——
mkdir -p ~/cpu-demo-perf && cp /tmp/main.cpp /tmp/Makefile ~/cpu-demo-perf/ && cd ~/cpu-demo-perf
g++ -O0 -g -Wall -Wextra -std=c++11 -pthread -o cpu_demo main.cpp # O0 版
g++ -O2 -g -Wall -Wextra -std=c++11 -pthread -o cpu_demo_o2 main.cpp # O2 版(名字区分)
ls -la cpu_demo cpu_demo_o2步骤 3:安装 FlameGraph 工具(一次性)
FlameGraph 核心是两个 Perl 脚本:stackcollapse-perf.pl(把 perf script 的原始栈折叠成 a;b;c 次数)和 flamegraph.pl(把折叠栈画成 SVG)。国内服务器直连 GitHub 慢时可用 gitee 镜像:
cd ~
git clone --depth 1 https://github.com/brendangregg/FlameGraph.git
# 若超时:git clone --depth 1 https://gitee.com/mirrors/FlameGraph.git
ls FlameGraph/flamegraph.pl FlameGraph/stackcollapse-perf.pl步骤 4:跑起来 + perf record 采样
靶子程序是交互式的(启动后输入 1/2/3 选场景),用管道喂选项、后台运行:
cd ~/cpu-demo-perf
echo 1 | nohup ./cpu_demo > run_m1_o0.log 2>&1 & # 模式1:用户态忙循环
sleep 2
PID=$(pgrep -x cpu_demo | head -1)
echo "target pid = $PID"
perf record -F 99 -g -o m1_o0.data -p "$PID" -- sleep 20 # 99Hz、带栈、20 秒
kill -9 "$PID"参数含义:
| 参数 | 作用 |
|---|---|
-F 99 | 采样频率 99 次/秒(用 99 而非 100,避免与周期性任务同相位造成偏差) |
-g | 记录调用栈(callchain),火焰图的纵向全靠它 |
-p PID | 只采样目标进程(不加会采全机,噪声大) |
-o m1_o0.data | 采样数据文件 |
-- sleep 20 | 持续 20 秒后自动停止 |
栈展开方式:
-g默认用帧指针(frame pointer)展开。O2 常省略帧指针,栈可能断成一层;此时改--call-graph dwarf(靠-g调试信息,开销略大)或--call-graph lbr(Intel Haswell+ 硬件 LBR,开销最低)。本次 O2 版用默认-g也拿到了完整栈。
同理录另外三组:
echo 1 | nohup ./cpu_demo_o2 > run_m1_o2.log 2>&1 & sleep 2
perf record -F 99 -g -o m1_o2.data -p $(pgrep -x cpu_demo_o2 | head -1) -- sleep 20
pkill -9 -x cpu_demo_o2
echo 2 | nohup ./cpu_demo > run_m2_o0.log 2>&1 & sleep 2 # 模式2:syscall 风暴
perf record -F 99 -g -o m2_o0.data -p $(pgrep -x cpu_demo | head -1) -- sleep 20
pkill -9 -x cpu_demo
echo 3 | nohup ./cpu_demo > run_m3_o0.log 2>&1 & sleep 2 # 模式3:空闲
perf record -F 99 -g -o m3_o0.data -p $(pgrep -x cpu_demo | head -1) -- sleep 20
pkill -9 -x cpu_demo步骤 5:先看文字报告(确认采样有效)
出图之前先用 perf report --stdio 看文字版热点,确认"采到了、符号解析正常":
perf report -i m1_o0.data --stdio --no-children --percent-limit 0.5--stdio:纯文本输出(不加会进交互式 TUI);--no-children:只看函数自身(self)开销,不含它调用的子函数——火焰图顶层每个函数的宽度对应的就是 self 值;--percent-limit 0.5:只显示占比 ≥0.5% 的符号,过滤噪声。
步骤 6:生成火焰图(三步流水线)
FG=~/FlameGraph
for tag in m1_o0 m1_o2 m2_o0 m3_o0; do
perf script -i $tag.data > $tag.perf # 1. data → 文本栈
$FG/stackcollapse-perf.pl $tag.perf > $tag.folded # 2. 折叠成 a;b;c N
$FG/flamegraph.pl --title="cpu-demo $tag (99Hz x 20s)" \
--width=1400 $tag.folded > $tag.svg # 3. 折叠栈 → SVG
done
ls -la *.svg本次实测产出:
| 文件 | 折叠栈条数 | SVG 大小 | 说明 |
|---|---|---|---|
m1_o0.svg | 1 | 16K | 所有样本塌缩为同一条栈(热点极度集中) |
m1_o2.svg | 1 | 16K | O2 把线程包装层内联,栈更短 |
m2_o0.svg | 191 | 72K | 栈形态多样(open/write/close 各路径 + 内核帧) |
m3_o0.svg | 0 | — | 0 个样本,flamegraph.pl 报 No stack counts found |
步骤 7:把图回传到本地站点
火焰图是交互式 SVG(内置 JS,支持点击放大、Ctrl-F 搜索),放进 VitePress 的 public/ 目录即可随站点发布:
# 本地执行:从服务器拉回 SVG + 文字报告(报告留作存档)
mkdir -p public/demos/cpu-demo/flamegraphs
scp -i ~/.ssh/id_ed25519 <user>@<server>:~/cpu-demo-perf/m{1_o0,1_o2,2_o0,3_o0}.svg \
public/demos/cpu-demo/flamegraphs/
scp -i ~/.ssh/id_ed25519 <user>@<server>:'~/cpu-demo-perf/*.report.txt' \
<user>@<server>:'~/cpu-demo-perf/*.stat.txt' \
public/demos/cpu-demo/flamegraphs/public/ 下的文件构建时原样拷进站点根目录,部署后可直接通过 https://<站点域名>/demos/cpu-demo/flamegraphs/m1_o0.svg 访问。Markdown 里这样引用:
想交互(点击缩放 / Ctrl-F 搜索):在浏览器里直接打开 SVG 的 URL;用 Markdown
![]()内嵌时多数渲染器只显示静态图。
三、火焰图怎么看(读图方法论)
火焰图(Flame Graph)是 Brendan Gregg 发明的调用栈可视化,读法固定为四条:
- 横轴 = 采样命中数(≈ 占用 CPU 的时间比例),但不是时间顺序。函数框越宽,说明采样时落在它(及其子调用)栈上的次数越多。相邻框之间没有先后关系,只是按栈序/字母排列。
- 纵轴 = 调用栈深度。底下是调用者(父),上面是被调用者(子):最底部是线程入口,越往上越接近正在执行的叶子函数。真正"在 CPU 上跑"的是最顶层的框(leaf / on-CPU 函数);下面的框只表示"调用链经过它"。
- 看顶部最宽的 plateau(平顶):优化第一目标永远是最宽的顶层框——CPU 真正消耗的地方。宽底座 + 尖顶说明时间分散在很多子调用;宽底座 + 宽平顶说明热点集中。
- 颜色是随机暖色调,没有语义(只有
--color=mem/io等特殊模式才有含义)。判断冷热只看宽度。
交互操作(浏览器直接打开 SVG 时):
- 点击任意框:以该函数为根放大(zoom),看它内部子调用细节;左上角 "Reset Zoom" 返回。
- Ctrl-F(Search):按函数名搜索,匹配框高亮成橙黄色并显示命中总占比——查"某个函数到底吃了多少"最快。
- 鼠标悬停:状态栏显示该函数样本数和百分比。
self(自身)vs total(累计):框的总宽度是 total(含所有子调用);叶子部分是 self。perf report --no-children 给 self,--children(默认)给 total。优化叶子指令改 self;优化架构(减少调用次数、批量化)改 total。
四、模式 1 图详解:用户态忙循环(热点一目了然)
模式 1 的核心代码(main.cpp):
void busy_user_cpu() {
unsigned long sum = 0;
while (true) {
for (unsigned long i = 0; i < 10000000; i++) {
sum += i; // 纯算术,没有任何系统调用
}
}
}20 秒、99Hz 共采到 1979 个样本。perf report --stdio --no-children 的实测输出:
Samples: 1K of event 'cpu-clock'
Event count (approx.): 19989898790
Overhead Command Shared Object Symbol
........ ........ ............. .................
100.00% cpu_demo cpu_demo [.] busy_user_cpu
|
---busy_user_cpu
std::_Bind_simple<void (*())()>::_M_invoke<>
std::_Bind_simple<void (*())()>::operator()
std::thread::_Impl<std::_Bind_simple<void (*())()> >::_M_run
0x7fe519c03330折叠栈(m1_o0.folded 全部内容,只有一行):
cpu_demo;[libstdc++.so.6.0.19];std::thread::_Impl<...>;std::_Bind_simple<...>;...;busy_user_cpu 19989898790图文件:
m1_o0.svg(浏览器打开可点击/Ctrl-F)。
逐元素读图:
- 整根"柱子"从底到顶只有一条调用链:
cpu_demo(进程)→ libstdc++ 的std::thread::_Impl::_M_run(线程入口)→std::_Bind_simple(std::thread(busy_user_cpu)的绑定包装,共三层)→ 顶层busy_user_cpu。 - 最顶层的
busy_user_cpu框占满 100% 宽度:1979 次采样每一次都落在这个函数里。这就是"宽底座 + 宽平顶"的极致形态——热点 100% 集中,没有第二个候选。 - 柱子下面三层 libstdc++ 框宽度也是 100%,但它们不是热点:它们只是"调用链经过"(total 100%、self ≈ 0)。线程启动后这层包装代码早已执行完,采样时 CPU 全部在
busy_user_cpu的循环里。这正说明了为什么要看顶层、看 self。 - 结论与代码完全对应:忙循环里只有寄存器加法和比较跳转,不碰内核、不碰内存(
sum长期待在寄存器),所以用户态 100%。
O0 vs O2 对比(真实编译器行为,纠正旧结论)
图文件:
m1_o2.svg。
O2 版同样采到 1979 个样本,报告仍是 100.00% busy_user_cpu,但折叠栈从 5 层缩成 2 层:
cpu_demo_o2;busy_user_cpu 19989898790| 对比项 | -O0(实测) | -O2(实测,GCC 4.8.5) |
|---|---|---|
| 热点函数 | busy_user_cpu 100% | busy_user_cpu 100%(仍然存在) |
| 调用栈深度 | 5 层(含 3 层 libstdc++ 线程包装) | 2 层(线程包装被内联/消音) |
| 折叠栈条数 | 1 | 1 |
重要更正:旧版文档曾断言"O2 下
sum是死存储,编译器会删除整个循环,采到的是空闲进程"。在本机 GCC 4.8.5 + 当前代码上并没有发生:busy_user_cpu依旧 100% 占满一个核。原因是循环次数达 10^7 且sum跨外层while(true)迭代累加,GCC 在 -O2 下把循环做了强度优化/展开,但保留了循环本身(它无法证明无限循环无副作用可整体删除,且该版本优化器相对保守)。教学结论要改成更准确的表述:"编译器会不会 DCE 掉忙循环"取决于编译器版本、优化等级和代码写法,不能想当然。验证方法就是本文这套流程——改编译选项、重新采样、让火焰图说真话。想强制保留循环可用
volatile unsigned long sum;想观察循环被删,可在更新的 GCC/Clang 上试int main(){ while(1){ int s=0; for(int i=0;i<N;i++) s+=i; } }这类s不逃逸的写法。
五、模式 2 图详解:内核态时间去哪了
模式 2 的核心代码——每轮循环 open/write/close 一个文件:
void busy_kernel_cpu() {
std::string tmp_file = "/tmp/test_kernel_io.tmp";
while (true) {
std::ofstream f(tmp_file, std::ios::app);
f.write("hello\n", 6);
f.close(); // open → write → close 三个系统调用,无限循环
}
}20 秒采到 1957 个样本,但这张图和模式 1 长得完全不一样:
图文件:
m2_o0.svg;文字报告存档m2_o0.report.txt。
读图(按 DSO 汇总的实测占比):
66.02% [unknown] ← 内核态(kallsyms 受限,符号名解析不出来)
3.17% libstdc++.so.6.0.19 [.] __dynamic_cast
1.43% libc-2.17.so [.] __GI___libc_close
1.07% libc-2.17.so [.] _IO_file_fopen
1.07% libstdc++ [.] basic_filebuf::close
0.97% libc [.] _IO_un_link
0.92% libc [.] _int_free / fclose
0.87% libc [.] malloc
...- 折叠栈多达 191 条(模式 1 只有 1 条):图上是一簇高低不一、顶端大量
[unknown]灰框的"火焰"。这说明采样点分散在 open/write/close 三条系统调用路径的不同位置——每次系统调用进内核后走的路径(VFS、ext4、fd 分配、页缓存)都不完全一样。 [unknown]+[k] 0xffffffff91xxxxxx占 66%:这些就是内核态样本。地址0xffffffff91...是典型的 x86_64 内核空间地址。因为本次以普通用户录制、kptr_restrict=1,perf 读不到/proc/kallsyms的符号表,无法把地址翻译成__x64_sys_write、ext4_file_write_iter这样的函数名(报告开头也明确打印了Kernel address maps ... were restricted)。- 要看懂内核帧:用 root 重录(
sudo perf record ...)或sudo sysctl -w kernel.kptr_restrict=0,最好再装内核 debuginfo(CentOS:debuginfo-install kernel),灰框就会变成具名内核函数。
- 要看懂内核帧:用 root 重录(
- 用户态那 ~34% 也不是"业务计算":
__dynamic_cast、fopen/fclose、malloc/_int_free、basic_filebuf::close——全是 C++ iostream/std::ofstream每轮构造/析构流对象带来的开销(RTTI 检查、内存分配释放)。 - 程序自报的 CPU 拆分也印证了这一点:运行日志里
stime(内核态 jiffies)增长明显快于utime,采样窗口内自报 CPU 使用率约 25%(单核口径,4 核机器上 ≈ 满一个核)。
诊断结论与优化方向:这个场景的瓶颈不是某条算术指令,而是系统调用次数 + 每轮重复的对象构造。火焰图指导的优化是结构性的:
- 批量化 IO:不要每 6 字节 open/write/close 一次;打开一次文件、循环内只 write、最后 close——系统调用密度直接降几个数量级;
- 复用流对象:
std::ofstream移到循环外,避免每轮构造/析构 + malloc/free + RTTI; - 加大写入粒度 / 用缓冲:用户态攒够一块再 write。
这类"宽底座、尖顶分散、大半 [k]/[unknown]"的图,是 syscall-heavy / 内核态热点程序的典型长相,和模式 1 的"一根柱子"形成对照。
六、模式 3:采不到样本本身就是答案
模式 3 是 sleep(100ms) 循环的空闲任务。20 秒录制结束后:
perf script输出 0 条样本;stackcollapse产出空文件;flamegraph.pl直接报错No stack counts found(生成的m3_o0.svg是空图)。- 原因:
perf record -F 99按 CPU 周期/定时器采样,只在进程"正在 CPU 上运行"时才采得到。睡眠中的进程不在 CPU 上,自然没有 on-CPU 样本。
用 perf stat 量化(15 秒窗口实测):
# 模式3(空闲)
4.80 msec task-clock # 0.000 CPUs utilized
164 context-switches # 0.034 M/sec
1 cpu-migrations
0 page-faults
# 对照:模式1(忙循环)
15,002.30 msec task-clock # 1.000 CPUs utilized
21 context-switches # 0.001 K/sec
4 cpu-migrations
0 page-faults15 秒墙钟里空闲任务只占了 4.8 毫秒 CPU(0.03%),其余时间都在睡眠(被调度器踢出 CPU,所以上下文切换 164 次 vs 忙循环的 21 次)。
教学点:on-CPU 火焰图(perf record 默认)只能回答"CPU 时间花在哪";对于"线程为什么慢、在等什么"(IO 等待、锁等待、sleep),需要 off-CPU 分析(perf record -e sched:sched_switch 或 eBPF/bpftrace、--color=io 的 off-CPU 火焰图)。空闲/阻塞型程序采不到 on-CPU 样本,不是工具坏了,而是它根本不在 CPU 上。
七、三模式实测对比总表
| 模式1 用户态忙循环 | 模式2 syscall 风暴 | 模式3 空闲 | |
|---|---|---|---|
| 20s 采样数 | 1979 | 1957 | 0 |
| 折叠栈条数 | 1 | 191 | 0(空图) |
| 顶层热点 | busy_user_cpu 100% | 66% 内核 [unknown] + libc/libstdc++ IO 开销 | 无(在睡眠) |
| task-clock(15s) | 15,002 ms(1.000 核) | ~满 1 核(自报 ~25%,单核口径) | 4.8 ms(0.000 核) |
| 上下文切换 | 21 | 频繁(open/close 路径) | 164 |
| 火焰图形态 | 一根满宽柱子 | 宽底座 + 尖顶分散 + 大量内核帧 | 空 |
| 优化方向 | 优化/向量化热循环 | 减少 syscall、批量 IO、复用流对象 | 无需优化(本就该睡) |
八、常见问题与排障
| 现象 | 原因 | 处理 |
|---|---|---|
栈只有一层 / 一堆 [unknown](用户态) | 没编 -g;或 O2 省略帧指针 | 加 -g 重编;用 --call-graph dwarf 或 -fno-omit-frame-pointer |
内核帧全是 [k] 0xffffffff... / [unknown] | kptr_restrict=1 且非 root | sudo 重录,或 sudo sysctl -w kernel.kptr_restrict=0;装 kernel debuginfo |
cycles/instructions 报 <not supported> | 虚拟机/容器无硬件 PMU | 火焰图不受影响(回退 cpu-clock 软件事件);要硬件计数需在裸机或支持 vPMU 的环境 |
| 目标进程 0 样本 | 进程在睡眠/阻塞(off-CPU) | 这是正常结论;改用 off-CPU 分析(sched_switch / eBPF) |
perf record 权限报错 | perf_event_paranoid 过高 | sudo sysctl -w kernel.perf_event_paranoid=1(或 -1);或直接 sudo 录制 |
脚本上传后报 set: -\r: invalid option | Windows CRLF 行尾 | sed -i 's/\r$//' script.sh 后再跑 |
g++ 报 unrecognized -std=c++17 | 老编译器(GCC 4.x) | 降到 -std=c++11(本程序 c++11 特性即可) |
| 火焰图函数名是 C++ 乱码符号 | 未 demangle | stackcollapse-perf.pl 默认 demangle;也可 `perf script |
一句话总结:
perf record -F 99 -g -p PID采调用栈 →perf report --stdio核对文字热点 →perf script | stackcollapse-perf.pl | flamegraph.pl出 SVG → 看最宽的顶层框定位 CPU 热点;内核帧变[unknown]是权限问题、空闲进程 0 样本是 off-CPU 问题——图长成什么样,本身就是诊断信息。完整链路本文已在真实服务器上逐命令跑通,产物 SVG 见public/demos/cpu-demo/flamegraphs/。