想象你是城市交通局的观察员,坐在直升机上俯瞰整座城市。你没法同时盯着每条街,于是每隔几秒随机按下快门,拍一张俯瞰照片。一天下来,你把几百张照片冲洗出来,统计哪些路口、哪些写字楼出镜率最高——出镜最多的地方,就是城市最拥堵的地方。 perf 干的是同一件事,只不过它拍的不是街道,而是 CPU 此刻正在执行的函数。它每隔一小段时间就打断程序一次,看一眼程序“堵在”哪个函数里,记录下来。跑完几千次快门,哪个函数上镜最多,哪个函数就是程序的性能瓶颈。这篇文章,带你学会用 perf 这位“空中观察员”做采样、看硬件计数器、画火焰图,并验证优化前后效果。

岗位关联

学会 perf,等于给程序装上了城市交通监控系统——线上 CPU 飙高、接口变慢、缓存疯狂 miss,你都能在几分钟内锁定真凶。这是性能调优工程师的看家本领。

目录
  1. 可视化演示
  2. 采样原理:直升机上的快门
  3. 命令总览:观察员的四件套
  4. 采样频率:快门按多快
  5. 硬件计数器:看见看不见的堵点
  6. 火焰图生成:把照片拼成热力地图
  7. CPU热点定位:锁定最忙的街区
  8. 对比调优:优化后重新拍一张
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示perf性能分析流程:火焰图(main→foo→sort/cmp)展示调用栈热点,Stat→Rec→Rpt→Flame四步分析管线定位CPU瓶颈。

本节配有交互动效(进入视口后自动加载,约 3KB)。

perf性能分析核心 火焰图展示调用栈热点:main→foo→sort(30%)/cmp(20%),alloc(15%)→mem(12%)/str(10%),框宽=CPU占比 perf四步:Stat(计数器)→Rec(采样)→Rpt(报告)→Flame(火焰图),硬件计数器IPC/Cache/Br/Cyc定位瓶颈 Sampling: perf record -F 99 -g -- ./app, Flame: perf script | FlameGraph

采样原理:直升机上的快门

城市观察员坐直升机在城市上空盘旋,不盯着每条街看,而是随手按快门。照片里出现最多的路口,就是最繁忙的路口——真实路况不用人工去数,靠“拍”就能还原。

程序运行也像城市车流:CPU 不停地在各个函数之间切换。我们没法无时无刻记录“车在哪儿”,于是每隔固定时间打断程序一下,看一眼此刻正在执行哪个函数。这个动作就叫采样。perf 默认用 CPU 时钟事件驱动采样,把中断瞬间的函数与调用栈记进 perf.data。

每秒钟按多少次快门,叫采样频率 f;程序总共跑了多久,叫时长 T;两者相乘得到总样本数 N。最后统计函数 i 被拍到的次数 n_i,它的占比就近似等于该函数消耗 CPU 的比例——这就是“用统计量还原真相”。

回到直升机:只要快门够密、飞行时间够长,统计出的“繁忙路口榜”就非常接近真实拥堵。perf 的热点排行榜,本质上就是一张城市出镜率榜单。

N=f×TP^i=niN×100%\displaystyle N = f \times T \qquad \hat{P}_i = \frac{n_i}{N} \times 100\%
样本总数等于采样频率乘以运行时间;某个函数的耗时占比,近似等于它的样本数除以总样本数。
  1. 样本总数 N整轮采样收集到的“照片”张数,也就是 perf.data 里样本条目的总数。
    NN
  2. 采样频率 f每秒按下快门的次数,单位 Hz,perf record 中用 -F 指定。
    ff
  3. 运行时间 T程序从开始到退出经历的总时间,单位秒。
    TT
  4. 函数 i 的占比 P_i照片里拍到函数 i 在执行的次数 n_i 除以总样本数,再乘以 100%。
    P^i=niN×100%\hat{P}_i = \frac{n_i}{N} \times 100\%

命令总览:观察员的四件套

