跳到内容

第 3 卷 / 第 18 章 / 第 2 课

18.2 用 perf 建立性能证据 ​

Linux perf 同时覆盖 hardware PMU counter、software event 和 tracepoint。它回答两类不同问题:

  • perf stat 统计一段运行里发生了多少事件;
  • perf record/report 通过 sampling 估计事件集中在哪些 instruction 和 call stack。

counter 不是 call stack,sampling 也不是每次事件的完整日志。先写下问题和 workload,再选择模式。以下 application arguments 表示你的程序及其参数;在专用输出目录操作,保留每次 perf.data 与对应构建。

1. 采集前的四项检查 ​

工作负载是否代表问题 ​

让程序运行足够久,覆盖实际慢路径,并保存数据规模、并发和输入分布。只 profile 启动阶段,得到的热点可能全是动态链接和初始化。

二进制是否可符号化 ​

保留独立 debug symbols,记录 build ID,并避免在分析前删除对应二进制。优化构建可以继续使用;-g 不等于 -O0。

调用栈还取决于 unwind 方式。frame-pointer、DWARF CFI 和硬件 LBR 各有可用性与开销边界;不存在一组选项适合所有 CPU、语言和生产环境。

当前平台提供哪些事件 ​

bash
perf list

事件名由 CPU PMU、内核和虚拟化暴露能力决定。不要从另一台机器复制 raw event 编码。混合大小核平台还可能有多个 PMU,同名事件需要区分 unit。

权限与作用域 ​

系统配置会限制普通用户采集内核地址、其他进程或 system-wide 数据。容器也可能没有 PMU 访问能力。遇到权限错误时应按组织安全策略授权最小范围,不要直接关闭整机保护。

2. perf stat:先看总账 ​

从默认统计开始:

bash
perf stat -r 5 -- ./application arguments

-r 5 重复运行,报告均值与标准差信息,适合发现明显不稳定。需要特定事件时,再根据 perf list 选择:

bash
perf stat -e cycles,instructions,branches,branch-misses -- ./application arguments

并非所有平台都支持这组名字;失败或显示 <not supported> 时,先确认 PMU 和权限。

怎样读,而不是怎样猜 ​

elapsed time 是端到端时间。user/system time 帮助区分用户态 CPU 与内核态 CPU,但多线程总 CPU 时间可以超过 wall time。

instructions/cycles 可计算 IPC。低 IPC 可能来自依赖链、cache miss、分支错误、前端供给等正在运行时的停顿;线程未被调度通常不计入该线程的 cycles,应另看 task-clock、CPU 利用率与调度等待;它不是“内存瓶颈”的单独证明。

branch-misses/branches 描述被计数分支中的预测失败比例。编译器可能已把源码 if 变成条件移动,事件也受 PMU 定义影响。

cache-misses 是宽泛事件,未必对应某一级 cache 或某种 load。要定位 cache 层级、TLB 或远端 NUMA,需要平台专用事件或 memory profiling。

若请求的事件超过硬件计数器数量,perf 会 multiplex。输出中的运行时间比例与缩放值得检查;不同时段轮换的事件可能偏向不同程序相位。需要比值时可尝试同组事件(如 -e '{cycles,instructions}'),但整组必须能一起调度,否则可能显示 not counted。

3. perf record:热点在哪里 ​

基本 CPU 采样流程:

bash
perf record --call-graph dwarf -- ./application arguments
perf report

perf record 把样本写入 perf.data,perf report 再按 symbol、DSO 和调用关系聚合。默认采样事件与频率由平台和 perf 版本决定;需要可复现实验时,应显式记录命令、版本和 event。

若构建保留 frame pointer,可以评估:

bash
perf record --call-graph fp -- ./application arguments

frame-pointer unwind 开销较低,但遇到省略 frame pointer 或混合 runtime 时可能得到断裂甚至错误的栈。DWARF 会采集用户栈片段并据 CFI 展开,记录量和开销更高。LBR 只在部分平台可用,也有调用栈深度和组合选项限制。

即使传了 -fno-omit-frame-pointer,也应检查实际汇编;leaf function、尾调用和外部库仍可能缺帧。

不要看到栈断了就解释业务。先用已知调用链验证 unwind 质量。

4. Inclusive 与 self overhead ​

假设:

text
handle_request
  ├─ parse
  └─ query_database
       └─ decode_rows

handle_request 的 inclusive cost 包含所有子调用;self cost 只计算采样点落在自身指令的部分。宽父框不表示父函数本身计算很多,它也可能只是调用了昂贵子函数。

优化时沿最宽调用路径向上追所有权、向下找实际消耗:

  • 顶部 leaf 很宽:成本集中在自身;
  • 中间框宽而 self 小:它是聚合入口;
  • 同一 leaf 出现在多个路径:需要判断哪个调用者贡献更大;
  • [unknown] 或十六进制地址很多:先修符号与 unwind。

采样百分比是对所选 event 的估计,不自动等于 wall-time 比例。采 cycles、cpu-clock 和 cache event 会得到不同意义的图。

5. 火焰图是另一种调用栈投影 ​

若已安装相应 stack collapse 与 FlameGraph 脚本,可以:

bash
perf script > samples.perf
stackcollapse-perf.pl samples.perf > samples.folded
flamegraph.pl samples.folded > cpu.svg

读图原则:

  • 横向宽度由输入的折叠栈权重决定,常用流程按样本计数;perf report 的 event-period 权重不一定与之相同;
  • 纵向是调用深度,不是耗时;
  • 横向位置通常不表示时间先后;
  • 默认颜色主要用于视觉区分,不代表温度或严重级别;
  • 顶部宽平台常是实际 on-CPU leaf,底部宽框通常是入口或聚合路径。

