编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

    • 开发技巧

      • 信号崩溃快速排查
      • ASan内存三件套
      • GDB十命令速查
      • CoreDump破案
      • perf火焰图实战
        • 1. 案例引入:两条主线
          • 1.1 主线一:CPU 100% 找不到凶手
          • 1.2 主线二:QPS 莫名腰斩
          • 1.3 顺藤摸到根因
          • 1.4 本篇要回答什么
        • 2. 性能问题九种成因
          • 2.1 算法复杂度爆炸
          • 2.2 频繁内存分配
          • 2.3 锁竞争
          • 2.4 cache miss
          • 2.5 系统调用过多
          • 2.6 上下文切换
          • 2.7 IO 阻塞
          • 2.8 字符串拷贝
          • 2.9 编译优化未开
        • 3. perf 在排障链定位
          • 3.1 用什么工具找瓶颈
          • 3.2 采样 vs 计数 vs tracing
          • 3.3 perf 的硬件基础
          • 3.4 与其他工具互补
          • 3.5 排障四象限
        • 4. perf 命令体系
          • 4.1 perf top 实时
          • 4.2 perf record 采样
          • 4.3 perf report 文本
          • 4.4 perf stat 计数
          • 4.5 perf script 导出
          • 4.6 perf trace 类 strace
        • 5. 采样原理与精度
          • 5.1 PMU 与硬件计数器
          • 5.2 采样频率怎么选
          • 5.3 三种栈回溯方式
          • 5.4 内核与用户态
          • 5.5 采样的盲区
        • 6. 火焰图三步法
          • 6.1 第一步采样
          • 6.2 第二步折叠栈
          • 6.3 第三步生成 SVG
          • 6.4 一行命令模板
          • 6.5 容器与远程
        • 7. 火焰图怎么读
          • 7.1 横宽与纵高
          • 7.2 找平顶找尖塔
          • 7.3 颜色不是含义
          • 7.4 主线一火焰图
          • 7.5 主线二火焰图
          • 7.6 五个常见误读
        • 8. 高级火焰图变体
          • 8.1 差分火焰图
          • 8.2 反转火焰图
          • 8.3 off-CPU 火焰图
          • 8.4 内存与分配
          • 8.5 锁竞争火焰图
          • 8.6 BPF 火焰图
        • 9. 符号与离线分析
          • 9.1 没有符号怎么办
          • 9.2 JIT 与解释器
          • 9.3 容器内的 perf
          • 9.4 远端采样落地
          • 9.5 perf.data 兼容性
        • 10. 五步定位方法论
          • 10.1 先看宏观指标
          • 10.2 缩小到函数
          • 10.3 看汇编与缓存
          • 10.4 假设修复对比
          • 10.5 沉淀回归
        • 11. 典型场景速查
          • 11.1 单核打满
          • 11.2 多核不均
          • 11.3 系统态过高
          • 11.4 锁热点
          • 11.5 内存分配热
          • 11.6 cache miss 高
          • 11.7 慢但不忙
        • 12. 工程化最佳实践
          • 12.1 编译选项基线
          • 12.2 持续 profiling
          • 12.3 自动差分告警
          • 12.4 性能门禁 CI
          • 12.5 成熟度模型
        • 13. 综合案例串讲
          • 13.1 案例真相揭晓
          • 13.2 一次性能定位全景
          • 13.3 设计哲学回扣
          • 13.4 perf 火焰图速查表
          • 13.5 思考题
      • 迭代器失效陷阱
      • 智能指针选型
      • 异常安全RAII
      • 多线程锁选型
      • 编译期防御
  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Cpp入门到精通
  • 开发技巧
杨充
2026-06-15
目录

perf火焰图实战

# 第24章:perf 火焰图实战