观察员不会只有一个动作。他带望远镜、计数器、录像机和回放设备,perf 也把工具分成四件套。

perf top 像直升机上的直播大屏,实时刷新当前 CPU 最忙的进程和函数,适合“先扫一眼”;perf stat 像跑完一圈后的总结报告,给出 cache miss、分支预测失败、IPC 等汇总数字;perf record 像录像机,把采样数据录进 perf.data;perf report 像回放设备,打开录像带生成带调用栈的图文报告。

record 与 report 是黄金组合。record 每次中断除了记录当前函数,还会抓取整条调用栈,方便还原“谁调用了谁”。一份带调用栈的样本要占几百字节,样本越多,perf.data 越大。

所以聪明的观察员不会一上来就录像:先用直播大屏 perf top 扫一眼,再用 perf stat 摸底,最后才决定要不要 record 和 report。四件套配合,层层深入。

Data≈N×Depth\displaystyle \mathit{Data} \approx N \times \mathit{Depth}
采样数据体积约等于样本总数乘以平均调用栈深度。这也是为什么高频采样会把磁盘写爆。
  1. 数据体积 Data生成的 perf.data 文件大小,单位可以是 MB 或 GB。
    Data\mathit{Data}
  2. 样本总数 N采样次数,等于采样频率乘以运行时长。
    NN
  3. 调用栈深度 Depth每次样本记录的栈帧层数,层数越深,单条样本越大。
    Depth\mathit{Depth}

采样频率:快门按多快

拍飞驰的赛车,快门太慢会糊成一团;快门太快,存储卡几秒就爆。快门速度,就是采样频率。

采样有个直觉法则:采样频率必须远高于目标事件的频率。程序里短促的函数像一闪而过的赛车,如果频率太低,一个只运行 0.1 毫秒的短函数可能一次都进不了照片,热点被整个漏掉。反之,频率过高时,每次采样都要打断程序、抓调用栈、写文件,这些开销会拖慢程序本身,甚至改变程序的真实行为。

实践中的经验是:先用 -F 99 粗测找方向,再用 -F 999 甚至更高精确定位。低频防失真,高频抠细节,中间的多试几次。

好摄影师会先试拍几张看曝光。我们同样建议:先小频率粗测,再加大频率精确定位,别一上来就 10000 Hz——小心把相机(程序)震坏。

f采样≫f事件,Δt=1f采样\displaystyle f_{\text{采样}} \gg f_{\text{事件}}, \qquad \Delta t = \frac{1}{f_{\text{采样}}}
采样频率要远大于关键事件的频率;采样间隔等于频率的倒数。
  1. 采样频率 f_采样perf record -F 指定的每秒采样次数,比如 -F 999 就是每秒 999 次。
    f采样f_{\text{采样}}
  2. 关键事件频率 f_事件短促函数或高频调用出现的频率,采样频率必须远大于它才捕捉得到。
    f事件f_{\text{事件}}
  3. 采样间隔 Δt两次快门之间的时间,等于频率的倒数。1/1000 秒就是 1 毫秒一次。
    Δt=1f采样\Delta t = \frac{1}{f_{\text{采样}}}

硬件计数器:看见看不见的堵点

直升机照片只能说明“哪里堵”,但一座桥堵,可能是桥面窄(代码逻辑重),也可能是收费站太慢(等内存)。perf 还有一组隐形传感器——硬件性能计数器,由 CPU 内部直接提供。

三个常见指标。cache-misses:数据在最后一级缓存没命中,不得不去内存取,一次 miss 要等上百个周期;branch-misses:分支预测猜错后,流水线要作废重来;IPC:每个时钟周期执行的指令数,IPC 越低说明 CPU 多数时间在“等”(等内存、等分支),而不是在“算”。

perf stat 会直接给出这些汇总。缓存 miss 率等于 miss 次数除以访问总次数。如果 miss 率很高而热点函数又是内存密集操作,就要考虑重构数据结构、提高局部性。