火焰图会牺牲时间轴。若问题是周期性抖动、一次短尖峰或启动阶段,时间序列、分窗口 profile 或 trace 往往更合适。

6. CPU 火焰图看不到睡眠时间 ​

线程可能因下列情况离开 CPU 或等待调度:

  • mutex 或 condition variable;
  • 磁盘与网络 I/O;
  • 需要等待存储的重大缺页(minor fault 常在 CPU 上处理);
  • scheduler run queue;
  • 下游 RPC;
  • runtime safepoint;

这些情况中的实际离开 CPU 时间,不会被 on-CPU 样本按 wall time 画宽。run queue 表示可运行但尚未获调度,并非睡眠;自旋锁、部分 safepoint 协调和轻微缺页处理仍可能消耗 CPU。

先用业务 trace、线程状态和系统指标判断“运行慢”还是“等待久”。Linux 侧可以根据场景使用 scheduler tracepoint、perf sched、perf lock、eBPF 工具或应用内 tracing。具体命令与权限依版本而变,先用本机 help/list 确认可用数据。

off-CPU 分析需要记录阻塞开始与结束,并把等待时间归到相应栈。只采一次睡眠函数调用无法得到等待持续时间。

7. 从 CPU 热点到 cache 与共享 ​

发现循环占据大量 cycles 后,再提出更具体假设:

工作集超过 cache。 随数据规模变化画吞吐曲线,观察是否在某个容量附近出现拐点。

访问缺乏局部性。 比较连续数组、指针追逐或结构体字段拆分后的事件与时间。

伪共享。 不同线程写不同变量,但变量落在同一 coherence unit,cache line 在核心间迁移。

真正共享写。 多线程都修改同一计数或队列头,单纯 padding 无法消除争用,需要分片或改变算法。

perf c2c 在支持的平台上可帮助定位 cache-to-cache sharing。通用 LLC-load-misses 并不能直接证明 false sharing:数据可能来自内存,也可能是正常容量 miss,且不同架构事件语义不同。

修复假共享常采用每线程局部数据,最后聚合;padding/alignment 只有在确认字段确实落在同一 line 且生命周期允许时才合适。缓存行大小与结构布局要从目标平台验证,不要把 64 字节写成 C 语言常量真理。

8. 分支、前端与汇编 ​

若 branch miss 占比可疑:

  1. 用 perf report 找到样本集中的函数;
  2. 用 perf annotate 把源代码和汇编对齐;
  3. 确认源码分支是否仍存在;
  4. 检查输入分布和热点路径布局;
  5. 修改后同时比较 cycles、instructions、branch misses 与总时间。

无分支版本可能执行更多指令,条件移动也会延长依赖链。__builtin_expect 主要帮助编译器做布局和静态决策,不会把概率直接写进硬件预测器。

前端问题还可能来自指令 cache、译码带宽和过度内联。火焰图显示某个函数宽,不代表继续内联它就更快;代码体积膨胀可能让热路径互相挤出 instruction cache。

9. 采样偏差与测量开销 ​

采样不是无扰动观察:

  • 频率过高会增加中断、记录与 unwind 开销;
  • buffer 太小会丢样本;
  • 短函数可能因 skid 或事件精度归到邻近指令;
  • 动态生成代码需要正确的 JIT symbol 支持;
  • 容器 PID/mount namespace 会影响 symbol 路径解析;
  • system-wide profile 可能收集敏感调用栈与地址;
  • 程序相位变化会让整段聚合掩盖局部问题。

先在测试环境评估开销。生产采集应限制持续时间、频率、事件、目标 PID/cgroup 和数据访问权限,并遵守隐私与运维流程。

对比两个版本时使用相同采样方法。若优化幅度接近 profiler 自身扰动或运行方差,结论不够稳。

10. 从“程序慢”走到可复查的证据链 ​

工具读数很多,排障却不该从随机 event 开始。先固定 workload 和症状,再从总账缩小到热点、调用栈、cache 或调度证据;每走一步,都要能解释下一条命令回答什么问题。

text
SLO 变差
  │
  ├─ 目标线程/核心在忙算?
  │    ├─ 是:perf stat → record/report → annotate
  │    └─ 否:看排队、锁、I/O、下游和 runtime
  │
  ├─ 热点受什么限制?
  │    ├─ 指令/算法
  │    ├─ branch/front-end
  │    ├─ cache/TLB/NUMA
  │    ├─ shared cache line/lock
  │    └─ syscall/device
  │
  └─ 最小改动 → 重跑正确性、SLO、资源与 profile

工具选择跟着假设走,而不是把默认 perf stat 的每一行都解释一遍。一个 counter 异常通常只是下一次实验的入口。

11. 小结 ​

perf 能把性能直觉变成可复核证据:

  • stat 给出一段运行的事件总账;
  • record/report 用采样定位 event 集中的代码与调用路径;
  • frame-pointer、DWARF 和 LBR 是不同的 call graph 获取方式;
  • 火焰图宽度属于输入的栈权重,不是自动的 wall-time 百分比;
  • on-CPU profile 看不到锁、I/O 和排队中的完整等待;
  • cache miss 不自动证明 false sharing,需更具体事件和数据布局证据;
  • 事件、PMU 与权限随机器变化,应从 perf list 和本机文档开始。

至此,第 3 卷从编码、CPU、内存、进程、文件系统一路走到性能验证。继续阅读:第 4 卷:计算机网络。

参考 ​

Built with VitePress | Software Systems Atlas