# 目录介绍

  • 1. 案例引入:两条主线
    • 1.1 主线一:CPU 100% 找不到凶手
    • 1.2 主线二:QPS 莫名腰斩
    • 1.3 顺藤摸到根因
    • 1.4 本篇要回答什么
  • 2. 性能问题九种成因
    • 2.1 算法复杂度爆炸
    • 2.2 频繁内存分配
    • 2.3 锁竞争
    • 2.4 cache miss
    • 2.5 系统调用过多
    • 2.6 上下文切换
    • 2.7 IO 阻塞
    • 2.8 字符串拷贝
    • 2.9 编译优化未开
  • 3. perf 在排障链定位
    • 3.1 用什么工具找瓶颈
    • 3.2 采样 vs 计数 vs tracing
    • 3.3 perf 的硬件基础
    • 3.4 与其他工具互补
    • 3.5 排障四象限
  • 4. perf 命令体系
    • 4.1 perf top 实时
    • 4.2 perf record 采样
    • 4.3 perf report 文本
    • 4.4 perf stat 计数
    • 4.5 perf script 导出
    • 4.6 perf trace 类 strace
  • 5. 采样原理与精度
    • 5.1 PMU 与硬件计数器
    • 5.2 采样频率怎么选
    • 5.3 三种栈回溯方式
    • 5.4 内核与用户态
    • 5.5 采样的盲区
  • 6. 火焰图三步法
    • 6.1 第一步采样
    • 6.2 第二步折叠栈
    • 6.3 第三步生成 SVG
    • 6.4 一行命令模板
    • 6.5 容器与远程
  • 7. 火焰图怎么读
    • 7.1 横宽与纵高
    • 7.2 找平顶找尖塔
    • 7.3 颜色不是含义
    • 7.4 主线一火焰图
    • 7.5 主线二火焰图
    • 7.6 五个常见误读
  • 8. 高级火焰图变体
    • 8.1 差分火焰图
    • 8.2 反转火焰图
    • 8.3 off-CPU 火焰图
    • 8.4 内存与分配
    • 8.5 锁竞争火焰图
    • 8.6 BPF 火焰图
  • 9. 符号与离线分析
    • 9.1 没有符号怎么办
    • 9.2 JIT 与解释器
    • 9.3 容器内的 perf
    • 9.4 远端采样落地
    • 9.5 perf.data 兼容性
  • 10. 五步定位方法论
    • 10.1 先看宏观指标
    • 10.2 缩小到函数
    • 10.3 看汇编与缓存
    • 10.4 假设修复对比
    • 10.5 沉淀回归
  • 11. 典型场景速查
    • 11.1 单核打满
    • 11.2 多核不均
    • 11.3 系统态过高
    • 11.4 锁热点
    • 11.5 内存分配热
    • 11.6 cache miss 高
    • 11.7 慢但不忙
  • 12. 工程化最佳实践
    • 12.1 编译选项基线
    • 12.2 持续 profiling
    • 12.3 自动差分告警
    • 12.4 性能门禁 CI
    • 12.5 成熟度模型
  • 13. 综合案例串讲
    • 13.1 案例真相揭晓
    • 13.2 一次性能定位全景
    • 13.3 设计哲学回扣
    • 13.4 perf 火焰图速查表
    • 13.5 思考题

# 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     ← 八个核全打满
1
2
3

进程 CPU 798%(8 核全打满),但 top 不能告诉你进程内部哪个函数在跑。pstack 看了几次:

$ pstack 24891
Thread 1: ...QueryParser::parse...
Thread 2: ...SearchEngine::search...
Thread 3: ...IndexReader::read...
1
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
   ...
1
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  ⚠️
1
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 倍!
1
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 章
1
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) ...
1
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());
1
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];
}
1
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;
1
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());
1
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)
1
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);
1
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));
}
1
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)
   ...
1
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 阻塞分类
        └─────────────────────┘
1
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
1
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 / jemalloc profile;
  • 网络 IO → bpftrace / tcpdump;
  • 微架构 → Intel VTune / perf c2c。

# 3.5 排障四象限

          忙 (CPU 高)               不忙 (CPU 低)
       ┌─────────────────────┬─────────────────────┐
快 (延 │ ① CPU bound 优化空间  │ ② IO bound          │
迟低)  │   - 算法/cache/锁   │    - 网络 / 磁盘     │
       │   - 工具: 火焰图     │    - 工具: off-CPU   │
       ├─────────────────────┼─────────────────────┤
慢 (延 │ ③ 算法 + 系统都有问题  │ ④ 阻塞 / 锁 / 调度    │
迟高)  │   - 多重瓶颈         │    - 工具: off-CPU + │
       │   - 全套 perf       │      perf sched     │
       └─────────────────────┴─────────────────────┘
1
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
   ...
1
2
3
4
5
6
7
8
9
10

适用:现场告警时第一刀——服务还在跑、还来得及定位。