这一组传感器像地铁闸机上的计数器:车(数据)过闸时被挡了一下(miss),计数器咔嗒一声。一天下来,你就知道哪些闸机最堵。硬件计数器让“看不见的等待”变成白纸黑字的数字。

Rmiss=CmissCaccess×100%,IPC=ICcycles\displaystyle R_{\text{miss}} = \frac{C_{\text{miss}}}{C_{\text{access}}} \times 100\%, \qquad \mathit{IPC} = \frac{I}{C_{\text{cycles}}}
缓存未命中率是未命中次数占访问总数的比例;IPC 是完成指令数除以消耗的时钟周期数。
  1. 未命中次数 C_miss数据请求在缓存里没找到、必须去主存取数的次数。
    CmissC_{\text{miss}}
  2. 访问总次数 C_accessCPU 对缓存发起的全部访问次数,命中加未命中。
    CaccessC_{\text{access}}
  3. 完成指令数 I程序实际执行完毕的指令条数。
    II
  4. 时钟周期 C_cyclesCPU 消耗的周期总数。IPC 越低,说明“等得多、算得少”。
    IPC=ICcycles\mathit{IPC} = \frac{I}{C_{\text{cycles}}}

火焰图生成:把照片拼成热力地图

光有“哪条街最堵”还不够,市长还想知道“堵是从哪个区导流过来的”。火焰图就是这个叙事视图:把每条调用栈叠成一座火焰山,宽度代表停留时间占比,从下往上是根到叶的调用路径。

生成只需三步:先用 perf record -g 采集带调用栈的样本(录像时每帧附上 GPS 轨迹);再用折叠脚本把多行调用栈压成一行,比如 main;parse;compute 42,这一步叫栈折叠;最后用 FlameGraph 工具把折叠后的数据画成 SVG。

解读时记住三条:顶部宽宽的平顶是性能问题的聚集地,说明某叶子函数独占了 CPU;颜色只是随机分配,与好坏无关;尖尖的山峰是频繁进出的小函数,平缓的熔岩层是持续霸占 CPU 的长函数。

有了热力地图,从上往下看:哪座山最宽,就沿着它往山下(上游调用者)走,找到真正值得优化的一整条调用链。这就是“拍照 → 折叠 → 画图 → 读图”的完整闭环。

Wi=niN×Wtotal\displaystyle W_i = \frac{n_i}{N} \times W_{\text{total}}
某个函数帧在火焰图中的横向宽度,等于它的样本数占总样本数的比例乘以总宽度。
  1. 火焰条宽度 W_i函数 i 在火焰图中的横向宽度,宽的就是停留久的。
    WiW_i
  2. 该帧样本数 n_i函数 i 出现在调用栈中的次数。
    nin_i
  3. 总样本数 N火焰图最底层(通常是 main)的样本总数,对应满宽。
    NN
  4. 总宽度 W_total火焰图的横向全程,代表 100% 的样本。
    WtotalW_{\text{total}}

CPU热点定位:锁定最忙的街区

城市报告说“金融街最忙”,我们还要回答:是修路还是红绿灯配时?热点定位的产出不是一句话,而是一条可执行的结论。

方法是:先用足够的频率 record,再用 perf report 按 self 列排序——self 是函数自己消耗的 CPU,不含子函数;children 才包含子函数。排在最前面的 self 大户就是候选热点。接着展开调用链,看流量来自哪几个 caller,结合源码确认:是调用次数太多,还是单次太慢,还是被锁和 IO 拖住。

一个常见陷阱:某函数很宽,但它是在等 IO 或等锁,优化它本身没用,要改的是上游并发度或锁设计。热点函数的大致耗时可以用总时间乘以样本占比估算。

锁定最忙街区后还要沿街巡查:看入口 caller 能否缓存、能否减少调用次数。定位不是目的,定位之后能行动才是目的。

