perf火焰图实战
# 第24章:perf 火焰图实战
# 目录介绍
- 1. 案例引入:两条主线
- 2. 性能问题九种成因
- 3. perf 在排障链定位
- 4. perf 命令体系
- 5. 采样原理与精度
- 6. 火焰图三步法
- 7. 火焰图怎么读
- 8. 高级火焰图变体
- 9. 符号与离线分析
- 10. 五步定位方法论
- 11. 典型场景速查
- 12. 工程化最佳实践
- 13. 综合案例串讲
# 1. 案例引入:两条主线
性能问题从来不能"靠猜"。本篇用两条真实主线贯穿全文:一条来自生产环境凌晨告警,一条来自一个看似无害的小函数。前者展示"CPU 100% 但 top 看不出谁吃的"的全链路定位,后者展示"性能回归没改算法,只是改了一行代码"的反直觉破案。
# 1.1 主线一:CPU 100% 找不到凶手
某搜索后端服务,周二上午 10:30 流量高峰,单机 CPU 100%、QPS 从 8 千掉到 2 千。SRE 第一反应——top:
$ top -p $(pidof search-svc)
PID USER PR NI VIRT RES SHR S %CPU %MEM
24891 search 20 0 12.4g 8.2g 43210 R 798.4 52.0 ← 八个核全打满
2
3
进程 CPU 798%(8 核全打满),但 top 不能告诉你进程内部哪个函数在跑。pstack 看了几次:
$ pstack 24891
Thread 1: ...QueryParser::parse...
Thread 2: ...SearchEngine::search...
Thread 3: ...IndexReader::read...
2
3
4
每次抓到的栈都不一样——采样太稀疏,看不出热点。直觉上怀疑了几个嫌疑:
- 是不是 IndexReader 有死循环?看了代码没发现;
- 是不是 QueryParser 算法变复杂了?git log 最近一周没改它;
- 是不是某个查询攻击触发了正则爆炸?日志里没异常 query。
线索断了——直到上 perf:
$ sudo perf top -p 24891
42.3% search-svc std::__cxx11::basic_string<...>::basic_string
18.7% search-svc std::_Hashtable<...>::find
9.2% search-svc malloc
6.1% search-svc free
4.4% search-svc __memcpy_avx_unaligned
...
2
3
4
5
6
7
42% 的 CPU 花在 std::string 构造上——一行业务代码都没看到。生成火焰图后真相水落石出:所有热路径最后都汇聚到 QueryContext::clone(),里面有一个看似无害的 std::map<std::string, std::string> 深拷贝——每次请求都拷贝整张配置表,配置表 5000 条、每条 200 字节,单次请求拷贝 1MB 字符串。
QPS 8 千 × 1MB = 8GB/s 内存分配/拷贝,单机 80GB/s 内存带宽的 10%——CPU 不忙才怪。
修复:拷贝改为 shared_ptr<const Config>,CPU 立刻从 798% 降到 220%,QPS 回到 9.5 千(比之前还高,因为内存带宽不再被吃满)。
这就是 perf 的核心价值——top 告诉你"进程忙",perf 告诉你"进程在哪个函数里忙"。
# 1.2 主线二:QPS 莫名腰斩
另一位同学发来求助:
"我就改了一行代码,把
vector<int>换成vector<MyStruct>,性能从 5 万 QPS 掉到 2.3 万——MyStruct才 24 字节啊。"
代码长这样:
// hot_path.cpp —— 热路径循环, 每秒被调几千万次
struct MyStruct { // 24 字节
int id; // 4
int type; // 4
double weight; // 8
double score; // 8
};
// 改之前
std::vector<int> ids = ...; // 100 万 int
int sum = 0;
for (int i : ids) sum += compute(i); // 5 万 QPS
// 改之后
std::vector<MyStruct> items = ...; // 100 万 MyStruct
int sum = 0;
for (const auto& item : items)
sum += compute(item.id); // 2.3 万 QPS ⚠️
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
直觉怀疑:MyStruct 比 int 大 6 倍,所以慢 6 倍?但实际只用了 item.id 这 4 字节字段——理论上 cache prefetcher 应该应付得了。
perf stat 一跑,真相裂开:
$ perf stat -e cycles,instructions,cache-misses,cache-references ./bench
# 改之前 (vector<int>)
3,421,556,221 cycles
8,109,234,556 instructions (2.37 IPC)
12,883,402 cache-misses (3.2% of refs)
# 改之后 (vector<MyStruct>)
8,994,121,003 cycles ← 2.6x cycles
8,142,883,221 instructions (0.91 IPC) ← IPC 暴跌
298,776,883 cache-misses (62.4%) ← cache miss 翻 23 倍!
2
3
4
5
6
7
8
9
10
11
指令数没变,但 cycles 翻 2.6 倍——CPU 大部分时间在等内存。MyStruct 24 字节,64 字节的 cache line 只能装 2.66 个;vs int 一个 cache line 装 16 个。遍历 100 万元素:
| 类型 | 一次 cache miss 的访问数 | 总 miss 次数 |
|---|---|---|
vector<int> | 16 | 62,500 |
vector<MyStruct> | 2.66 | 376,000 |
差 6 倍——和 perf 报的 cache miss 差 23 倍基本吻合(剩下的差距来自 prefetcher 失效后的 TLB miss)。
两个现象非常扎眼:
- 指令数几乎不变 —— 业务逻辑没变;
- cycles 翻 2.6 倍 —— 是访存开销,不是计算开销。
好的性能调试,第一步永远不是改代码,而是用 perf stat 看"瓶颈是 CPU 还是内存"——前 3 秒就能决定后续 30 分钟的方向。
# 1.3 顺藤摸到根因
带着两条主线往下挖,至少藏着这些原理点:
① 性能问题有几大类? 怎么选采样工具? → 第 2、3 章
② perf 到底有几个子命令, 各自什么场景? → 第 4 章
③ -F 99 是什么意思? 为什么不是 100、不是 999? → 第 5.2 节
④ 火焰图横宽到底代表什么? 时间还是次数? → 第 7.1 节
⑤ 火焰图全是 [unknown] 怎么救? → 第 9.1 节
⑥ on-CPU 看了没问题, 为什么响应还是慢? → 第 8.3、11.7 节
⑦ 怎么把 perf 接到 CI / 持续 profiling? → 第 12 章
2
3
4
5
6
7
# 1.4 本篇要回答什么
| 层次 | 你将学到 |
|---|---|
| 原因层 | 9 种性能问题成因,从算法到访存到锁 |
| 工具层 | perf 子命令体系:top/record/stat/script/trace 的差异与组合 |
| 原理层 | PMU 采样、栈回溯、IPC、cache miss、off-CPU 的本质 |
| 火焰图层 | 横宽 vs 纵高、平顶 vs 尖塔、差分 vs 反转 vs off-CPU |
| 工程层 | 持续 profiling、性能门禁、成熟度模型 |
📌 本篇定位:这是排查篇的第五篇。前四篇(信号崩溃 / ASan / GDB / Core Dump)解决"代码崩了怎么办"——本篇解决"代码不崩但慢"。调试器问"是谁杀了我",profiler 问"是谁拖死了我"。读完本篇,再看任何"CPU 100%、QPS 掉一半、延迟尖刺",都能立刻回答:"用哪个 perf 子命令、采多久、看火焰图哪一段"。
# 2. 性能问题九种成因
进入 perf 之前,先把"代码上能写出哪几类性能问题"列清楚。下面 9 种覆盖了 95% 的实战场景。
# 2.1 算法复杂度爆炸
最经典的——O(n²) 在小数据下表现尚可,数据一上量就崩:
// 找重复元素 O(n²)
for (int i = 0; i < items.size(); ++i)
for (int j = i + 1; j < items.size(); ++j)
if (items[i] == items[j]) ...
// 改 O(n) 用 hashset
std::unordered_set<Item> seen;
for (auto& it : items)
if (!seen.insert(it).second) ...
2
3
4
5
6
7
8
9
底层观察:火焰图上看到一个很宽的"金字塔"——某个函数和它的内部循环占据 50%+ 横向宽度。perf stat 看 IPC 一般正常(1.5+),instructions 数量级远超合理值。
# 2.2 频繁内存分配
主线一就是这一类。new / malloc 看似 O(1),但在多线程高 QPS 下会撞 glibc tcache 锁、tlb miss、内存碎片:
// 每次循环 new + delete
void hot_loop() {
for (...) {
auto* tmp = new Buffer(1024); // 每次堆分配
process(tmp);
delete tmp;
}
}
// 改用对象池或栈对象
thread_local std::array<char, 1024> tmp;
for (...) process(tmp.data());
2
3
4
5
6
7
8
9
10
11
12
底层观察:火焰图最顶有大量 malloc / free / _int_malloc / tcache_get。perf top 显示这几个函数排前 3。
# 2.3 锁竞争
std::mutex global_mtx;
std::map<int, Data> cache;
Data get(int k) {
std::lock_guard g(global_mtx); // 16 个线程在这里排队
return cache[k];
}
2
3
4
5
6
7
底层观察:on-CPU 火焰图上 __pthread_mutex_lock / __lll_lock_wait 占大头;perf stat -e contention-rate 高;线程数加倍但 QPS 几乎不变。off-CPU 火焰图能直接看到"线程在等什么锁"。
# 2.4 cache miss
主线二就是这一类——数据布局影响访存效率:
struct Bad { // 88 字节, 不友好
char name[64]; // 大字段在前
int id; // 真正用的字段在后
int tag;
double weight;
};
// 热路径只用 id, 但要从内存读 88 字节
for (auto& x : items) total += x.id;
2
3
4
5
6
7
8
9
底层观察:perf stat 的 cache-misses 占比 > 5%、IPC < 1.0 是高度怀疑信号。
# 2.5 系统调用过多
// 每次写日志都 write
for (auto& msg : messages)
write(fd, msg.c_str(), msg.size()); // 数百万次系统调用
// 改用缓冲 + 批量写
std::string buf;
for (auto& msg : messages) buf += msg;
write(fd, buf.data(), buf.size());
2
3
4
5
6
7
8
底层观察:火焰图上有大段用户态 + 内核态混合栈(看到 entry_SYSCALL_64 / do_syscall_64),sys% 在 top 里很高。
# 2.6 上下文切换
线程数远多于 CPU 核数 → 每次切换 5~10μs,频繁切换吃掉大量周期:
$ vmstat 1
r b swpd free buff cache si so bi bo in cs us sy id wa
8 0 0 4.2g 102m 1.2g 0 0 0 0 3812 184523 62 31 7 0
↑↑↑↑↑↑
184k cs/s, 远超合理值 (< 50k)
2
3
4
5
底层观察:perf stat -e context-switches 异常高;top 看 sy% > 20%。
# 2.7 IO 阻塞
最隐蔽——CPU 不忙、QPS 也不高、p99 延迟却很大:
auto data = read_file("/data/big.bin"); // 阻塞 50ms
process(data);
2
底层观察:on-CPU 火焰图很短(CPU 大多时间空闲),但 off-CPU 火焰图显示线程长时间停在 read / epoll_wait / futex_wait。这是 on-CPU profiler 的盲区。
# 2.8 字符串拷贝
std::string 深拷贝、临时对象、隐式转换——单次几十纳秒,热路径上累积惊人:
void log_request(std::string id, std::string path) { // 值传递
log_db_.write(id + " " + path); // 临时 string
}
// 改: const std::string& + std::string_view
void log_request(std::string_view id, std::string_view path) {
log_db_.write(std::format("{} {}", id, path));
}
2
3
4
5
6
7
8
底层观察:火焰图上 std::string::string / std::string::operator+ / std::string::~string 各占 5%~10%——加起来吓人。
# 2.9 编译优化未开
最尴尬——上线前才发现 release 是 -O0 编译的:
# 看二进制是否优化
$ objdump -d ./svc | grep -A1 'main:' | head
...
mov %rdi, -0x18(%rbp) ← O0 特征: 频繁存取栈
mov %rsi, -0x20(%rbp)
...
2
3
4
5
6
底层观察:perf stat IPC < 0.5、instructions 数量比预期翻几倍、火焰图深度异常(O0 不内联)。
| 成因 | 主要 perf 信号 | 工具 |
|---|---|---|
| 算法复杂度 | 火焰图金字塔 + instructions 高 | perf record + 火焰图 |
| 内存分配 | malloc/free 在火焰图顶部 | perf record + heaptrack |
| 锁竞争 | lock_wait 占大头 / off-CPU 长 | perf record + off-CPU 火焰图 |
| cache miss | IPC < 1.0, cache-misses 高 | perf stat + perf c2c |
| 系统调用 | 内核态栈帧多 / sys% 高 | perf trace |
| 上下文切换 | cs > 100k/s | perf sched |
| IO 阻塞 | on-CPU 短 + off-CPU 长 | off-CPU 火焰图 |
| 字符串拷贝 | string ctor/dtor 在火焰图顶部 | 火焰图 |
| 优化未开 | IPC 极低 + 深度异常 | perf stat |
# 3. perf 在排障链定位
# 3.1 用什么工具找瓶颈
性能排查工具按"看到什么粒度"分级:
┌─────────────────────┐
粒度大 │ top / vmstat / sar │ 整机级: CPU/内存/IO/网络百分比
├─────────────────────┤
│ pidstat / pstack │ 进程级: 进程 CPU/线程栈快照
├─────────────────────┤
│ perf top / record │ 函数级: 哪个函数热, 调用栈汇总 ← 本篇主战场
├─────────────────────┤
│ perf stat │ 事件级: cycles/IPC/cache miss
├─────────────────────┤
│ perf annotate │ 指令级: 哪条汇编最热
粒度细 │ Intel VTune / pmu │ 微架构级: pipeline 阻塞分类
└─────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
经验法则:从粗到细往下走——top → perf top → 火焰图 → perf annotate → VTune。每一层都先排除一类问题再进下一层,不要直接从 perf annotate 开始。
# 3.2 采样 vs 计数 vs tracing
| 方式 | 工具 | 原理 | 适合 | 代价 |
|---|---|---|---|---|
| 采样 (sampling) | perf record | 定时中断,取栈 | 找 CPU 热点函数 | 开销 < 1% |
| 计数 (counting) | perf stat | 累加 PMU 计数器 | 量化"花在啥事件上" | 开销 ~ 0% |
| tracing | perf trace / bpftrace | 每个事件触发一次 | 看具体每次发生 | 开销 5%~50% |
别混用:找谁慢用采样,量化用计数,看具体事件链用 tracing。
# 3.3 perf 的硬件基础
perf 不是软件层面"反复读内存"——它的核心是 CPU 提供的 PMU(Performance Monitoring Unit):
CPU 内每个核都有几个特殊寄存器:
├─ Fixed Counters (Intel: 3-4 个固定计数器)
│ cycles, instructions retired, ref-cycles
└─ Programmable Counters (Intel: 4-8 个可编程)
可绑定任何 PMU 事件: cache miss, branch miss, ...
每发生一次目标事件 → 计数器 +1
计数器溢出 → CPU 抛出一个特殊中断 (PMI, Performance Monitoring Interrupt)
内核 perf 子系统 → 截取 PMI → 记录此刻的 PC + 调用栈 → 写入环形 buffer
用户空间 perf 工具 → 读取 buffer → 落到 perf.data
2
3
4
5
6
7
8
9
10
理解这层有几个推论:
- 采样开销极低:硬件计数 + 偶发中断,远低于"每次函数都打 log";
- 采样可能丢点:buffer 满会丢;高频采样里要看
perf record -m调缓冲区; - PMU 事件因 CPU 而异:Intel/AMD/ARM 各有差异,
perf list列出当前 CPU 支持的事件。
# 3.4 与其他工具互补
| 维度 | perf | gdb/lldb | strace | bpftrace | VTune |
|---|---|---|---|---|---|
| 主问 | 谁慢 | 是谁错 | 系统调用怎么走 | 内核行为 | 微架构哪卡 |
| 时机 | 在跑 | 出错后 | 在跑 | 在跑 | 在跑 |
| 开销 | < 1% | 暂停 | 5x-10x | 1%-10% | 1%-5% |
| 侵入 | 0 | 大 | 大 | 0 | 0 |
| 分辨率 | 函数 | 行 | 系统调用 | 任意内核事件 | μop |
perf 不是性能调试的银弹——而是"找方向"的第一刀。找到方向后再上专项工具:
- 锁竞争 → off-CPU 火焰图 /
bpftrace -e 'kprobe:mutex_lock'; - 内存分配 →
heaptrack/jemallocprofile; - 网络 IO →
bpftrace/tcpdump; - 微架构 → Intel VTune /
perf c2c。
# 3.5 排障四象限
忙 (CPU 高) 不忙 (CPU 低)
┌─────────────────────┬─────────────────────┐
快 (延 │ ① CPU bound 优化空间 │ ② IO bound │
迟低) │ - 算法/cache/锁 │ - 网络 / 磁盘 │
│ - 工具: 火焰图 │ - 工具: off-CPU │
├─────────────────────┼─────────────────────┤
慢 (延 │ ③ 算法 + 系统都有问题 │ ④ 阻塞 / 锁 / 调度 │
迟高) │ - 多重瓶颈 │ - 工具: off-CPU + │
│ - 全套 perf │ perf sched │
└─────────────────────┴─────────────────────┘
2
3
4
5
6
7
8
9
10
主线一是 ①(CPU 100% 但有优化空间),主线二同样是 ①。生产里 ②④ 才是最难调的——CPU 看着不忙,但用户感觉慢,普通 perf top 一无所获,必须上 off-CPU 火焰图(第 8.3 节)。
# 4. perf 命令体系
perf 不是单一命令,是一套子命令集合。下面是排查 CPU 性能必备的 6 把刀,按使用频率排序。
# 4.1 perf top 实时
最像 top,但粒度到函数:
$ sudo perf top -p $(pidof search-svc)
Samples: 12K of event 'cycles', Event count (approx.): 4421003221
Overhead Shared Object Symbol
42.31% libsearch.so std::__cxx11::basic_string::basic_string
18.74% libsearch.so std::_Hashtable::find
9.21% libc.so.6 __GI___libc_malloc
6.12% libc.so.6 __GI___libc_free
4.43% libc.so.6 __memcpy_avx_unaligned_erms
3.18% libsearch.so QueryContext::clone
...
2
3
4
5
6
7
8
9
10
适用:现场告警时第一刀——服务还在跑、还来得及定位。
几个常用键:
进入 perf top 后:
k 只看内核态 / 用户态切换
f 筛选符号
s 按符号排序
Enter 下钻 (annotate, 看汇编)
q 退出
2
3
4
5
6
坑:
perf top默认看整机,必须-p PID限制到目标进程;- 默认事件是
cycles,可改perf top -e cache-misses; - 容器里 PID 不一样——要用 host PID(容器进程在 host 上的 PID)。
# 4.2 perf record 采样
最常用的——事后分析首选:
# 基本用法: 对 PID, 采 30 秒, 带调用栈
$ sudo perf record -F 99 -p $(pidof svc) -g -- sleep 30
# ↑ ↑ ↑ ↑
# 采样频率 目标进程 带栈 持续时间
# 输出: ./perf.data (二进制)
2
3
4
5
6
7
参数详解:
| 参数 | 含义 | 推荐值 |
|---|---|---|
-F N | 每秒采样 N 次 | 99 (避开 100 整数倍, 防共振) |
-p PID | 限定进程 | 必填 |
-g | 采集调用栈 (默认 fp 模式) | 必填 |
--call-graph dwarf | 用 DWARF 展开栈 | -fno-omit-frame-pointer 缺失时用 |
-e EVENT | 采样事件 | 默认 cycles, 也可 cache-misses 等 |
-a | 整机采样 | 看系统级问题 |
-C N | 限定 CPU 核 | 多核不均时 |
-o FILE | 输出文件 | 默认 perf.data |
-m N | mmap buffer 大小 | 高频采样要调大 (e.g. -m 256) |
生产建议:
# 对于关键现场, 加 buffer 大小防止丢点
sudo perf record -F 999 -p $(pidof svc) -g -m 512 -- sleep 30
# 对于 -O2 编译且没 -fno-omit-frame-pointer 的二进制
sudo perf record -F 99 -p $(pidof svc) --call-graph dwarf -- sleep 30
# 多核不均时, 只采 4 号核
sudo perf record -F 99 -C 4 -g -- sleep 30
2
3
4
5
6
7
8
# 4.3 perf report 文本
采样完了用 perf report 看:
$ sudo perf report -i perf.data
# 进入 TUI 界面:
# Samples: 50K of event 'cycles', Event count: 1.2B
# Overhead Command Shared Object Symbol
# - 42.31% search libsearch.so std::string::string
# - std::string::string ← 展开调用栈
# - 38.10% QueryContext::clone
# + 36.42% on_request ← 谁调它最多
2
3
4
5
6
7
8
TUI 关键操作:
↑↓ 移动
Enter 展开调用关系 (show callers / show callees)
+ 展开
- 折叠
/ 搜索符号
a annotate 看汇编
t 切换调用方/被调方视图
2
3
4
5
6
7
非交互模式(生成报告好导入到平台):
$ sudo perf report -i perf.data --stdio --no-children | head -30
$ sudo perf report -i perf.data --stdio -n # 显示样本数
$ sudo perf report -i perf.data --stdio -g graph,0.5 # 调用图, 阈值 0.5%
2
3
# 4.4 perf stat 计数
不采样、只计数——主线二就靠它定位了 cache miss:
$ sudo perf stat -p $(pidof svc) -- sleep 10
Performance counter stats for process id '24891':
42,331.92 msec task-clock # 4.232 CPUs utilized
12,345 context-switches # 0.292 K/sec
12 cpu-migrations # 0.000 K/sec
189,221,003 page-faults # 4.470 M/sec
132,334,221,884 cycles # 3.126 GHz
151,002,883,221 instructions # 1.14 insn per cycle
21,003,558,331 branches # 496.250 M/sec
332,887,221 branch-misses # 1.59% of all branches
2
3
4
5
6
7
8
9
10
11
12
最有价值的指标:
| 指标 | 健康范围 | 异常含义 |
|---|---|---|
IPC (insn per cycle) | > 1.0 | < 0.5 严重访存或分支预测失败 |
cache-misses / cache-references | < 5% | > 10% 数据布局问题 |
branch-misses / branches | < 2% | > 5% 分支预测灾难 |
context-switches | < 5K/s | > 50K/s 线程过多 |
page-faults | < 1K/s 稳态 | 频繁 = 内存问题 |
自定义事件(看微架构):
# Intel CPU 看 frontend stall
$ perf stat -e cycles,instructions,\
cycle_activity.stalls_l1d_miss,\
cycle_activity.stalls_l2_miss,\
cycle_activity.stalls_mem_any \
-p $(pidof svc) -- sleep 10
2
3
4
5
6
perf list 看本机支持的事件——Intel/AMD/ARM 各不一样。
# 4.5 perf script 导出
把二进制 perf.data 转成文本(火焰图必经一步):
# 转成"每条采样一行"的文本
$ sudo perf script -i perf.data > perf.script
# 看一下格式
$ head -20 perf.script
search-svc 24891 [005] 1234567.890123: 1234567 cycles:
7f3a8b478e2c std::string::string+0x12 (/lib/libsearch.so)
7f3a8b465311 QueryContext::clone+0x45 (/lib/libsearch.so)
7f3a8b465200 on_request+0x88 (/lib/libsearch.so)
7f3a8b400055 EventLoop::run+0x125 (/lib/libsearch.so)
...
2
3
4
5
6
7
8
9
10
11
每条采样:时间戳 + 事件类型 + 完整调用栈。这就是火焰图的原料。
# 4.6 perf trace 类 strace
strace 的现代替代品——开销低 10 倍:
$ sudo perf trace -p $(pidof svc) --duration 1
123.456 ( 0.123 ms): svc/24891 read(fd: 5, ...) = 4096
123.789 ( 1.234 ms): svc/24891 epoll_wait(...) = 1
↑
超过 1ms 的系统调用才显示
2
3
4
5
典型用法:定位"谁在频繁调系统调用"——比如发现 write 每秒被调 100 万次,就该查谁在不缓冲地写日志。
# 5. 采样原理与精度
perf top / perf record 看着像魔法——不改代码就知道每个函数占多少 CPU。其实背后是一套硬件 + 内核 + 用户态的协作机制。理解原理才知道它什么时候不准。
# 5.1 PMU 与硬件计数器
CPU 每个核都有几组特殊寄存器,专门用来计数。Intel x86-64 上典型布局:
┌──────────────── 一个 CPU 核 ────────────────┐
│ │
│ 通用寄存器: RAX, RBX, ... │
│ 控制寄存器: CR0~4 │
│ │
│ ┌─────── PMU 区域 ───────┐ │
│ │ Fixed counters: │ (3 个固定) │
│ │ IA32_FIXED_CTR0 │ → instructions │
│ │ IA32_FIXED_CTR1 │ → cycles │
│ │ IA32_FIXED_CTR2 │ → ref-cycles │
│ │ │ │
│ │ Programmable counters: │ (4-8 个可编程) │
│ │ IA32_PMC0 │ ← 可绑定任意事件 │
│ │ IA32_PMC1 │ │
│ │ ... │ │
│ │ │ │
│ │ 配置寄存器: │ │
│ │ IA32_PERFEVTSELn │ 指定 PMC 监听啥 │
│ │ IA32_PERF_GLOBAL_CTRL│ 全局开关 │
│ └────────────────────────┘ │
└──────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
采样工作流:
1. perf record -F 99 -e cycles → 内核配置 PMC, 设阈值
(每隔 cycles/99 触发一次 → 大约 1 秒 99 次)
2. CPU 跑业务代码, PMC 自动累加 cycles
3. PMC 累加到阈值, 触发硬件中断 (PMI / NMI)
4. 内核 PMI handler:
- 当前 PC (RIP)
- 当前进程的栈 (用户态 + 内核态)
- 时间戳
- PID/TID, CPU 号
→ 写到 ring buffer
5. perf 用户态进程 mmap ring buffer → 落到 perf.data
6. PMC 重新设阈值, 继续
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
精髓:PMC 累加是 CPU 硬件干的,对业务代码 0 开销;只有累加到阈值时才有一次中断(每秒 99 次中断 = 99×1μs ≈ 0.01% 开销)。
# 5.2 采样频率怎么选
perf record -F N 中的 N 是每秒采样次数。为什么标准范例都用 99 而不是 100?
1 秒 = 1,000,000,000 纳秒
100 Hz → 阈值 = 10,000,000 周期
99 Hz → 阈值 = 10,101,010 周期 (不是整 1000 万)
2
3
很多业务有"每秒 100 次"的固定节奏(定时器、心跳、batch 处理)。如果采样也是 100 Hz,正好踩在每次定时器触发的同一相位——你看到的全是定时器代码,业务热点反而被采漏。
99 是个质数附近的"反共振"频率——和大多数业务节奏不同步,结果更有代表性。
频率 vs 精度对照:
-F | 每秒样本 | 采 30 秒 总样本 | 适用 |
|---|---|---|---|
| 19 | 19 | 570 | 长跑(小时级)大致看 |
| 99 | 99 | 2970 | 标准(默认推荐) |
| 199 | 199 | 5970 | 想看更精确分布 |
| 999 | 999 | 29970 | 短期高精度(秒级现场) |
| 4999 | 4999 | 149970 | 极致精度(注意开销) |
经验法则:总样本数 > 5000 时,结果基本稳定。低 QPS 服务用低频长跑,高 QPS 服务用高频短跑。
# 5.3 三种栈回溯方式
perf record -g 的栈回溯,背后有三种实现,性能、精度、二进制要求各不同。
方式 A:fp(frame pointer,默认)
依赖 RBP 寄存器形成的栈帧链——必须编译时加 -fno-omit-frame-pointer。
g++ -O2 -fno-omit-frame-pointer -g ...
perf record -F 99 -g ... # 默认 fp
2
3
| 维度 | 表现 |
|---|---|
| 速度 | 最快(< 100 ns/帧) |
| 精度 | 高 |
| 二进制要求 | 必须保留 fp |
| 缺陷 | -O2 默认丢 fp(少占一个寄存器) |
方式 B:dwarf(DWARF 展开)
读 .eh_frame 段的展开规则——不需要 fp,但需要在采样点拷贝栈数据回内核:
perf record -F 99 --call-graph dwarf,16384 ...
# ↑
# 每次采样拷贝 16K 栈数据 (默认 8K, 深栈不够)
2
3
| 维度 | 表现 |
|---|---|
| 速度 | 慢(拷贝栈,开销 5%-10%) |
| 精度 | 高 |
| 二进制要求 | 有 .eh_frame(默认就有) |
| 缺陷 | 高 QPS 服务开销显著 |
方式 C:lbr(Last Branch Record,硬件辅助)
Intel CPU 提供的 LBR 寄存器,硬件记录最近 16/32 条分支跳转:
perf record -F 99 --call-graph lbr ...
| 维度 | 表现 |
|---|---|
| 速度 | 极快(硬件) |
| 精度 | 受限(栈深度 ≤ LBR 大小) |
| 二进制要求 | 无 |
| 缺陷 | 深栈截断;ARM/旧 Intel 不支持 |
选哪个:
有 -fno-omit-frame-pointer → fp (最优)
没有 fp, 二进制带 -g → dwarf
都没有 + Intel CPU → lbr (浅栈也能用)
栈很浅 (< 16 帧) + Intel → lbr (开销最小)
2
3
4
# 5.4 内核与用户态
perf 默认采样用户态 + 内核态全栈:
# 看完整栈 (默认)
perf record -g ...
# 只看用户态 (业务函数)
perf record -g -e cycles:u ...
# 只看内核态 (系统调用 / 中断)
perf record -g -e cycles:k ...
2
3
4
5
6
7
8
为什么要分?有些业务问题在内核栈里看才明白——例如:
- 网络收包栈在内核:
__netif_receive_skb→tcp_v4_rcv→ 业务 epoll_wait 唤醒; - 锁竞争在内核:
futex_wait→__schedule; - 系统调用本质:业务的
read()→ 内核vfs_read→ fs 驱动。
生产坑:默认很多发行版关闭了非 root 看内核栈的权限:
# 看 perf_event_paranoid 设置
$ cat /proc/sys/kernel/perf_event_paranoid
2
# -1 = 完全开放
# 0 = 普通用户可看 cpu/kernel/user 事件
# 1 = 普通用户只能看用户态
# 2 = 默认: 普通用户必须 sudo
# 生产建议: 0 (调试方便)
$ echo 0 | sudo tee /proc/sys/kernel/perf_event_paranoid
2
3
4
5
6
7
8
9
10
11
kptr_restrict=0 才能看到内核符号名(不是裸地址):
$ echo 0 | sudo tee /proc/sys/kernel/kptr_restrict
# 5.5 采样的盲区
perf 不是万能的——下面这些场景它一无所知:
| 盲区 | 原因 | 替代工具 |
|---|---|---|
| 线程 sleep / 等 IO | 不在 CPU 上, cycles 不增长 | off-CPU 火焰图 |
| 锁等待 | 同上 | off-CPU 火焰图 / mutex tracing |
| 短突发热点 | 30 秒内只发生几毫秒 | 高频采样 + 触发式 |
| 内联展开后的小函数 | 调用栈里看不到 | inlining 后的火焰图(-fno-inline 重编) |
| JIT 代码 | 没有 ELF 符号 | perf-PID.map(第 9.2 节) |
| 完全空闲(CPU 0%) | 没采样点 | tracing |
对策:
- on-CPU 看不出 → off-CPU 看(第 8.3 节);
- 30 秒采样不够 → 长跑 5-10 分钟;
- 看不到具体行 → annotate 到汇编(第 7.4 节)。
# 6. 火焰图三步法
文本 perf report 信息密度高但不直观。火焰图用一张图把"几百个函数 + 几千条调用栈"压成一目了然的形状。Brendan Gregg 发明的工具链已经成为业界标准。
# 6.1 第一步采样
# 1. clone 火焰图工具 (一次性)
git clone https://github.com/brendangregg/FlameGraph ~/flamegraph
# 2. 对目标进程采 30 秒
sudo perf record -F 99 -p $(pidof search-svc) -g -- sleep 30
# 输出: ./perf.data
2
3
4
5
6
采样三个常见错误:
# ❌ 错误 1: 没带 -g, 没栈, 火焰图就一层
sudo perf record -F 99 -p $PID -- sleep 30
# ❌ 错误 2: -F 太低, 30 秒只有几百样本, 火焰图不准
sudo perf record -F 9 -p $PID -g -- sleep 30
# ❌ 错误 3: 二进制 -O2 没 fp, 默认 fp 模式拿不到栈
# 火焰图大量 [unknown]
sudo perf record -F 99 -p $PID -g -- sleep 30
# ✅ 改用 dwarf
sudo perf record -F 99 -p $PID --call-graph dwarf -- sleep 30
2
3
4
5
6
7
8
9
10
11
# 6.2 第二步折叠栈
# 把 perf.data 转成文本
sudo perf script -i perf.data > out.perf
# 折叠成"每条栈一行"
~/flamegraph/stackcollapse-perf.pl out.perf > out.folded
2
3
4
5
折叠之前长这样(一条采样多行):
search-svc 24891 [005] 1234.890123: cycles:
7f3a8b478e2c std::string::string+0x12
7f3a8b465311 QueryContext::clone+0x45
7f3a8b465200 on_request+0x88
...
2
3
4
5
折叠之后(一条栈一行 + 出现次数):
EventLoop::run;on_request;QueryContext::clone;std::string::string 421
EventLoop::run;on_request;QueryContext::clone;std::_Hashtable::find 187
EventLoop::run;on_request;process;malloc 92
...
2
3
4
# 6.3 第三步生成 SVG
~/flamegraph/flamegraph.pl out.folded > flame.svg
打开 flame.svg(浏览器直接拖进去),鼠标悬停可看函数名 + 占比,点击下钻。
几个常用参数:
# 自定义标题
flamegraph.pl --title="search-svc on-CPU" out.folded > flame.svg
# 改颜色方案
flamegraph.pl --colors=java out.folded > flame.svg
flamegraph.pl --colors=mem out.folded > flame.svg
flamegraph.pl --colors=blue out.folded > flame.svg
# 改宽度
flamegraph.pl --width=1600 out.folded > flame.svg
# 反转 (堆栈从顶往下)
flamegraph.pl --inverted out.folded > flame.svg
2
3
4
5
6
7
8
9
10
11
12
13
# 6.4 一行命令模板
把三步压成一行,写到 ~/.bashrc 备查:
# 标准火焰图
alias mkflame='sudo perf record -F 99 -p $PID -g -- sleep 30 && \
sudo perf script | \
~/flamegraph/stackcollapse-perf.pl | \
~/flamegraph/flamegraph.pl > flame.svg'
# DWARF 模式 (无 fp 时用)
alias mkflame-dwarf='sudo perf record -F 99 -p $PID --call-graph dwarf,16384 -- sleep 30 && \
sudo perf script | \
~/flamegraph/stackcollapse-perf.pl | \
~/flamegraph/flamegraph.pl > flame.svg'
# 用法:
$ PID=$(pidof search-svc) mkflame
$ open flame.svg
2
3
4
5
6
7
8
9
10
11
12
13
14
15
或者用 Brendan Gregg 的全自动脚本 profile.sh:
#!/bin/bash
# profile.sh
PID=$1
DURATION=${2:-30}
OUT=${3:-flame.svg}
TMPDIR=$(mktemp -d)
sudo perf record -F 99 -p $PID -g --output=$TMPDIR/perf.data -- sleep $DURATION
sudo perf script -i $TMPDIR/perf.data | \
~/flamegraph/stackcollapse-perf.pl | \
~/flamegraph/flamegraph.pl --title="PID $PID over ${DURATION}s" > $OUT
rm -rf $TMPDIR
echo "Flame graph: $OUT"
2
3
4
5
6
7
8
9
10
11
12
13
之后:./profile.sh $(pidof search-svc) 60 search-svc.svg。
# 6.5 容器与远程
容器内进程的 PID 在 host 上看是另一个数:
# 1. 看容器内的 PID (容器视角)
$ docker exec -it search-svc ps
PID TTY TIME CMD
1 ? 00:00:00 search-svc
# 2. 反查 host PID
$ docker inspect -f '{{.State.Pid}}' search-svc
12345
# 3. 用 host PID 采样
$ sudo perf record -F 99 -p 12345 -g -- sleep 30
2
3
4
5
6
7
8
9
10
11
容器内符号查找:perf 默认从 host 文件系统找符号,容器里的二进制要 mount 出来或用 --symfs:
$ sudo perf record -F 99 -p 12345 -g -- sleep 30
$ sudo perf script -i perf.data --symfs=/proc/12345/root | \
~/flamegraph/stackcollapse-perf.pl | \
~/flamegraph/flamegraph.pl > flame.svg
2
3
4
远程机器:
# 在远端跑采样
ssh prod-svc 'sudo perf record -F 99 -p $(pidof svc) -g -o /tmp/perf.data -- sleep 30'
# 拷回本地 (注意要包二进制)
scp prod-svc:/tmp/perf.data .
scp prod-svc:/usr/bin/svc ./bin/
scp prod-svc:/usr/lib/libsvc.so ./lib/
# 本地解析 (要告诉 perf 在哪找符号)
sudo perf script -i perf.data --symfs=. | \
~/flamegraph/stackcollapse-perf.pl | \
~/flamegraph/flamegraph.pl > flame.svg
2
3
4
5
6
7
8
9
10
11
12
生产建议:把 perf 工具 + flamegraph 脚本打进基础镜像,落地时直接生成 SVG,传 SVG 比传 perf.data + 二进制方便几个数量级。
# 7. 火焰图怎么读
火焰图很美——但至少 50% 的工程师读错。这一节讲清楚每条线、每个色块到底意味着什么。
# 7.1 横宽与纵高
高 (调用深度)
↑
│ ┌──────────────┐
│ │ leaf_func │ ← 叶子函数 (CPU 真正在跑的那条指令)
│ ├──────────────┤
│ │ inner_func │
│ ├──────────────┤
│ │ outer_func │
│ ├──────────────┤
│ │ main │
│ └──────────────┘
└────────────────────→ 宽 (CPU 占比)
2
3
4
5
6
7
8
9
10
11
12
两个轴的含义截然不同:
| 轴 | 含义 | 量化 |
|---|---|---|
| 横向(宽度) | CPU 时间占比 | 一个矩形宽度 = 它在采样里出现的总次数(含被调用层) |
| 纵向(高度) | 调用栈深度 | 自下而上:main → ... → 当前 PC |
关键判断:
- 宽:值得优化的函数;
- 窄:忽略,再快也省不了多少;
- 高:栈深,但和 CPU 占用无关——栈再深,宽度才是主角。
正确读法:只看顶层的"平顶"——那才是真正跑的代码。下面的栈帧只是路径。
错误读法: 看到 main 横通到底, 以为 main 慢
正确读法: main 横通到底是因为它是入口, 真正慢的是它顶上某个细分函数
2
# 7.2 找平顶找尖塔
火焰图的 90% 价值,来自识别两种形状:
形状 A: 平顶 (Plateau)
┌─────────────────────────────────────┐
│ std::string::string │ ← 顶上这个很宽
├─────────────────────────────────────┤
│ QueryContext::clone │
├─────────────────────────────────────┤
│ on_request │
└─────────────────────────────────────┘
含义: 这一个函数自己很慢 (或被无数个上游调用)
解法: 优化这个函数本身, 或减少调用次数
形状 B: 尖塔 (Tower)
┌─┐
│f│
├─┤
┌─────────┤e│
│ d ├─┤
├─────────┤e│
│ c │ │
├─────────┤ │
│ ... │ │
含义: 调用链很深, 每层都不慢, 但加起来栈很高
解法: 一般栈高不是问题, 除非看到"塔顶有一个未知热点"
形状 C: 金字塔 (Pyramid) - 最坏
┌─────────────────────────────────────┐
│ memcpy / lock / malloc │ ← 顶层全是系统函数
├─────────────────────────────────────┤
│ inner_loop │ ← 业务循环
├─────────────────────────────────────┤
│ algorithm_xxx │
└─────────────────────────────────────┘
含义: 算法本身重 + 系统开销重, 双重瓶颈
解法: 重构算法
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
视觉直觉:
- 顶层有连续平顶 ≥ 火焰图总宽 5% → 重点优化对象;
- 顶层不连续,多个小峰 → 没有热点,性能均匀分散,难优化;
- 整图扁平(高度低) → 调用栈短,逻辑简单,bottleneck 通常在叶子函数。
# 7.3 颜色不是含义
最大的误读:以为火焰图的颜色有意义。
实际上:默认颜色(暖色系黄→红)只是为了视觉区分相邻函数——颜色深浅完全随机。
# 不同 colorscheme 一对比就明白
flamegraph.pl --colors=hot ... # 默认: 黄红色
flamegraph.pl --colors=mem ... # 绿色
flamegraph.pl --colors=java ... # 按语言分色
2
3
4
特殊场景下颜色才有含义:
| 颜色方案 | 含义 |
|---|---|
--colors=java | 按语言/层着色(C++/JNI/库) |
差分火焰图 | 红=变慢, 蓝=变快(第 8.1 节) |
--colors=mem | 一般用于内存分配火焰图 |
别凭颜色判断"红色 = 危险"——这是错的。
# 7.4 主线一火焰图
回到主线一的现场,火焰图大致长这样:
┌─────────────────────────────────────────────────────────────────────┐
│ 42% std::string::string │ ← 平顶!
├─────────────────────────────────────────────────────────────────────┤
│ 18% std::_Hashtable::find │ 9% malloc │ 6% free │ ...│
├─────────────────────────────────────────────────────────────────────┤
│ 40% QueryContext::clone │
├─────────────────────────────────────────────────────────────────────┤
│ 45% on_request │
├─────────────────────────────────────────────────────────────────────┤
│ 80% EventLoop::run │
├─────────────────────────────────────────────────────────────────────┤
│ main │
└─────────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
读法:
- 顶层平顶
std::string::string占 42% → 第一目标; - 它的父帧是
QueryContext::clone40% → 字符串构造来自 clone; - 再上一层
on_request45% → clone 在每次请求都被调; - bottleneck 锁定:每次请求都拷贝一份
QueryContext,里面有大量std::string。
perf annotate 进一步看 clone 的汇编:
$ sudo perf annotate -i perf.data QueryContext::clone
QueryContext::clone():
push %rbp
mov %rsp,%rbp
...
callq std::string::string@plt ← 88% 的样本在这条
...
callq std::string::string@plt ← 12%
2
3
4
5
6
7
8
9
精确到那两条指令——根因找到。修复:shared_ptr<const Config> + COW。
# 7.5 主线二火焰图
主线二的火焰图反而看起来很平淡:
┌──────────────────────────────────────────────────────────┐
│ 95% compute (含内联展开) │
├──────────────────────────────────────────────────────────┤
│ 95% main_loop │
├──────────────────────────────────────────────────────────┤
│ main │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
7
compute 一个函数占 95%,看不出任何额外信息——这是 cache miss 的典型特征:CPU 全忙在一个函数里,但忙的不是计算,是等内存。
这种情况火焰图没用——必须上 perf stat:
$ perf stat -e cycles,instructions,cache-misses ./bench
8,994,121,003 cycles
8,142,883,221 instructions (0.91 IPC) ← 关键!
298,776,883 cache-misses (62.4%) ← 关键!
2
3
4
5
IPC < 1.0 + cache miss > 50% → 内存瓶颈,不是计算瓶颈。
进一步:perf annotate 加上 -e cache-misses:
$ sudo perf record -F 99 -e cache-misses -p $(pidof bench) -- sleep 30
$ sudo perf annotate -i perf.data
compute():
...
mov 0x0(%rdi),%eax ← 75% 的 cache-miss 样本
这条访问 MyStruct.id, 触发 miss
2
3
4
5
6
7
perf c2c 工具更精细——能告诉你哪个 cache line 在哪些核之间反复弹(false sharing):
$ sudo perf c2c record ./bench
$ sudo perf c2c report
2
# 7.6 五个常见误读
误读 1:颜色有含义
→ 默认颜色只是视觉区分,不代表"红色=慢"。
误读 2:栈深的就是瓶颈
→ 栈深是函数调用层数多,跟 CPU 时间无关。看横宽,不是纵高。
误读 3:底部 main 占 100% 是问题
→ main 是入口,所有路径都汇聚到它,宽度 100% 正常。看顶层平顶才有意义。
误读 4:找最高的尖塔优化
→ 高不等于慢。要找宽且高——尤其是顶层窄不下来的"平顶"。
误读 5:火焰图为空白 / [unknown] 多 → perf 坏了
→ 9 成是符号问题(第 9.1 节):没装 debug 包、二进制 strip 了、JIT 代码、容器内符号没拷贝出来。
正确读法心法:
一看横宽: 哪些函数占 > 5%? 这些是优化候选
二看顶层: 平顶在哪? 平顶就是叶子热点
三看父帧: 平顶往下一两层是什么? 这是触发热点的"凶手"
四看占比: 改这个函数能优化掉多少 CPU? (就是它的横宽)
五看汇编: 缩到 perf annotate, 哪条指令最热? (尤其是 cache miss 场景)
2
3
4
5
# 8. 高级火焰图变体
基础 on-CPU 火焰图能解决 70% 的性能问题。剩下 30% 的硬骨头——锁等待、内存分配、IO 阻塞——要靠下面这些变体。
# 8.1 差分火焰图
性能回归排查神器:对比两次采样的差异。
# 1. 采优化前
sudo perf record -F 99 -p $PID -g -o before.data -- sleep 30
sudo perf script -i before.data > before.perf
~/flamegraph/stackcollapse-perf.pl before.perf > before.folded
# 2. 改了代码后, 采优化后
sudo perf record -F 99 -p $PID -g -o after.data -- sleep 30
sudo perf script -i after.data > after.perf
~/flamegraph/stackcollapse-perf.pl after.perf > after.folded
# 3. 生成差分火焰图
~/flamegraph/difffolded.pl before.folded after.folded | \
~/flamegraph/flamegraph.pl > diff.svg
2
3
4
5
6
7
8
9
10
11
12
13
读法:
| 颜色 | 含义 |
|---|---|
| 红色 | 这个函数变得更宽了(变慢) |
| 蓝色 | 这个函数变得更窄了(变快) |
| 浅色 | 变化不大 |
典型用法:
- 新版本上线后 QPS 掉了 → 跟旧版差分,红色块就是回归点;
- 优化生效验证 → 期望看到目标函数的蓝色变化;
- 配置变更影响 → 改了某个 flag 后差分。
# 8.2 反转火焰图
普通火焰图:栈底是 main,栈顶是叶子。反转火焰图:栈底是叶子,往上聚合。
~/flamegraph/flamegraph.pl --inverted out.folded > flame.svg
普通火焰图 反转火焰图
┌──────────┐ ┌──────────┐
│ leaf │ │ main │
├──────────┤ ├──────────┤
│ inner │ │ inner │
├──────────┤ ├──────────┤
│ main │ │ leaf │ ← 叶子在底, 往上聚
└──────────┘ └──────────┘
2
3
4
5
6
7
8
反转的妙处:当某个底层函数(如 malloc)被几十个不同上游调用时,普通火焰图会把它打散到几十个细窄条;反转火焰图把它合并成一个大块——一眼看出"malloc 总共占多少,分别被谁调"。
经典使用场景:
- 找某个公共函数的所有调用方;
- 看某个系统调用的所有触发路径;
- 内存分配热点反向追溯。
# 8.3 off-CPU 火焰图
普通 perf 看 on-CPU——线程在 CPU 上时它在干嘛。但如果线程因为锁、IO、调度被换下了 CPU,普通 perf 看不到。
offcputime(来自 BCC 工具集)专门记录"线程不在 CPU 上的时间":
# 安装 BCC tools
sudo apt install bpfcc-tools # 或 yum install bcc-tools
# 采 30 秒
sudo offcputime-bpfcc -df -p $(pidof svc) 30 > off.folded
# 生成火焰图
~/flamegraph/flamegraph.pl --colors=io \
--title="off-CPU" --countname=us off.folded > off.svg
2
3
4
5
6
7
8
9
读法:
普通 on-CPU 火焰图 off-CPU 火焰图
横宽 = 在 CPU 上花的时间 横宽 = 不在 CPU 上的时间
顶层 = 哪段代码在跑 顶层 = 线程从哪里被换下
(futex_wait, read, epoll_wait 等)
2
3
4
off-CPU 揭示什么:
| 场景 | off-CPU 顶层函数 |
|---|---|
| 锁竞争 | futex_wait / __lll_lock_wait |
| IO 阻塞 | io_schedule / __wait_on_buffer |
| 网络 IO | epoll_wait / inet_csk_accept |
| sleep / nanosleep | hrtimer_nanosleep |
| 调度延迟 | schedule |
主线一的 off-CPU:基本看不到东西(因为是 CPU bound)。 主线二的 off-CPU:基本看不到(也是 CPU bound)。
但如果遇到 ② / ④ 象限的问题——CPU 不忙但延迟高——off-CPU 火焰图就是唯一的破案钥匙。
# 8.4 内存与分配
CPU 火焰图看不出内存问题。看内存分配热点要换工具:
A. heaptrack(推荐)
# 需要 LD_PRELOAD heaptrack 的 .so
heaptrack ./svc
# 或 attach 到运行中的进程
heaptrack -p $(pidof svc)
# 分析 (GUI)
heaptrack_gui heaptrack.svc.12345.gz
# 命令行 + 火焰图
heaptrack_print --print-flamegraph heaptrack.gz | \
~/flamegraph/flamegraph.pl --countname=bytes \
--title="heap allocations" > alloc.svg
2
3
4
5
6
7
8
9
10
11
12
13
B. jemalloc 内置 profiler
# 编译时链接 jemalloc
g++ ... -ljemalloc
# 启动时开 profile
MALLOC_CONF="prof:true,prof_prefix:jeprof.out" ./svc
# 跑一段时间后, kill -USR1 触发 dump
kill -USR1 $(pidof svc)
# 解析
jeprof --pdf ./svc jeprof.out.* > heap.pdf
jeprof --collapsed ./svc jeprof.out.* | \
~/flamegraph/flamegraph.pl --countname=bytes > heap.svg
2
3
4
5
6
7
8
9
10
11
12
13
C. perf 自己也能采 page-faults
sudo perf record -F 99 -e page-faults -p $PID -g -- sleep 30
# page-fault = 第一次访问该页, 通常对应 malloc 后第一次 touch
2
# 8.5 锁竞争火焰图
锁等待属于 off-CPU——但有专门的锁竞争分析工具:
A. mutextrace(基于 BPF)
sudo /usr/sbin/mutextrace -p $(pidof svc) 30
# 输出: 哪个 mutex 被等了多久, 哪个 PID 持有
2
B. perf lock(部分内核支持)
sudo perf lock record -p $PID -- sleep 30
sudo perf lock report
2
C. 自定义 BPF(最灵活)
sudo bpftrace -e '
uprobe:/lib/x86_64-linux-gnu/libpthread.so.0:pthread_mutex_lock {
@start[tid] = nsecs;
}
uretprobe:/lib/x86_64-linux-gnu/libpthread.so.0:pthread_mutex_lock {
@latency = hist(nsecs - @start[tid]);
delete(@start[tid]);
}'
2
3
4
5
6
7
8
D. C++ 代码里手动埋点
class TracedMutex {
std::mutex m_;
public:
void lock() {
auto t0 = std::chrono::steady_clock::now();
m_.lock();
auto dt = std::chrono::steady_clock::now() - t0;
if (dt > 1ms) { // 阈值
log_lock_contention(this, dt);
}
}
};
2
3
4
5
6
7
8
9
10
11
12
# 8.6 BPF 火焰图
BPF(eBPF)是新一代内核观测技术——比 perf 灵活得多。
优势:
| 维度 | perf | BPF |
|---|---|---|
| 自定义事件 | 受 PMU 限制 | 可埋任意内核函数 |
| 用户态 uprobe | 慢 | 快得多 |
| 数据加工 | 在用户态 | 内核态聚合 (省 IO) |
| 学习成本 | 中 | 高 |
bpftrace 一行火焰图(off-CPU 时间分布):
sudo bpftrace -e '
profile:hz:99 / pid == $PID / { @[ustack] = count(); }
END { print(@); }' > out.txt
2
3
专用工具(BCC 项目):
| 工具 | 用途 |
|---|---|
profile-bpfcc | on-CPU 火焰图(替代 perf record) |
offcputime-bpfcc | off-CPU 火焰图 |
funccount-bpfcc | 函数调用次数统计 |
argdist-bpfcc | 参数分布 |
tcptrace-bpfcc | TCP 事件追踪 |
生产推荐:Brendan Gregg 的 BCC + FlameGraph 全家桶——能解决 95% 的疑难。
# 9. 符号与离线分析
火焰图 60% 的"看不懂",源于本节问题——符号没了 = 火焰图就是一堆乱码。
# 9.1 没有符号怎么办
症状:火焰图大量 [unknown] / 裸地址。
根因清单(按优先级):
① 二进制被 strip 了 (release 默认)
→ 重新编译时分离调试符号 (见 04.CoreDump破案实录 第 9.2 节)
→ 或 strip 时只剥 .debug_info, 保留 .symtab
② 没有 -g 编译
→ 至少加 -g1 (只要符号名, 不要 line info)
③ 共享库的 -dev 包没装
→ apt install libfoo-dev / apt install libfoo-dbgsym
→ 或下载 .debug 包到 /usr/lib/debug/
④ 二进制不是用 -fno-omit-frame-pointer 编的
→ 改 dwarf 模式: perf record --call-graph dwarf
⑤ 容器内符号在 host perf 看不到
→ perf script --symfs=/proc/<host_pid>/root
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
临时修复:让裸地址变成函数名:
# 用 addr2line 批量翻译
$ awk '/0x/{print $1}' out.perf | \
sort -u | \
xargs -I{} addr2line -e ./svc -f -C {}
# 或用 perf 自带 (前提是符号能找到)
$ sudo perf script --kallsyms=/proc/kallsyms -i perf.data
2
3
4
5
6
7
# 9.2 JIT 与解释器
JIT 引擎(V8/JVM/JS/Python/Lua)的代码不在 ELF 文件里——perf 默认看不到。解决方案:让 JIT 写一个 /tmp/perf-<PID>.map:
# /tmp/perf-12345.map 格式:
<起始地址> <长度> <符号名>
7f3a8b400000 200 main_loop
7f3a8b400200 100 jit_dispatcher
...
2
3
4
5
6
perf 会自动读这个文件,把地址映射到 JIT 函数名。
各 JIT 实现的开关:
# Node.js / V8
node --perf-basic-prof your_script.js
# Java JVM (perf-map-agent)
java -XX:+PreserveFramePointer -agentpath:/usr/lib/perf-map-agent.so ...
# Python (py-spy 工具)
py-spy record -o flame.svg -p $PID
# Lua (luajit + lj-perf)
luajit -jp=v ...
2
3
4
5
6
7
8
9
10
11
# 9.3 容器内的 perf
容器是符号问题的重灾区。
问题 1:perf 工具不在容器里
# 解决: 用 host 的 perf, 通过 host PID 采样
$ docker inspect -f '{{.State.Pid}}' my-svc
12345
$ sudo perf record -F 99 -p 12345 -g -- sleep 30
2
3
4
问题 2:符号在容器内, perf 在 host 找不到
# 解决: --symfs 指向容器的 root
$ sudo perf script -i perf.data --symfs=/proc/12345/root | \
~/flamegraph/stackcollapse-perf.pl | \
~/flamegraph/flamegraph.pl > flame.svg
2
3
4
问题 3:容器没装 perf, 也不允许 host 跑 (受限环境)
# 用 sidecar 模式: 加一个 perf 容器, 共享 PID namespace
docker run --pid=container:my-svc \
--cap-add=SYS_ADMIN --cap-add=SYS_PTRACE \
perf-image perf record ...
2
3
4
Kubernetes:
# Pod spec
spec:
shareProcessNamespace: true # 同 Pod 内共享 PID
containers:
- name: my-svc
image: my-svc:1.0
- name: profiler # sidecar
image: perf-tools:1.0
securityContext:
capabilities:
add: ["SYS_ADMIN", "SYS_PTRACE"]
2
3
4
5
6
7
8
9
10
11
# 9.4 远端采样落地
生产机不能访问外网、本地拉文件麻烦。最佳实践:
# 1. 采样 + 落地 (生产机上一行搞定)
$ sudo perf record -F 99 -p $(pidof svc) -g -- sleep 30 \
&& sudo perf script | \
~/flamegraph/stackcollapse-perf.pl | \
~/flamegraph/flamegraph.pl > /tmp/flame.svg
# 2. 把 SVG 拉回本地 (单文件, 几百 KB ~ 几 MB)
$ scp prod:/tmp/flame.svg .
# 3. 浏览器打开
$ open flame.svg
2
3
4
5
6
7
8
9
10
11
如果非要拉 perf.data:
# 必须同时拉二进制 + 共享库 + 调试符号
$ tar czf perf-bundle.tgz perf.data \
/usr/bin/svc \
/usr/lib/libsvc.so \
/usr/lib/debug/.build-id/*.debug
$ scp prod:perf-bundle.tgz .
$ tar xzf perf-bundle.tgz
# 本地分析时指定 symfs
$ sudo perf script -i perf.data --symfs=. | ...
2
3
4
5
6
7
8
9
10
11
生产推荐:把"采样 + 生成 SVG + 上传到对象存储"做成一键脚本,挂到内部平台,授权工程师一键触发。
# 9.5 perf.data 兼容性
perf.data 不是简单的二进制——里面包含采样时机器的 PMU 信息。跨内核版本读 perf.data 可能报错:
$ sudo perf script -i perf.data
Error: 'perf.data' was generated by perf 5.4 but you are running perf 5.15
2
对策:
# 1. 强制读 (大多数时候 OK)
$ sudo perf script -i perf.data --force
# 2. 在生产机当场转换成跨版本通用格式 (folded)
$ sudo perf record ... && sudo perf script | stackcollapse-perf.pl > out.folded
# 3. 升级本地 perf 到匹配版本
$ apt install linux-tools-$(uname -r)
2
3
4
5
6
7
8
架构差异(x86 vs ARM)几乎没法跨——直接在生产机做完整链路最稳妥。
# 10. 五步定位方法论
把主线一、主线二的破案过程抽象成可复用流程:
┌─────────────────────────────────────────┐
│ 1. 看宏观指标 (top + perf stat) │
│ 锁定瓶颈方向: CPU bound / IO / 锁 │
└──────────────────┬──────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 2. 缩小到函数 (perf top / 火焰图) │
│ 找到顶层平顶 → 锁定可疑函数 │
└──────────────────┬──────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 3. 看汇编与缓存 (perf annotate, c2c) │
│ 精确到指令级根因 │
└──────────────────┬──────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 4. 假设 → 修改 → 差分对比 │
│ 用差分火焰图证明改动有效 │
└──────────────────┬──────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 5. 沉淀到 CI (benchmark + 性能门禁) │
│ 防止下次回归 │
└─────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 10.1 先看宏观指标
没看 top 和 perf stat 就开火焰图,等于不量血压就开刀。
主线一的宏观第一刀:
$ top -p $(pidof svc)
%CPU 798%, %MEM 52%
$ perf stat -p $(pidof svc) -- sleep 5
42,000 msec task-clock 4.2 CPUs utilized
132,300,000,000 cycles 3.15 GHz
151,000,000,000 instructions 1.14 IPC ← 还行
12,883,402 cache-misses 3.2% ← OK
2
3
4
5
6
7
8
结论:纯 CPU bound,IPC 正常 → 大概率是"算法或代码本身重",不是访存问题 → 直接上火焰图。
主线二的宏观第一刀:
$ perf stat ./bench
8,994,121,003 cycles
8,142,883,221 instructions 0.91 IPC ← ⚠️ 异常
298,776,883 cache-misses 62.4% ← ⚠️ 严重!
2
3
4
结论:IPC < 1.0 + cache miss 很高 → 不是计算瓶颈,是访存瓶颈 → 火焰图意义不大,直接 perf annotate 找热点指令 + 看数据布局。
两类问题的不同入口:
| 现象 | 第一工具 | 第二工具 |
|---|---|---|
| IPC > 1.5 + 火焰图明显平顶 | 火焰图 | annotate |
| IPC < 1.0 + cache miss > 10% | perf stat | annotate (找热指令) |
| 多线程多核但 QPS 不增 | off-CPU 火焰图 | mutex tracing |
| CPU 不忙但延迟高 | off-CPU 火焰图 | bpftrace |
| sys% > 30% | perf trace | bpftrace |
# 10.2 缩小到函数
宏观确认了"是 CPU 算法问题"后,进入"找具体哪个函数"阶段。最快路径是 perf top——不用等 30 秒采样,5 秒就能看排名:
$ sudo perf top -p $(pidof svc)
Overhead Shared Object Symbol
42.31% libsvc.so std::string::string
18.74% libsvc.so std::_Hashtable::find
...
2
3
4
5
但 perf top 只看到"叶子函数",看不到"调用链"。所以下一步必须火焰图:
$ sudo perf record -F 99 -p $(pidof svc) -g -- sleep 30
$ sudo perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg
$ open flame.svg
2
3
找平顶:哪个顶层函数横宽 > 5%?把它和它的父帧记下来。
几个典型分类:
| 顶层平顶 | 可能根因 |
|---|---|
std::string::* | 字符串拷贝/构造(主线一) |
__libc_malloc / _int_malloc | 频繁分配 |
std::_Hashtable::find / std::_Rb_tree::* | 大容器频繁查 |
__memcpy_* / memmove | 大块内存拷贝 |
__pthread_mutex_lock | 锁竞争 |
do_syscall_64 / entry_SYSCALL_64 | 系统调用过多 |
compute 单一业务函数 | 算法本身重 |
# 10.3 看汇编与缓存
火焰图找到嫌疑函数后,精确到指令:
$ sudo perf annotate -i perf.data QueryContext::clone
看哪些指令"样本数"最多:
QueryContext::clone():
Percent | Address | Instruction
88.32 | 4a25c | callq std::string::string@plt ← 88% 在这
8.14 | 4a280 | callq std::string::string@plt
1.20 | 4a298 | mov %rax,(%rdi)
...
2
3
4
5
6
对照源码找到对应行:
QueryContext::clone() {
QueryContext copy;
copy.cfg_ = cfg_; // ← string copy 1 (行 88%)
copy.headers_ = headers_; // ← string copy 2 (行 8%)
return copy;
}
2
3
4
5
6
主线二的 perf annotate(带 cache-misses 事件):
$ sudo perf record -F 99 -e cache-misses -p $(pidof bench) -g -- sleep 10
$ sudo perf annotate -i perf.data
2
compute():
Percent | Instruction
75.20 | mov 0x0(%rdi),%eax ← 主要 cache miss 在这
...
2
3
4
mov 0x0(%rdi), %eax = 读 MyStruct.id。但因为 MyStruct 是 24 字节、跨 cache line 半个,导致 prefetcher 失效——75% 的 miss 都在这条指令。
确认数据布局问题:
# 看 MyStruct 大小和对齐
$ pahole ./bench | grep -A20 'struct MyStruct'
struct MyStruct {
int id; /* 0 4 */
int type; /* 4 4 */
double weight; /* 8 8 */
double score; /* 16 8 */
/* size: 24, cachelines: 1 */
};
2
3
4
5
6
7
8
9
24 字节,一个 cache line 装 2.66 个 → 如果只用 id,最优是 SoA(Structure of Arrays):
// 改成 SoA
struct Items {
std::vector<int> ids; // 单独存
std::vector<int> types;
std::vector<double> weights;
std::vector<double> scores;
};
// 或者 hot/cold 拆分
struct ItemHot { int id, type; }; // 8 字节
struct ItemCold { double weight, score; };
std::vector<ItemHot> hot;
std::vector<ItemCold> cold;
2
3
4
5
6
7
8
9
10
11
12
13
# 10.4 假设修复对比
改完后必须用差分火焰图证明——不能凭"benchmark 跑一次更快"就下结论。
# 改前
$ sudo perf record -F 99 -p $PID -g -o before.data -- sleep 30
# 部署改后版本
# 改后
$ sudo perf record -F 99 -p $PID -g -o after.data -- sleep 30
# 差分
$ sudo perf script -i before.data | stackcollapse-perf.pl > before.folded
$ sudo perf script -i after.data | stackcollapse-perf.pl > after.folded
$ ~/flamegraph/difffolded.pl before.folded after.folded | \
~/flamegraph/flamegraph.pl > diff.svg
2
3
4
5
6
7
8
9
10
11
12
13
期望看到:
| 期望 | 火焰图表现 |
|---|---|
| 目标函数变窄/消失 | 蓝色块 |
| 没引入新热点 | 没有大红色块 |
| 总宽度(CPU 占用)下降 | 整体右侧"留白"更多 |
失败模式:
- 改完火焰图差不多 → 没改对地方;
- 改完出现新平顶 → 引入了新瓶颈(比如改 mutex 为 atomic 后 atomic 又成热点);
- 目标函数不见了,但其他函数变宽 → 总开销没变,只是换了个地方花时间。
主线一的差分(改 string 拷贝 → shared_ptr):
改前火焰图:
std::string::string 42% ████████████████████
std::_Hashtable::find 18% █████████
malloc + free 15% ███████
...
差分火焰图:
std::string::string -38% ████████████████████ (蓝色, 大幅减少)
std::_Hashtable::find -3% █ (蓝色, 间接受益)
shared_ptr::operator-> +2% █ (新增, 但很小)
...
改后火焰图:
std::_Hashtable::find 22% ███████████ (变成新最宽, 但绝对值小很多)
...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 10.5 沉淀回归
修复有效 ≠ 修复闭环。CI 必须加性能门禁,否则下次回归一定还来。
Step A:写 benchmark
// bench_clone.cpp
#include <benchmark/benchmark.h>
static void BM_QueryContext_Clone(benchmark::State& s) {
QueryContext ctx;
populate_with_5000_entries(ctx);
for (auto _ : s) {
auto c = ctx.clone();
benchmark::DoNotOptimize(c);
}
}
BENCHMARK(BM_QueryContext_Clone);
2
3
4
5
6
7
8
9
10
11
12
Step B:基线落库
$ ./bench_clone --benchmark_format=json > baseline.json
# 提交到代码仓的 perf/ 目录
2
Step C:CI 比对
# .github/workflows/perf.yml
- name: Run benchmarks
run: ./bench_clone --benchmark_format=json > current.json
- name: Compare
run: |
python compare_bench.py baseline.json current.json --threshold=10
# 慢 > 10% 则 fail
2
3
4
5
6
7
8
Step D:定期更新基线
每个 release 跑一次基线 baseline,存到对象存储。
心法八条:
- 先 stat 再火焰图:宏观看完才知道方向;
- 看横宽,不看高度:火焰图横向决定收益;
- 顶层平顶 = 优化候选:80/20 在这里体现;
- IPC + cache miss 是关键 KPI:决定瓶颈类型;
- 改完必差分:不差分等于没改;
- CPU 不忙不等于没问题:上 off-CPU;
- 符号丢就什么都看不到:先把符号问题修好再看火焰图;
- 沉淀到 CI:调一次 1 天,CI 跑一次 30 秒。
# 11. 典型场景速查
把第 1~10 章的方法论,落到 7 个最高频的性能场景。
# 11.1 单核打满
top: 1 个核 100%, 其他核空闲
特征:业务是单线程模型,或者多线程但任务分配不均,所有重活都在一个线程。
第一刀:
# 看 CPU 占用最高的线程
$ top -H -p $(pidof svc)
$ perf top -p $TID # TID = 那个忙的线程
2
3
修法:
- 单线程逻辑能并行化的部分用
std::thread/ 线程池; - 任务分配用 work-stealing 队列;
- 整个进程改成多 worker(fork / 多进程)。
# 11.2 多核不均
top: 一些核 90%, 另一些核 10%
特征:线程间负载不均衡。常见原因:分片不均(hash 不均)、亲和性绑定错误、NUMA 跨节点。
第一刀:
# 按 CPU 看分布
$ mpstat -P ALL 1
# 按线程看绑核
$ for tid in $(ls /proc/$(pidof svc)/task); do
taskset -p $tid
done
# 分核单独采样
$ perf record -F 99 -C 0 -g -- sleep 30
$ perf record -F 99 -C 4 -g -- sleep 30
# 比较两个火焰图
2
3
4
5
6
7
8
9
10
11
12
修法:
- 任务分片用 consistent hashing 而非 mod;
- 用
taskset/pthread_setaffinity_np绑核; - NUMA 系统用
numactl --interleave=all或绑节点。
# 11.3 系统态过高
top: %sy > 30%
特征:进程频繁陷入内核——大量系统调用、page fault、context switch。
第一刀:
# 看 syscall 分布
$ sudo perf trace -p $(pidof svc) --duration 1 -s
# 看 context switches
$ vmstat 1
r b swpd free buff cache in cs us sy id wa
8 0 0 2.1g 98m 1.2g 3812 184523 62 31 7 0
^^^^^^
cs > 50K/s 异常
# 内核栈火焰图
$ perf record -F 99 -p $PID -g -e cycles:k -- sleep 30
2
3
4
5
6
7
8
9
10
11
12
修法:
- read/write 改 readv/writev 批量;
- epoll / io_uring 减少系统调用;
- 锁改用 spinlock + futex (fastpath 不进内核);
- 线程数减少(context switch 主因)。
# 11.4 锁热点
on-CPU 火焰图: __pthread_mutex_lock 平顶
off-CPU 火焰图: futex_wait 占大头
2
特征:多个线程频繁等同一把锁。
第一刀:
# off-CPU 火焰图
$ sudo offcputime-bpfcc -df -p $PID 30 > off.folded
$ flamegraph.pl --colors=io --countname=us off.folded > off.svg
# 看到底是哪把锁
$ sudo bpftrace -e '
uprobe:/lib/.../libpthread.so.0:pthread_mutex_lock {
@[ustack, arg0] = count();
}'
2
3
4
5
6
7
8
9
修法:
- 减小临界区(锁外做计算,锁内只更新);
- 用读写锁(
std::shared_mutex); - 用无锁数据结构(
folly::ConcurrentHashMap/ TBB); - 分片锁(hash 到 N 把锁);
- RCU 风格快照(详见 04.CoreDump破案实录 第 13.1 节)。
# 11.5 内存分配热
火焰图顶层: malloc / free / _int_malloc 占 > 10%
特征:热路径频繁堆分配——主线一就是。
第一刀:
# 找谁在 malloc
$ heaptrack -p $PID
# 30 秒后 ctrl-C
$ heaptrack_print --print-flamegraph heaptrack.gz | \
flamegraph.pl --countname=allocs > alloc.svg
# 或 jemalloc profiler
MALLOC_CONF="prof:true" ./svc
2
3
4
5
6
7
8
修法:
- 对象池 /
boost::pool/ arena allocator; - thread_local 缓存(避免跨线程同步);
- 容器 reserve 避免 reallocate;
string_view/span代替 string / vector 拷贝;- jemalloc / tcmalloc 替换 glibc malloc。
# 11.6 cache miss 高
perf stat: cache-misses > 10%, IPC < 1.0
特征:访存瓶颈——主线二就是。
第一刀:
$ perf stat -e cache-references,cache-misses,L1-dcache-load-misses,LLC-load-misses ./svc
$ perf record -e cache-misses -F 99 -g -p $PID -- sleep 30
$ perf annotate -i perf.data # 找热指令
# 看数据结构布局
$ pahole ./svc | grep -A20 'struct MyStruct'
# 看 cache line 撞车 (false sharing)
$ sudo perf c2c record ./svc
$ sudo perf c2c report
2
3
4
5
6
7
8
9
10
修法:
- 数据布局:AoS → SoA、hot/cold 拆分;
- 缩小结构体:bitfield、按使用频率排序字段;
- 避免 false sharing:
alignas(64)隔离热字段; - 顺序访问代替随机访问;
- 预取(
__builtin_prefetch)。
# 11.7 慢但不忙
top: %CPU 50% (不高)
但: p99 延迟 500ms (用户感觉慢)
2
特征:典型的 ② / ④ 象限——线程大部分时间不在 CPU 上。普通 perf top 一无所获。
第一刀:
# off-CPU 是关键
$ sudo offcputime-bpfcc -df -p $PID 60 > off.folded
$ flamegraph.pl --colors=io --countname=us off.folded > off.svg
# 看每个线程的状态分布
$ sudo bpftrace -e '
profile:hz:99 / pid == $PID / {
@[ustack, kstack, comm] = count();
}'
# 看 IO 延迟
$ sudo iotop -p $PID
$ sudo biolatency-bpfcc 30
2
3
4
5
6
7
8
9
10
11
12
13
修法:
- 异步化:
std::async、coroutine、io_uring; - 缓存:把慢的远程调用结果本地缓存;
- 批处理:N 次小请求合并成 1 次大请求;
- 减少串行依赖:能并行的别串行。
# 12. 工程化最佳实践
单次性能调优能力强 ≠ 团队整体性能水位高。这一节讲"怎么把 perf 变成持续基础设施"。
# 12.1 编译选项基线
性能分析的前置条件——编译选项。任何 release 二进制都应满足:
g++ -O2 -g # 优化 + 符号
-fno-omit-frame-pointer # 保留 fp (火焰图必需)
-fno-plt # 跳过 PLT, 减少间接跳转
-gsplit-dwarf # 分离 DWARF, 二进制小但调试可用
-Wl,--build-id # build-id 索引
...
2
3
4
5
6
-fno-omit-frame-pointer 的代价:少 1 个通用寄存器,性能影响通常 1%-3%——远小于"看不到栈回溯"的代价。
部署时 strip 但保留符号:
# 同 04.CoreDump破案实录 第 9.2 节
objcopy --only-keep-debug svc svc.debug
strip --strip-debug --strip-unneeded svc
objcopy --add-gnu-debuglink=svc.debug svc
# svc.debug 上传符号服务器
2
3
4
5
6
CMake 模板:
add_executable(svc ${SRCS})
# 性能基线
target_compile_options(svc PRIVATE
-O2 -g
-fno-omit-frame-pointer
)
# 链接选项
target_link_options(svc PRIVATE
-Wl,--build-id
-Wl,--gdb-index
)
# 生成调试包
add_custom_command(TARGET svc POST_BUILD
COMMAND objcopy --only-keep-debug $<TARGET_FILE:svc> $<TARGET_FILE:svc>.debug
COMMAND objcopy --strip-debug $<TARGET_FILE:svc>
COMMAND objcopy --add-gnu-debuglink=$<TARGET_FILE:svc>.debug $<TARGET_FILE:svc>
)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 12.2 持续 profiling
"performance is a feature, not an afterthought" —— 现代团队不再"等出问题再 profile",而是持续低频采样所有生产服务。
架构:
┌──────────────┐ 采样 (每 10 秒一次, 1 秒窗口)
│ 生产 svc 实例 │ ─────────────────────┐
└──────────────┘ │
│ ▼
│ 旁路采样 agent ┌─────────────────┐
└────────────────────────→│ Profile 接收器 │
│ (parca / pyro) │
└────────┬────────┘
│ 长存
▼
┌─────────────────┐
│ 对象存储 + 索引 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Web UI (查询/ │
│ 火焰图/差分) │
└─────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
主流方案:
| 方案 | 采样原理 | 适用 |
|---|---|---|
| Pyroscope | eBPF / perf_event_open | 多语言 |
| Parca | eBPF | 容器原生 |
| Google Cloud Profiler | 集成 SDK | GCP 用户 |
| Datadog Continuous Profiler | agent | 商用 |
| 自研 | perf record + 上传 | 定制 |
自研最小方案:
#!/bin/bash
# /usr/local/bin/profile-agent.sh
SVC=$1
INTERVAL=${2:-600} # 10 分钟一次
DURATION=${3:-10} # 每次采 10 秒
while true; do
PID=$(pidof $SVC)
[ -z "$PID" ] && sleep $INTERVAL && continue
OUT=/tmp/perf-$(hostname)-$(date +%s).data
sudo perf record -F 99 -p $PID -g -o $OUT -- sleep $DURATION 2>/dev/null
# 转 folded
sudo perf script -i $OUT | stackcollapse-perf.pl > ${OUT}.folded
# 上传对象存储
aws s3 cp ${OUT}.folded s3://my-profiles/$SVC/$(date +%Y-%m-%d)/
rm -f $OUT ${OUT}.folded
sleep $INTERVAL
done
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 12.3 自动差分告警
持续 profiling 的真正价值——自动发现性能回归。
思路:每天凌晨跑一个对比 job,把今天的火焰图和上周同一时段对比,红色块超过阈值就告警。
# diff_alert.py
import subprocess, sys, json
def fetch(date, hour):
return subprocess.run(
["aws", "s3", "cp", f"s3://my-profiles/svc/{date}/{hour}.folded", "-"],
capture_output=True
).stdout
today = fetch("2024-01-21", "10")
last_week = fetch("2024-01-14", "10")
# difffolded 输出: 函数名 旧次数 新次数
diff = subprocess.run(
["difffolded.pl", "-", "-"],
input=last_week + b"\n" + today,
capture_output=True
).stdout.decode()
regressions = []
for line in diff.split("\n"):
parts = line.split(";")
if len(parts) < 2: continue
func = parts[-1].split(" ")[0]
old, new = int(parts[-1].split(" ")[1]), int(parts[-1].split(" ")[2])
if new > old * 1.2 and new - old > 100: # > 20% 且 > 100 样本
regressions.append((func, old, new))
if regressions:
print(f"⚠️ Performance regressions:")
for func, old, new in regressions:
delta = (new - old) / old * 100
print(f" {func}: {old} → {new} ({delta:+.1f}%)")
sys.exit(1)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
挂到 cronjob:每天 10 点跑一次,发现回归发 IM。
# 12.4 性能门禁 CI
代码合并到 master 之前,CI 跑一次 microbenchmark,超出阈值则 fail。
典型流程:
# .github/workflows/perf-gate.yml
name: Performance Gate
on:
pull_request:
branches: [master]
jobs:
bench:
runs-on: perf-machine # 专用机器, 减少 noise
steps:
- uses: actions/checkout@v3
- run: cmake -B build && cmake --build build
# 运行 benchmark
- run: |
./build/bench --benchmark_format=json > current.json
# 拉基线 (master 的最近一次结果)
- run: |
aws s3 cp s3://perf-baselines/svc/master.json baseline.json
# 比对 (允许 5% 退化)
- run: |
python compare_bench.py baseline.json current.json --threshold=5
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
对比脚本核心逻辑:
# compare_bench.py
import json, sys, argparse
ap = argparse.ArgumentParser()
ap.add_argument("baseline")
ap.add_argument("current")
ap.add_argument("--threshold", type=float, default=10.0)
args = ap.parse_args()
baseline = {b["name"]: b["cpu_time"] for b in json.load(open(args.baseline))["benchmarks"]}
current = {b["name"]: b["cpu_time"] for b in json.load(open(args.current ))["benchmarks"]}
regressions = []
for name, cur in current.items():
base = baseline.get(name)
if not base: continue
delta = (cur - base) / base * 100
if delta > args.threshold:
regressions.append((name, base, cur, delta))
if regressions:
print("❌ Performance regressions detected:")
for name, base, cur, delta in regressions:
print(f" {name}: {base:.1f} → {cur:.1f} ns/iter ({delta:+.1f}%)")
sys.exit(1)
print("✅ No regression.")
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
CI 跑性能用例的注意事项:
- 专用机器(不能是共享 runner——CPU 抖动太大);
- 绑核 + isolcpus(避免被中断打扰);
- 关 CPU frequency scaling(
cpupower frequency-set -g performance); - 多次运行取中位数(不是平均);
- 基线随主分支推进而更新(避免基线"漂移")。
# 12.5 成熟度模型
| Level | 能力 |
|---|---|
| L1 | 知道 top、perf top,能看哪个进程哪个函数忙 |
| L2 | 会用 perf record + 火焰图、perf stat 看 IPC |
| L3 | 区分 on-CPU / off-CPU,会用差分火焰图、perf annotate 到指令级 |
| L4 | 团队有持续 profiling、性能基线库、CI 性能门禁 |
| L5 | 全链路 profiling(应用 + 内核 + 硬件)、自动回归告警、Postmortem 闭环 |
L1→L2:投入半天学 flamegraph,所有工程师都该具备。 L2→L3:差分思维 + off-CPU 视角,这是"高级 SRE"的分水岭。 L3→L4:基础设施工作,1 个 SRE 团队 1 个月可建。 L4→L5:年度计划级别,需要专项团队。
ROI 排序(投入回报比从高到低):
- 编译选项基线(
-fno-omit-frame-pointer+ 分离符号)—— 1 天工作量,受益终身; - CI 性能门禁(5% 阈值)—— 1 周工作量,防绝大多数回归;
- 持续 profiling(自研最小版本)—— 2 周工作量,发现 90% 性能问题;
- 自动差分告警 —— 1 周工作量,发现 80% 慢回归;
- 全链路追踪 + 多维分析 —— 1 个月工作量,覆盖最难场景。
# 13. 综合案例串讲
# 13.1 案例真相揭晓
回到第 1 章的两条主线,七个疑问现在能逐条作答:
| 疑问 | 答案 |
|---|---|
| ① 性能问题有几大类? 怎么选采样工具? | 第 2、3 章:9 种成因 + 采样/计数/tracing 三档工具 |
| ② perf 子命令体系? | 第 4 章:top(实时)/ record + report(事后)/ stat(计数)/ script(导出)/ trace(系统调用) |
| ③ -F 99 为什么不是 100? | 第 5.2 节:避开业务节奏共振,质数附近的"反共振"频率 |
| ④ 火焰图横宽到底代表什么? | 第 7.1 节:横宽 = CPU 时间占比;纵高 = 调用栈深度(与 CPU 无关) |
| ⑤ 火焰图全是 [unknown]? | 第 9.1 节:5 大根因——strip / -g 缺 / 库无 dbgsym / 无 fp / 容器符号 |
| ⑥ on-CPU 看不出怎么办? | 第 8.3 节:off-CPU 火焰图——锁等待 / IO / 调度全暴露 |
| ⑦ 怎么工程化? | 第 12 章:编译基线 → 持续 profiling → 差分告警 → CI 门禁 |
主线一最终诊断:
搜索服务每个请求都调
QueryContext::clone(),里面把 5000 条std::map<string, string>配置整个深拷贝一遍——单次 1MB 字符串构造。QPS 8 千 × 1MB = 8GB/s 内存带宽,吃掉 80GB/s 单机带宽的 10%,CPU 全花在 string 构造和 malloc/free 上。火焰图顶层std::string::string平顶 42% +malloc/free15%,三栈合一直接定位。
修复路径:
// 改前: 每次请求深拷贝
QueryContext QueryContext::clone() {
QueryContext c;
c.cfg_ = cfg_; // map<string, string> 深拷贝
c.headers_ = headers_; // 同上
return c;
}
// 改后: 共享只读, COW 写时拷贝
class QueryContext {
std::shared_ptr<const Config> cfg_; // const + shared_ptr
std::shared_ptr<const Headers> headers_;
public:
QueryContext clone() const {
return *this; // 默认拷贝构造, shared_ptr 引用计数+1
}
// 修改时必须先 detach (COW)
Config& mutable_cfg() {
if (!cfg_.unique()) cfg_ = std::make_shared<Config>(*cfg_);
return const_cast<Config&>(*cfg_);
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
效果:CPU 798% → 220%,QPS 2 千 → 9.5 千。
主线二最终诊断:
把
vector<int>改成vector<MyStruct>(24 字节)后,每个 cache line 只能装 2.66 个元素 vs 改前 16 个。遍历时 prefetcher 命中率从 95% 掉到 38%。perf stat显示 IPC 从 2.37 跌到 0.91,cache miss 从 3.2% 飙到 62.4%——CPU 大部分时间在等内存,不是计算。
修复路径:
// 方案 A: SoA (Structure of Arrays)
struct Items {
std::vector<int> ids;
std::vector<int> types;
std::vector<double> weights;
std::vector<double> scores;
};
for (auto id : items.ids) sum += compute(id); // 4 字节顺序访问
// 方案 B: hot/cold 拆分
struct ItemHot { int id; int type; }; // 8 字节, 一个 cache line 8 个
struct ItemCold { double weight, score; };
std::vector<ItemHot> hot;
std::vector<ItemCold> cold;
for (auto& h : hot) sum += compute(h.id);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
效果:QPS 2.3 万 → 4.8 万(接近改前 5 万)。
# 13.2 一次性能定位全景
把"周二上午 10:30 告警 → 中午 12:00 修复上线"的全过程串成一棵知识树(以主线一为例):
告警: search-svc CPU 100%, QPS 掉一半
│
├─ 宏观第一刀
│ ├─ top: %CPU 798%, 8 核打满 ─── 第 4 节 / 第 10.1
│ ├─ perf stat: IPC 1.14, cache 3.2% ─── 第 4.4 节
│ └─ 结论: CPU bound, 算法/代码重 ─── 第 3.5 排障四象限
│
├─ 工具准备
│ ├─ 二进制有 -fno-omit-frame-pointer ✓ ─── 第 12.1
│ ├─ 调试包从符号服务器拉 ─── 第 9.1
│ └─ FlameGraph 工具就位 ─── 第 6.1
│
├─ 函数级定位 (perf top)
│ ├─ std::string::string 42% ─── 第 4.1
│ ├─ std::_Hashtable::find 18%
│ ├─ malloc/free 15%
│ └─ 怀疑: 字符串拷贝 + 频繁分配
│
├─ 调用链锁定 (火焰图)
│ ├─ 顶层平顶 std::string::string ─── 第 7.2
│ ├─ 父帧 QueryContext::clone 40% ─── 第 7.4
│ ├─ 再上 on_request 45%
│ └─ 链路: 每请求 → clone → 5000 string copy
│
├─ 指令级精确 (perf annotate)
│ ├─ clone 内部 88% 在 string ctor 那两行 ─── 第 7.4 / 10.3
│ └─ 根因落定
│
├─ 修复 + 验证
│ ├─ shared_ptr<const Config> + COW ─── 第 13.1
│ ├─ 部署 → 重新采样
│ ├─ 差分火焰图: string ctor 蓝色 -38% ─── 第 8.1 / 10.4
│ └─ QPS 9.5 千, CPU 220%, 修复确认
│
└─ 沉淀
├─ 加 benchmark: BM_QueryContext_Clone ─── 第 10.5
├─ baseline 入库 ─── 第 12.4
├─ CI 门禁: 5% 阈值 ─── 第 12.4
└─ 持续 profiling 接入 ─── 第 12.2
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
整个过程 1.5 小时——熟练后能压到 30 分钟。关键是每一步都有对应章节的方法论支撑,没有任何一步靠"猜"。
# 13.3 设计哲学回扣
整理本篇的四条跨篇适用的设计哲学:
哲学 1:测量 > 直觉
性能直觉常常是错的——主线一谁能想到 "string copy 占 42% CPU"?主线二谁能想到 "改个结构体大小 QPS 跌一半"?好的工程师不靠猜,靠测量。perf 让"瓶颈在哪"从猜测变成数据。这条与 01.信号崩溃快速排查 第 13.3 节哲学 2 同源——"先看错误信息再开调试器",本篇翻译成"先看 perf stat 再上火焰图"。
哲学 2:横宽 ≠ 高度
火焰图最被误读的就是这点——很多人盯着"高耸的尖塔"想优化,结果尖塔再高横宽才 1%,改完毫无效果。性能优化的 80/20 法则在火焰图上就是横宽——找最宽的平顶下手。这是普通工程师和资深工程师视觉的根本差距。
哲学 3:on-CPU 不够,off-CPU 才完整
普通 perf 只看 "CPU 上发生了什么"。但生产里最难调的问题是 "CPU 上没在跑,但用户在等"——锁竞争、IO 阻塞、调度延迟。off-CPU 火焰图把这些"看不见的等待"也画出来。on-CPU 看竞争 CPU 的代码,off-CPU 看竞争资源的代码——两者合一才是完整画像。这条对应 04.CoreDump破案实录 哲学 3 的"crash site ≠ bug site"——表面现象不是真相。
哲学 4:单次优化 → 持续不退化
修复 1 个性能问题不难,难的是 6 个月后不让它回来。无数团队的痛点是"上次调好的性能这次又烂了,没人知道是哪个 commit 引入的"。持续 profiling + 差分火焰图 + CI 门禁三件套,把"性能"从一次性英雄战役变成日常自动化。这是 L4/L5 团队和 L1/L2 团队的根本差距。
# 13.4 perf 火焰图速查表
一张图保存以备查:
| 现象 | 第一查 | 工具命令 |
|---|---|---|
| CPU 100% 但找不到凶手 | perf top → 火焰图 | perf top -p $PID |
| QPS 跌但 CPU 不忙 | off-CPU 火焰图 | offcputime-bpfcc -df -p $PID 30 |
| IPC < 1.0 | perf stat 看 cache miss | perf stat -e cycles,instructions,cache-misses |
| 火焰图全 [unknown] | 检查 fp/symbol/-g | 改 --call-graph dwarf |
| 火焰图很扁但慢 | annotate 看汇编 | perf annotate -i perf.data |
| 单核打满 | 看线程 CPU 分布 | top -H -p $PID |
| sys% > 30% | perf trace 看 syscall | perf trace -p $PID -s |
| 锁竞争 | off-CPU + futex | bpftrace -e 'kprobe:__lll_lock_wait' |
| 内存分配热 | heaptrack | heaptrack -p $PID |
| 性能回归 | 差分火焰图 | difffolded.pl before after \| flamegraph.pl |
60 秒诊断命令清单:
# 1. 宏观: CPU 是不是真在跑
top -p $(pidof svc)
perf stat -p $(pidof svc) -- sleep 5
# 2. 函数: 谁占 CPU 最多
sudo perf top -p $(pidof svc)
# 3. 调用链: 火焰图 (30 秒)
sudo perf record -F 99 -p $(pidof svc) -g -- sleep 30
sudo perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg
# 4. 指令级: 哪条指令最热
sudo perf annotate -i perf.data <function-name>
# 5. off-CPU: 等什么资源
sudo offcputime-bpfcc -df -p $(pidof svc) 30 > off.folded
flamegraph.pl --colors=io off.folded > off.svg
# 6. 差分: 改后真的快了吗
sudo perf record ... -o after.data
difffolded.pl before.folded after.folded | flamegraph.pl > diff.svg
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
症状 → 第一工具(与第 11 章互补,本篇侧重"快速分流"):
%CPU 高 → perf top + 火焰图
%CPU 不高但慢 → off-CPU 火焰图
sys% > 30% → perf trace
cs > 50K/s → perf sched
IPC < 1.0 → perf stat (cache miss / branch miss)
malloc 热 → heaptrack / jemalloc prof
锁热点 → off-CPU + bpftrace mutex
火焰图扁平 → perf annotate (汇编级)
回归排查 → 差分火焰图
不是 CPU bound → bpftrace 看任意子系统
2
3
4
5
6
7
8
9
10
生产 perf 时间分布经验:
诊断阶段 平均耗时
────────────── ────
perf top 看一眼 30 sec
perf record 30 秒 30 sec + 写盘
火焰图生成 15 sec
火焰图阅读 1-5 min
锁定根因 5-30 min (因人而异)
修复 + 验证 10-60 min
─────────────────────────
总计 (熟练) 15-90 min
2
3
4
5
6
7
8
9
10
# 13.5 思考题
你的服务 CPU 80%,火焰图顶层平顶是
std::__cxx11::basic_string<char>::compare——但代码里没有显式 string compare。可能是哪些隐式 compare 触发的?怎么定位?perf record -F 99 -g跑出来的火焰图有 30% 是[unknown]。你检查后发现-fno-omit-frame-pointer是开的、-g也带了。还有什么可能原因?一个服务在 8 核机器上 QPS 5 万,扩到 16 核机器只到 6 万。
perf stat看 IPC 一致、cache miss 一致。可能是什么瓶颈?用什么工具确认?主线二的 cache miss 问题,如果不能改数据结构(API 兼容性),还有什么优化方法?提示:考虑访问模式 / 软件 prefetch。
一个 latency 敏感服务,p50 延迟 1ms 但 p99 延迟 50ms。
perf top看一切正常。如何定位 p99 抖动来源?你的同事用
perf record --call-graph dwarf,8192抓栈,但栈在第 8 帧就截断(业务调用栈深 30 层)。怎么修?火焰图显示
__pthread_mutex_lock占 25%——但你只是用了std::cout。这两者之间有什么关系?怎么验证?用差分火焰图对比两个版本,发现"红色"和"蓝色"几乎相互抵消(总宽度没变)——但 QPS 实测确实快了 10%。可能是什么原因?
你想给一个完全 strip 的生产二进制做火焰图(没有任何 debug 包)——还有救吗?哪些信息能恢复,哪些必丢?
假设你是某团队的性能负责人,预算 1 个月、2 个工程师,要把团队从"出问题才看 perf"提升到"持续 profiling + CI 门禁"。你会按什么 ROI 顺序投入?为什么?
调试器告诉你"代码哪里错",profiler 告诉你"代码哪里慢"。 但真正的性能能力,是从一个 IPC 数字、一个火焰图平顶出发,把"机器在哪一纳秒等了什么"的微观图景还原回来的能力。
下一篇:本篇讲了"代码不崩但慢怎么办",至此排查篇五件套(01.信号崩溃快速排查 → 02.ASan内存诊断 → 03.GDB命令速查表 → 04.CoreDump破案实录 → 05.perf火焰图实战)已经形成完整闭环:事前阻断 → 现场调试 → 事后破案 → 持续优化。配套阅读:01.进程地址空间布局(理解 cache line / TLB / 内存层级是 perf annotate 的物理基础)。