几个常用键:

进入 perf top 后:
  k       只看内核态 / 用户态切换
  f       筛选符号
  s       按符号排序
  Enter   下钻 (annotate, 看汇编)
  q       退出
1
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 (二进制)
1
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
1
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                ← 谁调它最多
1
2
3
4
5
6
7
8

TUI 关键操作:

↑↓        移动
Enter     展开调用关系 (show callers / show callees)
+         展开
-         折叠
/         搜索符号
a         annotate 看汇编
t         切换调用方/被调方视图
1
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%
1
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
1
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
1
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)
        ...
1
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 的系统调用才显示
1
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│  全局开关         │
│  └────────────────────────┘                  │
└──────────────────────────────────────────────┘
1
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 重新设阈值, 继续
1
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 万)
1
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
1
2
3
维度 表现
速度 最快(< 100 ns/帧)
精度 高
二进制要求 必须保留 fp
缺陷 -O2 默认丢 fp(少占一个寄存器)

方式 B:dwarf(DWARF 展开)

读 .eh_frame 段的展开规则——不需要 fp,但需要在采样点拷贝栈数据回内核:

perf record -F 99 --call-graph dwarf,16384 ...
#                                ↑
#                  每次采样拷贝 16K 栈数据 (默认 8K, 深栈不够)
1
2
3
维度 表现
速度 慢(拷贝栈,开销 5%-10%)
精度 高
二进制要求 有 .eh_frame(默认就有)
缺陷 高 QPS 服务开销显著

方式 C:lbr(Last Branch Record,硬件辅助)

Intel CPU 提供的 LBR 寄存器,硬件记录最近 16/32 条分支跳转:

perf record -F 99 --call-graph lbr ...
1
维度 表现
速度 极快(硬件)
精度 受限(栈深度 ≤ LBR 大小)
二进制要求 无
缺陷 深栈截断;ARM/旧 Intel 不支持

选哪个:

有 -fno-omit-frame-pointer  → fp (最优)
没有 fp, 二进制带 -g         → dwarf
都没有 + Intel CPU           → lbr (浅栈也能用)
栈很浅 (< 16 帧) + Intel     → lbr (开销最小)
1
2
3
4

# 5.4 内核与用户态

perf 默认采样用户态 + 内核态全栈:

# 看完整栈 (默认)
perf record -g ...

# 只看用户态 (业务函数)
perf record -g -e cycles:u ...

# 只看内核态 (系统调用 / 中断)
perf record -g -e cycles:k ...
1
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
1
2
3
4
5
6
7
8
9
10
11

kptr_restrict=0 才能看到内核符号名(不是裸地址):

$ echo 0 | sudo tee /proc/sys/kernel/kptr_restrict
1

# 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
1
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
1
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
1
2
3
4
5

折叠之前长这样(一条采样多行):

search-svc 24891 [005] 1234.890123:    cycles:
        7f3a8b478e2c std::string::string+0x12
        7f3a8b465311 QueryContext::clone+0x45
        7f3a8b465200 on_request+0x88
        ...
1
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
...
1
2
3
4

# 6.3 第三步生成 SVG

~/flamegraph/flamegraph.pl out.folded > flame.svg
1

打开 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
1
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
1
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"
1
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
1
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
1
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
1
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 占比)
1
2
3
4
5
6
7
8
9
10
11
12

两个轴的含义截然不同:

轴 含义 量化
横向(宽度) CPU 时间占比 一个矩形宽度 = 它在采样里出现的总次数(含被调用层)
纵向(高度) 调用栈深度 自下而上:main → ... → 当前 PC

关键判断:

  • 宽:值得优化的函数;
  • 窄:忽略,再快也省不了多少;
  • 高:栈深,但和 CPU 占用无关——栈再深,宽度才是主角。

正确读法:只看顶层的"平顶"——那才是真正跑的代码。下面的栈帧只是路径。

错误读法: 看到 main 横通到底, 以为 main 慢
正确读法: main 横通到底是因为它是入口, 真正慢的是它顶上某个细分函数
1
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                        │
        └─────────────────────────────────────┘
含义: 算法本身重 + 系统开销重, 双重瓶颈
解法: 重构算法
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