T^i=niN×Ttotal\displaystyle \hat{T}_i = \frac{n_i}{N} \times T_{\text{total}}
函数 i 的估计耗时等于它的样本占比乘以程序总运行时间。
  1. 估计耗时 T_i函数 i 大概吃掉了多少秒程序时间。
    T^i\hat{T}_i
  2. 样本占比 n_i/N函数 i 的样本数占全体样本的比例,也是火焰图里的宽度。
    niN\frac{n_i}{N}
  3. 总运行时间 T_totalperf stat 里的 elapsed time 或 wall time。
    TtotalT_{\text{total}}

对比调优:优化后重新拍一张

修完路,要重新航拍验证效果。所有优化都要用同一把尺子量:perf stat 优化前后各跑一遍,对比 elapsed、IPC、cache-misses 和分支预测失败次数。

动手前先算一笔账——Amdahl 定律。它说整体加速比只取决于被优化部分的占比和加速倍数。如果某函数占 60% 时间,把它优化 2 倍,整体最多加速 1.43 倍;就算优化 100 倍,整体极限也只有 2.5 倍。所以别在 2% 的热点上耗三天,先优化占比最大的那块。

观察员养成的习惯是:每次修改只变一个变量,记录前后 perf stat 的完整数字。缓存 miss 降了但 IPC 没升?想想为什么。持续“拍一张 → 对比 → 再拍”,每次微小的进步汇聚成可观的性能提升。

S=1(1−p)+ps\displaystyle S = \frac{1}{(1 - p) + \dfrac{p}{s}}
整体加速比等于 1 除以“不可加速部分 + 可加速部分除以局部加速比”。
  1. 原占比 p被优化函数在优化前占程序总时间的比例,比如 0.6。
    pp
  2. 局部加速比 s该函数优化后快了多少倍,比如从 10 秒优化到 5 秒,s = 2。
    ss
  3. 整体加速比 S整个程序最终的加速倍数。p = 0.6、s = 2 时,S = 1 / (0.4 + 0.3) ≈ 1.43。
    S=1(1−p)+p/sS = \frac{1}{(1 - p) + p/s}

AI 应用场景

场景一:大模型推理服务的 CPU 飙高排查

阶段:模型推理与在线服务优化

解决的问题:传统排查靠猜、靠加机器,无法量化“CPU 到底烧在哪”。用 perf 这位空中观察员,先 perf top 看实时热点,再 perf record 抓调用栈画火焰图,把模型前处理、注意力计算、显存拷贝、日志输出逐一量化,定位真凶。

具体案例:某对话机器人服务压测时 p99 延迟暴涨,perf record 的火焰图显示 ext4 文件写入和日志序列化占了大片宽度——瓶颈不在模型,而在逐条同步写日志。改为批量异步落盘后,QPS 提升 2.3 倍。

场景二:向量检索服务的缓存命中率调优

阶段:数据预处理与特征检索

解决的问题:向量召回慢,perf stat 显示 cache-misses 率很高,说明数据访问局部性差,CPU 大量时间在等内存而不是计算。

具体案例:批量 embedding 查询时按用户 id 分散访问,perf stat 显示 miss 率 35%;改为按分区顺序批量读取后,miss 率降到 12%,p99 延迟下降 60%。

Python 演示

