CPU 性能分析笔记:如何用 perf 定位一次延迟抖动
沿着一次服务 p99 抖动的排查过程,串起 CPU 执行与等待分析、perf、火焰图、假设验证和基准测试。
CPU 性能分析最怕变成看图猜谜。
看到火焰图上哪个块最宽,就去改哪个函数;改完跑一次本地基准测试,快了一点,就说优化完成。这种做法很容易把时间花在不主导线上延迟的问题上。
我更愿意把性能分析放进一次完整排查里:
问题现象
-> 系统指标
-> 判断是执行时间还是等待时间
-> 在相同负载下采样
-> 提出假设
-> 只改一个主要变量
-> 用相同负载验证
这篇不写 perf 命令大全,只写一次延迟问题里我会怎样把证据串起来。
事故入口:p99 抖,但 CPU 看起来不一定满
假设服务出现这样的现象:
avg latency: 4 ms
p99 latency: 180 ms
QPS: stable
error rate: low
CPU usage: 45% - 65%
第一反应不应该是打开火焰图。
CPU 使用率不满,不代表没有 CPU 问题;但也可能根本不是执行阶段的问题。线程可能在等锁、等 I/O、等网络、等运行时停顿或等调度。
我会先看系统层:
top
pidstat -p <pid> 1
pidstat -w -p <pid> 1
vmstat 1
iostat -xz 1
sar -n DEV 1
这里先回答几个问题:
- user/sys CPU 是否接近瓶颈。
- 主动和被动上下文切换是否异常。
- 运行队列是否堆积。
- 磁盘等待时间和利用率是否升高。
- 网络是否丢包或打满。
- p99 抖动是否和某个资源曲线对齐。
如果 CPU 不高,但上下文切换、I/O 等待或锁等待很高,普通 CPU 火焰图可能不是首选工具。
flow
性能分析不应该跳过资源判定
先判断线程是在 CPU 上花时间,还是没在 CPU 上等东西。
- 1
问题现象
p99、超时或吞吐变化
延迟QPS错误率 - 2
系统指标
先看系统资源和调度状态
CPU运行队列I/O 等待上下文切换 - 3
采样分析
按执行或等待状态选择工具
perf互斥锁分析等待分析 - 4
验证
用同一负载验证改动
基线单一改动对比
先分清 CPU 执行时间和等待时间
CPU 执行分析关注线程正在 CPU 上运行时,时间花在哪里。
等待分析关注线程不在 CPU 上运行时,卡在哪里。
两者回答的问题不同:
CPU 执行:
CPU 周期花在哪些调用栈上?
等待:
墙上时钟时间消耗在等待什么?
如果 CPU 已经打满,CPU 执行火焰图通常很有用,可能看到序列化、压缩、哈希、内存分配、日志格式化、拷贝、正则、JSON 等热点。
如果 CPU 不满但延迟很高,等待分析可能更有价值。原因可能是互斥锁、条件变量、磁盘 I/O、套接字读写、epoll 等待、运行时安全点或调度等待。
只看 CPU 执行时间,容易优化一个“线程真正在跑时最忙”的函数,却错过“线程大部分时间根本没在跑”的事实。
perf 采样要贴近真实负载
Linux 上常见入口:
perf record -F 99 -p <pid> -g -- sleep 30
perf report
或者生成火焰图:
perf script > out.perf
stackcollapse-perf.pl out.perf > out.folded
flamegraph.pl out.folded > cpu.svg
这里有几个容易犯错的点。
第一,采样窗口要覆盖问题发生时段。没有流量时采样,看到的只是 idle、background thread 或 GC。
第二,负载要和线上症状接近。请求类型、请求体大小、并发和数据规模都要对齐。
第三,采样频率不要盲目拉高。频率太高会影响被测进程,太低又看不清。99Hz 常作为起点,是为了避开某些周期性对齐。
第四,符号要可读。C/C++/Rust 服务最好保留帧指针或提供可用的调试符号,否则火焰图上全是地址和 [unknown],解释力会大幅下降。
火焰图不是时间线
火焰图横向宽度表示采样占比,不表示时间顺序。
宽框:
很多采样都经过这个函数
高栈:
调用路径很深
顶部宽框:
CPU 周期实际花费的叶子函数
底部框架宽,不一定说明框架慢。它可能只是很多请求都经过那里。真正要看的是顶部宽块,以及它是否符合预期。
比如:
- 压缩函数宽:可能是必要计算,不一定是浪费。
- JSON 解析很宽:如果高频路径反复解析同一字段,可能有优化空间。
- malloc/free 很宽:可能是对象生命周期或缓冲区复用问题。
- 日志处理很宽:高频格式化日志可能很不值。
- memcpy 宽:要看是不是跨层重复复制。
火焰图给的是证据,不是自动结论。
我会先区分必要工作和额外开销
看到热点后,我会先问它是不是系统应该做的工作。
必要工作:
- 加密。
- 压缩。
- 模型计算。
- 正常序列化。
- 必要校验。
额外开销:
- 同一请求重复解析。
- 锁内做重活。
- 每层都复制缓冲区。
- 临时对象大量分配。
- 调试日志在热路径格式化。
- 低效的映射或列表查找。
- 明明可以批量处理却逐条调用。
优化主要工作通常要更换算法或实现,并接受相应取舍;优化浪费则更像把不该出现的成本拿掉。
一个假设只能配一个主要改动
性能优化很容易越改越多。最后数字变了,但不知道是哪一处生效。
我会把假设写具体:
假设:
请求接入阶段反复解析 JSON,抬高了 p99。
证据:
perf 显示 parse_request_fields 占 on-CPU 样本的 18%
p99 较高的负载中也出现了相同调用栈
改动:
在入口处只解析一次,并向下游传递类型化字段
然后只做这一类改动。不要顺手改批次大小、日志级别、锁粒度和缓冲池,否则验证阶段无法解释结果。
基准测试要和性能分析场景对得上
性能数据来自线上负载,验证却只用本地单线程模拟,这样的证据很弱。
我会尽量对齐:
- 请求类型。
- 请求体大小。
- 并发度。
- 数据规模。
- 编译参数。
- CPU 调频策略。
- 容器 CPU 限额。
- 日志级别。
- 下游模拟行为。
如果线上 p99 来自高并发下的锁竞争,本地单线程基准测试只会证明函数本身快,不会证明尾延迟已经改善。
结果记录要能被复盘
一条性能优化记录至少应该留下:
现象:
负载 X 下的 p99 为 180 ms
基线:
QPS、平均延迟、p95、p99、CPU、内存和上下文切换
采样:
命令、时间窗口、负载和主要调用栈
假设:
为什么这段调用栈属于额外开销或瓶颈
改动:
只改变一个主要变量
结果:
使用同一负载进行对比
风险:
语义变化、内存取舍和复杂度
没有这些,优化就会变成“我记得当时快了”。
性能分析的最终目标不是画出更好看的火焰图
CPU 性能分析的目标不是把某个宽块变窄,而是解释一次性能症状。
如果症状是 p99 抖动,最后就要回到 p99;如果症状是 CPU 打满,最后就要回到 CPU 利用率和吞吐;如果症状是请求卡住,最后可能要证明等待时间已经下降。
火焰图只是中间证据。真正的闭环是:同一个负载下,问题指标改善,而且你知道为什么。