视觉直觉:

  • 顶层有连续平顶 ≥ 火焰图总宽 5% → 重点优化对象;
  • 顶层不连续,多个小峰 → 没有热点,性能均匀分散,难优化;
  • 整图扁平(高度低) → 调用栈短,逻辑简单,bottleneck 通常在叶子函数。

# 7.3 颜色不是含义

最大的误读:以为火焰图的颜色有意义。

实际上:默认颜色(暖色系黄→红)只是为了视觉区分相邻函数——颜色深浅完全随机。

# 不同 colorscheme 一对比就明白
flamegraph.pl --colors=hot   ...   # 默认: 黄红色
flamegraph.pl --colors=mem   ...   # 绿色
flamegraph.pl --colors=java  ...   # 按语言分色
1
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                                        │
└─────────────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13

读法:

  1. 顶层平顶 std::string::string 占 42% → 第一目标;
  2. 它的父帧是 QueryContext::clone 40% → 字符串构造来自 clone;
  3. 再上一层 on_request 45% → clone 在每次请求都被调;
  4. 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%
1
2
3
4
5
6
7
8
9

精确到那两条指令——根因找到。修复:shared_ptr<const Config> + COW。

# 7.5 主线二火焰图

主线二的火焰图反而看起来很平淡:

┌──────────────────────────────────────────────────────────┐
│                95% compute (含内联展开)                   │
├──────────────────────────────────────────────────────────┤
│                95% main_loop                              │
├──────────────────────────────────────────────────────────┤
│                main                                       │
└──────────────────────────────────────────────────────────┘
1
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%)       ← 关键!
1
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
1
2
3
4
5
6
7

perf c2c 工具更精细——能告诉你哪个 cache line 在哪些核之间反复弹(false sharing):

$ sudo perf c2c record ./bench
$ sudo perf c2c report
1
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 场景)
1
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
1
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
1
普通火焰图                    反转火焰图
┌──────────┐                ┌──────────┐
│ leaf     │                │ main     │
├──────────┤                ├──────────┤
│ inner    │                │ inner    │
├──────────┤                ├──────────┤
│ main     │                │ leaf     │   ← 叶子在底, 往上聚
└──────────┘                └──────────┘
1
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
1
2
3
4
5
6
7
8
9

读法:

普通 on-CPU 火焰图               off-CPU 火焰图
横宽 = 在 CPU 上花的时间          横宽 = 不在 CPU 上的时间
顶层 = 哪段代码在跑               顶层 = 线程从哪里被换下
                                       (futex_wait, read, epoll_wait 等)
1
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
1
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
1
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
1
2

# 8.5 锁竞争火焰图

锁等待属于 off-CPU——但有专门的锁竞争分析工具:

A. mutextrace(基于 BPF)

sudo /usr/sbin/mutextrace -p $(pidof svc) 30
# 输出: 哪个 mutex 被等了多久, 哪个 PID 持有
1
2

B. perf lock(部分内核支持)

sudo perf lock record -p $PID -- sleep 30
sudo perf lock report
1
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]);
}'
1
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);
        }
    }
};
1
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
1
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
1
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
1
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
...
1
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 ...
1
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
1
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
1
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 ...
1
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"]
1
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
1
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=. | ...
1
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
1
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)
1
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 + 性能门禁)      │
  │    防止下次回归                           │
  └─────────────────────────────────────────┘
1
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
1
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%         ← ⚠️ 严重!
1
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
   ...
1
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
1
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
1

看哪些指令"样本数"最多:

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)
    ...
1
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;
}
1
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
1
2
compute():
  Percent | Instruction
   75.20 | mov    0x0(%rdi),%eax     ← 主要 cache miss 在这
    ...
1
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 */
};
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;
1
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
1
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%  ███████████  (变成新最宽, 但绝对值小很多)
  ...
1
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);
1
2
3
4
5
6
7
8
9
10
11
12

Step B:基线落库

$ ./bench_clone --benchmark_format=json > baseline.json
# 提交到代码仓的 perf/ 目录
1
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
1
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%, 其他核空闲
1

特征:业务是单线程模型,或者多线程但任务分配不均,所有重活都在一个线程。

第一刀:

# 看 CPU 占用最高的线程
$ top -H -p $(pidof svc)
$ perf top -p $TID                    # TID = 那个忙的线程
1
2
3

修法:

  • 单线程逻辑能并行化的部分用 std::thread / 线程池;
  • 任务分配用 work-stealing 队列;
  • 整个进程改成多 worker(fork / 多进程)。