python55 行
import signalimport time # 模拟 perf: 每隔固定间隔打断程序, 看 CPU 此刻在执行哪个函数samples = []        # 每次采样记录当前函数名interval = 0.001    # 采样间隔 1ms, 相当于 -F 1000  def fib(n):    if n <= 1:        return n    return fib(n - 1) + fib(n - 2)  def busy_square(n):    total = 0    for i in range(n):        total += i * i    return total  def main():    result = 0    for _ in range(3):        result += busy_square(200000)   # 快速但琐碎的活    result += fib(28)                   # 真正的 CPU 大热点    result += busy_square(300000)    return result  def sampler(signum, frame):    # 相当于 perf 的中断处理: 记录正在执行的函数名    samples.append(frame.f_code.co_name)  signal.signal(signal.SIGALRM, sampler)  def run_profile():    # 设置周期性定时器: 每 1ms 触发一次采样    signal.setitimer(signal.ITIMER_REAL, interval, interval)    start = time.time()    main()    signal.setitimer(signal.ITIMER_REAL, 0)   # 跑完关闭采样    elapsed = time.time() - start    counts = {}    for name in samples:        counts[name] = counts.get(name, 0) + 1   # 按函数聚合样本    print("elapsed: %.3fs  samples: %d" % (elapsed, len(samples)))    for name, num in sorted(counts.items(), key=lambda kv: kv[1], reverse=True):        print("%-14s %5d  %5.1f%%" % (name, num, num * 100.0 / len(samples)))  if __name__ == "__main__":    run_profile()
目的:用信号定时器模拟 perf 的采样过程,运行后输出每个函数被“拍到”的次数和占比,让你亲手感受“样本近似耗时”的统计学原理。请在 Linux 环境运行。
  1. signal.signal(signal.SIGALRM, sampler)注册中断处理器:每次响铃都跳进 sampler。
  2. signal.setitimer(signal.ITIMER_REAL, interval, interval)设置周期性定时器,相当于 perf record 的 -F 1000。
  3. samples.append(frame.f_code.co_name)中断瞬间记录当前正在执行的函数名,这就是“拍一张照片”。
  4. counts[name] = counts.get(name, 0) + 1把所有样本按函数名聚合,相当于 perf report 的统计。
  5. num * 100.0 / len(samples)计算每个函数的热点占比,对应文章里的样本占比公式。

常见误区

误区perf 显示函数 A 占了 80%,那 A 一定写得很烂,我把它整个重写就好了。

正解样本只告诉你“CPU 在这里停留”,不告诉你“为什么停留”。A 可能是被上层疯狂调用(调用次数爆炸),也可能是在自旋等待锁或内存。要先看调用链和硬件计数器,找到“流量来源”再动手。就像某路口照片多,可能是所有车都被导航导到这里——拆路口不如改导航。

为何常见:人脑天然把“停留时长”和“代码质量”画等号,忽略了调用次数与等待这两个隐藏变量。

误区我把 -F 设成 99999,样本多到爆炸,结果肯定最准。

正解每次采样都要打断程序、抓栈、写文件。高频采样本身就在拖慢程序,甚至改变函数之间的时间分布;而且数据量巨大,分析起来反而困难。实际中 -F 99 到 -F 999 就足以定位绝大多数热点。

为何常见:觉得“数据越多越客观”,忽略了观测行为本身对被测系统的扰动。

误区火焰图里红色的一定是问题函数,要优先处理它。

正解火焰图的颜色是随机分配的,只为区分相邻帧,与函数性能无关。读图只看宽度,宽度代表样本占比。真正要关注的是宽而平的大块,而不是颜色深浅。

为何常见:人们受红绿灯、热力图色彩语义的影响,不自觉给颜色赋予“危险”或“健康”的含义。

误区CPU 都跑满了,肯定是纯计算问题,加核就能解决。

正解CPU 100% 也可能是在空转:自旋锁、忙等、轮询,或者 cache miss 后不停重试。用 perf stat 看 IPC,如果 IPC 很低,说明 CPU 在“等”而不是在“算”,此时加核无用,要查锁、查内存布局。

为何常见:把“CPU 利用率”和“计算效率”混为一谈,忽略了等待类周期也消耗 CPU。

面试题

Q1. perf 采样的本质是下列哪一种做法?

单选题 · easy

  • A.逐个运行函数并记录各自耗时
  • B.高频打断程序记录执行位置← 正确答案
  • C.静态扫描源码寻找死循环
  • D.在每行代码插入计时桩点

答案:B。perf 的核心是周期性中断程序,记录中断瞬间正在执行的函数,用样本占比近似耗时。A 是插桩计时,C 是静态分析,D 是代码注入,都不是采样。

Q2. 火焰图中,一个函数帧的横向宽度代表什么?