# 11.2 多核不均

top: 一些核 90%, 另一些核 10%
1

特征:线程间负载不均衡。常见原因:分片不均(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
# 比较两个火焰图
1
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%
1

特征:进程频繁陷入内核——大量系统调用、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
1
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 占大头
1
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();
}'
1
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%
1

特征:热路径频繁堆分配——主线一就是。

第一刀:

# 找谁在 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
1
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
1

特征:访存瓶颈——主线二就是。

第一刀:

$ 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
1
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 (用户感觉慢)
1
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
1
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 索引
    ...
1
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 上传符号服务器
1
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>
)
1
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 (查询/   │
                                 │ 火焰图/差分)     │
                                 └─────────────────┘
1
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
1
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)
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
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

对比脚本核心逻辑:

# 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.")
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

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 排序(投入回报比从高到低):

  1. 编译选项基线(-fno-omit-frame-pointer + 分离符号)—— 1 天工作量,受益终身;
  2. CI 性能门禁(5% 阈值)—— 1 周工作量,防绝大多数回归;
  3. 持续 profiling(自研最小版本)—— 2 周工作量,发现 90% 性能问题;
  4. 自动差分告警 —— 1 周工作量,发现 80% 慢回归;
  5. 全链路追踪 + 多维分析 —— 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/free 15%,三栈合一直接定位。

修复路径:

// 改前: 每次请求深拷贝
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_);
    }
};
1
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);
1
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
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
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
1
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 看任意子系统
1
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
1
2
3
4
5
6
7
8
9
10

# 13.5 思考题

  1. 你的服务 CPU 80%,火焰图顶层平顶是 std::__cxx11::basic_string<char>::compare——但代码里没有显式 string compare。可能是哪些隐式 compare 触发的?怎么定位?

  2. perf record -F 99 -g 跑出来的火焰图有 30% 是 [unknown]。你检查后发现 -fno-omit-frame-pointer 是开的、-g 也带了。还有什么可能原因?

  3. 一个服务在 8 核机器上 QPS 5 万,扩到 16 核机器只到 6 万。perf stat 看 IPC 一致、cache miss 一致。可能是什么瓶颈?用什么工具确认?

  4. 主线二的 cache miss 问题,如果不能改数据结构(API 兼容性),还有什么优化方法?提示:考虑访问模式 / 软件 prefetch。

  5. 一个 latency 敏感服务,p50 延迟 1ms 但 p99 延迟 50ms。perf top 看一切正常。如何定位 p99 抖动来源?

  6. 你的同事用 perf record --call-graph dwarf,8192 抓栈,但栈在第 8 帧就截断(业务调用栈深 30 层)。怎么修?

  7. 火焰图显示 __pthread_mutex_lock 占 25%——但你只是用了 std::cout。这两者之间有什么关系?怎么验证?

  8. 用差分火焰图对比两个版本,发现"红色"和"蓝色"几乎相互抵消(总宽度没变)——但 QPS 实测确实快了 10%。可能是什么原因?

  9. 你想给一个完全 strip 的生产二进制做火焰图(没有任何 debug 包)——还有救吗?哪些信息能恢复,哪些必丢?

  10. 假设你是某团队的性能负责人,预算 1 个月、2 个工程师,要把团队从"出问题才看 perf"提升到"持续 profiling + CI 门禁"。你会按什么 ROI 顺序投入?为什么?


调试器告诉你"代码哪里错",profiler 告诉你"代码哪里慢"。 但真正的性能能力,是从一个 IPC 数字、一个火焰图平顶出发,把"机器在哪一纳秒等了什么"的微观图景还原回来的能力。

下一篇:本篇讲了"代码不崩但慢怎么办",至此排查篇五件套(01.信号崩溃快速排查 → 02.ASan内存诊断 → 03.GDB命令速查表 → 04.CoreDump破案实录 → 05.perf火焰图实战)已经形成完整闭环:事前阻断 → 现场调试 → 事后破案 → 持续优化。配套阅读:01.进程地址空间布局(理解 cache line / TLB / 内存层级是 perf annotate 的物理基础)。

上次更新: 2026/06/16, 19:27:07
CoreDump破案
迭代器失效陷阱

← CoreDump破案 迭代器失效陷阱→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
迭代器失效陷阱
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式