单选题 · easy

  • A.该函数被调用的先后顺序
  • B.该函数占全体样本的比例← 正确答案
  • C.该函数源码的行数总和
  • D.该函数分配的线程数量

答案:B。宽度正比于样本占比,即时间占比。A 的先后顺序由纵向层次体现,C 和 D 与采样无关。火焰图只看宽度,不看颜色。

Q3. 想实时观察当前系统 CPU 热点函数,应使用哪个命令?

单选题 · easy

  • A.perf top 实时榜单← 正确答案
  • B.perf build 编译缓存
  • C.perf mem 内存剖析
  • D.perf probe 动态探针

答案:A。perf top 像直升机直播大屏,实时刷新当前最热函数。perf mem 是内存剖析,perf probe 是动态探针,而 perf build 不是标准子命令。

Q4. perf record -F 1000 的含义是什么?

单选题 · mid

  • A.每秒采集一千次性能样本← 正确答案
  • B.总共记录一千个函数调用
  • C.每千条指令暂停并采样
  • D.采集时长限制一千秒钟

答案:A。-F 指定的是采样频率,即每秒 1000 次,相当于快门每秒按 1000 次。它不限制总数、也不按指令数采样。

Q5. 关于缓存未命中 cache miss 的说法,正确的是?

单选题 · mid

  • A.仅影响程序首次启动阶段
  • B.未命中要访内存执行变慢← 正确答案
  • C.性能和缓存未命中无关
  • D.指针多才会引发未命中

答案:B。cache miss 意味着数据要去更慢的内存取,一次可能损失上百个周期,是常见性能杀手。它贯穿整个运行过程,也与代码数据布局强相关。

Q6. 判断某热点函数真正“值得优化”,需要结合哪些信息?

多选题 · mid

  • A.该函数的样本占比很高← 正确答案
  • B.它是自旋等待还是真计算← 正确答案
  • C.上层调用者的调用路径← 正确答案
  • D.函数名长度与代码风格

答案:ABC。占比高是前提;若它是在等锁或等 IO,优化本身没用,要看调用链找源头。函数名长度和风格与性能无关。

Q7. perf stat 结果中的 IPC 指标指的是什么?

单选题 · hard

  • A.每秒处理的中断请求数量
  • B.每个时钟周期执行的指令数← 正确答案
  • C.指令缓存未命中的比例值
  • D.进程上下文的切换频率值

答案:B。IPC = 指令数 / 时钟周期数。IPC 低说明 CPU 大量时间在等待(内存、分支),而不是在真正计算,是“CPU 在忙还是在空转”的判据。

Q8. 采样频率设置过低可能带来哪些后果?

多选题 · hard

  • A.短小的函数根本采样不到← 正确答案
  • B.采样的开销会大幅上升
  • C.热点分布估计严重失真← 正确答案
  • D.火焰图自动变成彩色图

答案:AC。频率过低时,运行时间短的函数一次都进不了样本,热点占比会严重偏离真实,就像慢快门拍不到飞驰的赛车。频率低反而开销小,火焰图颜色与频率无关。

Q9. 下列哪些数据可以来自 perf stat 的硬件计数器?

多选题 · hard

  • A.缓存未命中的累计次数← 正确答案
  • B.分支预测失败的次数← 正确答案
  • C.磁盘剩余容量的数值
  • D.已退休指令的总数量← 正确答案

答案:ABD。缓存未命中、分支预测失败、已退休指令都是 CPU 硬件计数器。磁盘容量属于存储系统,不是 CPU 性能计数器。

Q10. 若函数 A 占运行时间 60%,优化后快了一倍,整个程序加速多少?

单选题 · extreme

  • A.加速 2.0 倍左右
  • B.加速 1.43 倍左右← 正确答案
  • C.加速 1.25 倍左右
  • D.加速 0.80 倍左右

答案:B。由 Amdahl 定律,S = 1 / ((1 - 0.6) + 0.6 / 2) = 1 / 0.7 ≈ 1.43。局部加速 2 倍不等于整体加速 2 倍,这就是“先算账再优化”的原因。