Python 程序变慢了怎么定位?cProfile 和 timeit 怎么用?
简化版
性能优化的第一原则是「先测量,再优化」——凭直觉猜热点几乎总是猜错。Python 的测量工具分三层:宏观用 cProfile(哪个函数最耗时)、微观用 timeit(两种写法哪个快)、逐行用 line_profiler(函数内部哪一行慢)、内存用 tracemalloc。cProfile 的用法是 python -m cProfile -s cumtime app.py,或代码里 cProfile.run("main()", "out.prof") 再用 pstats 分析;看结果时要分清两个关键指标:tottime 是「函数自身」耗时(不含它调用的子函数)——用来找「真正在干活的热点」;cumtime 是「累计」耗时(含子函数)——用来找「哪条调用链吃掉了时间」。timeit 的要点是:默认自动跑很多次取总时间,且应该看 repeat 里的最小值而不是平均值(最小值最接近「没有被系统噪声干扰的真实耗时」),命令行用 python -m timeit -s "setup" "stmt"。几个必须知道的坑:① cProfile 本身有 30%~100% 的额外开销,且对每次函数调用都插桩,所以「函数调用多但每次很快」的代码会被严重放大——profile 出来的绝对时间不可信,只看相对占比;② 它只统计 Python 层的函数调用,C 扩展内部(numpy、正则、json)的时间会全算在调用它的那一行上;③ 它默认只统计主线程,异步和多线程要另想办法(yappi、py-spy)。核心记忆:先 cProfile 定位到函数(看 tottime + cumtime),再 line_profiler 定位到行,最后用 timeit 验证优化确实变快了。
详细版
工具选型表:
| 想知道什么 | 用什么 | 典型命令 |
|---|---|---|
| 整个程序哪个函数慢 | cProfile + pstats | python -m cProfile -s cumtime app.py |
| 两种写法哪个快 | timeit | python -m timeit -s "..." "..." |
| 函数内部哪一行慢 | line_profiler | kernprof -l -v script.py |
| 线上服务实时看 | py-spy(采样、无需改代码) | py-spy top --pid 1234 |
| 内存涨在哪 | tracemalloc(标准库) | 见下方代码 |
| 想要火焰图 | py-spy record / snakeviz | py-spy record -o fg.svg -- python app.py |
import cProfile, pstats, io, timeit, tracemalloc
# ① cProfile:跑完存文件,再用 pstats 分析(比直接打印灵活得多)
def main():
return sum(len(str(i)) for i in range(200_000))
cProfile.run("main()", "out.prof")
p = pstats.Stats("out.prof")
p.strip_dirs().sort_stats("tottime").print_stats(10) # 自身耗时 Top10(找真正的热点)
p.sort_stats("cumtime").print_stats(10) # 累计耗时 Top10(找耗时的调用链)
p.print_callers("expensive_func") # 谁调用了它(找罪魁祸首)
# ② 只想 profile 一段代码:用上下文形态
pr = cProfile.Profile()
pr.enable()
main()
pr.disable()
pstats.Stats(pr).sort_stats("tottime").print_stats(5)
# ③ timeit:比较两种写法(★注意取 min 不取 mean★)
setup = "data = list(range(1000))"
t1 = min(timeit.repeat("[x*2 for x in data]", setup=setup, number=1000, repeat=5))
t2 = min(timeit.repeat("list(map(lambda x: x*2, data))", setup=setup, number=1000, repeat=5))
print(f"推导式 {t1:.4f}s map+lambda {t2:.4f}s") # 推导式通常更快(少一次函数调用)
# ④ 命令行版 timeit(最常用,自动选择合适的 number)
# python -m timeit -s "s='a'*1000" "s.replace('a','b')"
# → 10000 loops, best of 5: 21.3 usec per loop
# ⑤ tracemalloc:找内存分配的大户
tracemalloc.start()
snap1 = tracemalloc.take_snapshot()
big = [str(i) for i in range(100_000)]
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, "lineno")[:3]:
print(stat) # 输出:文件:行号 size=+3.8 MiB count=+100001
# ⑥ 自己写个最简计时装饰器(生产里给关键路径打点用)
import functools, time
def timed(fn):
@functools.wraps(fn)
def wrapper(*a, **kw):
t0 = time.perf_counter() # ★单调时钟,不受系统校时影响★
try:
return fn(*a, **kw)
finally:
print(f"{fn.__name__} 耗时 {time.perf_counter()-t0:.4f}s")
return wrapper
⚠️ 看 profile 结果的关键:分清
tottime和cumtime。tottime(total time)是函数体自身执行的时间,不含它调用的子函数;cumtime(cumulative time)是从进入到退出的全部时间,包含所有子函数。用法完全不同:main()的cumtime必然接近程序总时长(它包住了一切),但tottime可能只有 0.001 秒——所以按cumtime排序是为了找「耗时的调用链」(从上往下追责任),按tottime排序才是找「真正在消耗 CPU 的那个函数」(下手优化的地方)。实践流程是:先sort_stats("cumtime")看清时间大致流向哪条链路,再sort_stats("tottime")找到链路末端真正干活的函数,然后print_callers(那个函数)看是谁在疯狂调用它——很多时候「热点函数本身没问题,是被调用了 200 万次」,此时该优化的是调用方而不是函数体。另外记住ncalls列:形如120/20表示「总调用 120 次,其中非递归 20 次」。
完整版教学
一、先测量再优化:三层工具的分工
优化的正确顺序(跳过任何一步都会白干):
① 确认真的慢 → 有没有明确的性能指标?(P99 延迟、吞吐、任务总时长)
② 定位到函数 → cProfile(宏观,看哪个函数占大头)
③ 定位到行 → line_profiler(微观,看函数内哪一行慢)
④ 改 → 换算法 / 换数据结构 / 减少调用次数 / 上 C 扩展
⑤ 验证 → timeit 或重新 profile,★用数字证明确实变快了★
三层工具的量级和用途:
┌──────────────┬─────────────┬────────────────────────────┐
│ 工具 │ 粒度 │ 回答的问题 │
├──────────────┼─────────────┼────────────────────────────┤
│ cProfile │ 函数级 │ "整个程序时间花在哪些函数上" │
│ line_profiler │ 行级 │ "这个函数里哪一行最慢" │
│ timeit │ 语句级 │ "A 写法和 B 写法哪个快" │
│ py-spy │ 函数级(采样) │ "线上这个进程现在在干嘛" │
│ tracemalloc │ 分配点 │ "内存涨在哪一行" │
└──────────────┴─────────────┴────────────────────────────┘
为什么"凭直觉猜热点"几乎总是错的(真实案例的量级):
猜测:JSON 解析慢 实测:解析 0.3s,而"每条记录都 re.compile 一次"花了 4.2s
猜测:数据库慢 实测:SQL 12ms,但 ORM 的 N+1 让它执行了 900 次 = 10.8s
猜测:算法复杂度高 实测:算法 0.1s,日志里的 f-string 格式化占了 2.4s
→ 程序的时间分布通常极度倾斜(少数几行占 90%),而这几行往往不是你想的那几行
★ 优化的第一性原则:不要优化没测过的东西;不要优化占比 <5% 的部分
(占比 5% 的代码即使优化到 0,总时长也只降 5%——阿姆达尔定律)
性能工作最容易犯的错是跳过测量直接改代码——凭直觉找出来的「瓶颈」大多不是真瓶颈,改完性能没变还引入了 bug。程序的时间分布通常极度倾斜:少数几行代码占掉 90% 的时间,而这几行往往和你的直觉不符(真正的杀手常常是「循环里每次都 re.compile」「ORM 的 N+1 查询」「日志字符串格式化」这类不起眼的东西)。所以流程必须是「测量 → 定位 → 改 → 再测量验证」,三层工具各司其职:cProfile 找函数、line_profiler 找行、timeit 验证改动。还有一条纪律来自阿姆达尔定律:占总时长 5% 的部分即使优化到零,整体也只快 5%——所以永远从占比最大的那块下手,占比小的地方再精妙的优化也是白费力气。
二、timeit:微基准怎么测才准
基本用法(命令行最方便,自动选择合适的循环次数):
python -m timeit -s "s = 'a'*1000" "s.replace('a','b')"
→ 20000 loops, best of 5: 12.4 usec per loop
↑ number(每轮循环次数) ↑ repeat(轮数),报告的是★最好的一轮★
代码里用:
timeit.timeit(stmt, setup, number=10000) # 跑 number 次的★总耗时★
timeit.repeat(stmt, setup, number=10000, repeat=5) # 重复 5 轮,返回 5 个总耗时
★ 正确的读数方式:min(repeat(...))
为什么取最小值而不是平均值:
测量噪声(GC、其他进程、CPU 调频、缓存未命中)★只会让结果变慢,不会变快★
→ 最小值是"最接近真实耗时"的那次;平均值被离群的慢样本污染
必须知道的行为:
① timeit 执行期间会★关闭 GC★(gc.disable)→ 结果比真实场景乐观
想包含 GC:timeit.timeit(stmt, setup="gc.enable()", ...)
② stmt 和 setup 都是★字符串★(在独立命名空间里 exec)
→ 用到的变量必须在 setup 里准备好,否则 NameError
→ 也可以传函数对象:timeit.timeit(lambda: f(x), number=1000)(但 lambda 本身有开销)
③ number 太小 → 计时器精度不够;太大 → 等太久
命令行版会自动挑(跑到总时长 ≥0.2s 为止),代码里要自己给
典型误用(结果全错):
✗ 测量里带 I/O、网络、随机数据 → 波动远大于差异
✗ 被测代码有★缓存副作用★:
timeit("fib(30)", setup="from functools import lru_cache; ...")
→ 第一次真算,之后全部命中缓存 → 测出来快得离谱
✗ 数据规模不真实:用 10 个元素测出来 A 快,实际 100 万元素时 B 快(复杂度不同)
✗ 只测一次就下结论(没有 repeat)
一个真实对比(1000 元素的列表乘 2,number=1000):
[x*2 for x in data] 0.041 s ← 推导式(字节码内联,最快)
list(map(lambda x: x*2, data)) 0.068 s ← 每个元素多一次 Python 函数调用
result=[]; for + append 0.075 s ← 还多一次 append 方法查找
→ 差距 ~1.8 倍;但如果 data 只有 10 个元素,这点差别在真实程序里毫无意义
timeit 是做微基准(microbenchmark) 的标准工具,它替你处理了三件容易做错的事:跑足够多次以摆脱计时器精度限制、重复多轮以观察波动、测量期间关闭 GC 以减少噪声。最重要的使用纪律是读数取 min(repeat(...)) 而不是平均值——所有测量噪声(GC、其他进程抢 CPU、频率调节、缓存未命中)只会让某次运行变慢、不会让它变快,所以最小值才是最接近「纯粹计算耗时」的估计,而平均值会被离群的慢样本拉偏。使用时的常见翻车点:stmt 和 setup 是字符串(在独立命名空间 exec,用到的变量必须在 setup 里准备);被测代码若带 lru_cache 之类缓存会从第二次起全部命中、测出虚假的高速;数据规模不真实会导致结论在生产上完全反过来(复杂度不同的两个实现,小数据和大数据的胜负常常相反)。另外记住 timeit 关掉了 GC,结果比真实场景乐观,涉及大量对象分配的代码要额外留意。
三、cProfile:怎么跑、怎么读
三种跑法:
① 整个脚本(最常用):
python -m cProfile -s cumtime app.py # 直接打印,按累计时间排序
python -m cProfile -o out.prof app.py # 存文件,之后慢慢分析(推荐)
② 代码里 profile 某个调用:
cProfile.run("main()", "out.prof")
③ profile 一段区间:
pr = cProfile.Profile(); pr.enable(); ...业务...; pr.disable()
用 pstats 分析(比直接打印强得多):
p = pstats.Stats("out.prof")
p.strip_dirs() # 路径太长时去掉目录前缀
p.sort_stats("tottime").print_stats(15) # 自身耗时 Top15 ← 找热点
p.sort_stats("cumtime").print_stats(15) # 累计耗时 Top15 ← 找调用链
p.print_callers("parse_line") # 谁调用了 parse_line(以及各调用多少次)
p.print_callees("main") # main 调用了谁
p.print_stats("mymodule") # 只看某个模块/正则匹配的函数
输出的六列含义(必须背):
ncalls 调用次数("120/20" = 总 120 次,其中非递归 20 次)
tottime ★函数自身★耗时(不含子函数) ← 找"真正干活的热点"
percall tottime / ncalls(平均每次自身耗时)
cumtime ★累计★耗时(含子函数、含递归) ← 找"耗时的调用链"
percall cumtime / 非递归调用次数
filename:lineno(function)
一份真实输出的读法:
ncalls tottime cumtime filename:lineno(function)
1 0.001 8.420 app.py:50(main) ← cumtime 大=时间流向这里
1000 0.012 8.310 app.py:30(process_batch) ← 继续往下追
200000 0.180 8.290 app.py:12(parse_line) ← 调用 20 万次!
200000 7.900 7.900 {method 'compile' of ...} ← ★tottime 最大=真凶★
结论不是"parse_line 慢",而是"parse_line 里每次都 re.compile"
→ 修法:把 re.compile 提到循环外(或用模块级常量)→ 20 万次编译变 1 次
★ 这就是为什么要同时看两种排序:
cumtime 告诉你"时间流向哪条链",tottime 告诉你"链末端谁在烧 CPU",
print_callers 告诉你"它为什么被调了那么多次"
cProfile 是标准库自带的确定性 profiler(对每一次函数调用/返回都插桩记录),跑法建议存成 .prof 文件再用 pstats 分析——因为同一份数据你需要按不同维度反复看。读输出的核心是那两列时间:tottime 是函数自身的执行时间(不含子函数)、cumtime 是含子函数的累计时间。main() 的 cumtime 必然约等于程序总时长,但它的 tottime 可能接近零,所以按 cumtime 排序是「从上往下追时间流向哪条链」,按 tottime 排序才是「找到链末端真正烧 CPU 的函数」。上面那份真实输出演示了完整的推理链条:时间流向 parse_line(cumtime 8.29s),但它自身只花了 0.18s,真正的大头是被调用 20 万次的 re.compile(tottime 7.9s)——所以问题不在函数本身,而在「循环里重复编译正则」,修法是把 re.compile 提到循环外。print_callers() 则回答「它为什么被调那么多次」,很多时候该优化的是调用方。
四、cProfile 的局限:什么时候它会骗你
局限 1:插桩开销大,且★分布不均★
cProfile 对★每一次 Python 函数调用★记录时间戳 → 整体慢 30%~100%
★ 关键问题不是"整体变慢",而是"变慢的比例不均匀":
- 调用次数多、每次很快的函数 → 开销占比高 → 被★严重放大★
- 调用次数少、每次很慢的函数 → 开销可忽略 → 相对被缩小
→ profile 出的绝对时间不可信,甚至相对排名也可能失真
→ 对策:只看数量级差异(10 倍以上的差距才有意义);
怀疑失真时用采样式 profiler(py-spy)复核
局限 2:只看得见 Python 层的函数调用
numpy/pandas/json/re 的 C 实现内部是"黑盒",全部时间算在调用它的那一行
→ 你只知道"np.dot 花了 8 秒",不知道内部原因(也确实优化不了内部)
→ 对数值计算,profile 的价值在于"发现无意中退回了 Python 循环"
局限 3:默认只 profile ★主线程★
多线程程序:其他线程的时间完全看不到
→ 用 yappi(支持多线程)或 py-spy(看得到所有线程)
异步程序:await 期间的"等待时间"会算进 cumtime,看起来协程很慢,
其实它只是在等 I/O(CPU 是空闲的)
→ 异步服务优先用 py-spy 看"CPU 在忙什么",或用 asyncio 的 debug 模式找阻塞点
局限 4:I/O 密集型程序的"慢"不在 CPU 上
cProfile 报告的是墙上时间,socket.recv 会显示为耗时巨大
→ 但这不是"代码慢",是"在等网络";优化方向是并发/批量,不是优化那行代码
局限 5:无法用于生产环境
30%~100% 的开销 + 内存增长,线上不能开
→ 线上用 py-spy(★采样式,无需改代码、可 attach 到运行中的进程★):
py-spy top --pid 1234 # 实时看哪个函数在占 CPU
py-spy dump --pid 1234 # 打印所有线程当前栈(查卡死神器)
py-spy record -o fg.svg --pid 1234 # 生成火焰图
确定性 vs 采样式(面试常问):
┌──────────┬────────────────────┬──────────────────────┐
│ │ 确定性(cProfile) │ 采样式(py-spy/pyinstrument)│
├──────────┼────────────────────┼──────────────────────┤
│ 原理 │ 每次调用都记录 │ 每隔 N ms 抓一次调用栈 │
│ 精度 │ 调用次数精确 │ 统计意义上准确 │
│ 开销 │ 30%~100% │ 1%~5% │
│ 短函数 │ ★严重放大★ │ 不失真 │
│ 生产可用 │ ✗ │ ✓(可 attach) │
└──────────┴────────────────────┴──────────────────────┘
理解 cProfile 的局限比会用它更重要。它是确定性 profiler——对每一次 Python 函数调用都插桩,因此开销高达 30%~100%,而且这个开销分布不均:调用次数极多、每次极快的小函数被严重放大,可能在报告里排到前面,误导你去优化一个其实不慢的东西。第二个局限是只看得见 Python 层:numpy、json、re 的 C 实现是黑盒,时间全算在调用它的那一行(对数值计算,profile 的主要价值反而是「发现自己无意中退回了 Python 循环」)。第三,它默认只统计主线程,多线程要用 yappi,而异步程序里 await 的等待时间会计入 cumtime,让「其实在等 I/O」的协程看起来很耗 CPU。最后,它不能用在生产环境——线上定位要用采样式 profiler py-spy:每隔几毫秒抓一次调用栈,开销只有 1%~5%,能直接 attach 到运行中的进程(py-spy top --pid、py-spy dump --pid 查卡死、py-spy record 出火焰图),不用改一行代码也不用重启服务。
五、逐行分析与火焰图:把范围缩到「行」
line_profiler(第三方,pip install line_profiler):
# 给要分析的函数加 @profile(★不用 import,kernprof 会注入★)
@profile
def parse(lines): ...
kernprof -l -v script.py
输出(每行一条):
Line # Hits Time Per Hit % Time Line Contents
12 200000 198000 0.99 2.3 for line in lines:
13 200000 7900000 39.50 92.1 pat = re.compile(r"...") ← ★92% 在这★
14 200000 480000 2.40 5.6 m = pat.match(line)
→ 一眼看到"哪一行占了 92%",比函数级精确得多
★ 代价:开销比 cProfile 更大(可能 10 倍以上),只用来分析已锁定的少数函数
火焰图(最直观的全局视图):
py-spy record -o flame.svg -- python app.py # 无需改代码
snakeviz out.prof # 把 cProfile 结果变成交互图
怎么看火焰图:
横轴 = 占用时间比例(★不是时间顺序★),越宽越耗时
纵轴 = 调用栈深度,上面的函数被下面的调用
找"最宽的平顶" → 那就是热点(宽且顶部平坦 = 自身耗时高)
pyinstrument(另一个好用的采样 profiler):
python -m pyinstrument app.py
→ 直接输出一棵按耗时排序的调用树,可读性比 pstats 好很多,开销也小
工具组合的实战路径:
线上告警(P99 变高)
→ py-spy top --pid 看当前 CPU 在哪个函数 (零侵入、秒级定位)
→ 本地复现 + cProfile -o out.prof (拿到完整调用关系)
→ snakeviz/pstats 找到可疑函数 (tottime + print_callers)
→ line_profiler 精确到行 (确认是哪一行)
→ 改(换算法/减少调用/缓存/批量化)
→ timeit 或重新 profile 验证 (★必须用数字确认★)
定位到函数之后,下一步是精确到行。line_profiler 用 @profile 标注目标函数(不需要 import,kernprof 会注入这个名字),输出每一行的命中次数、总耗时和占比——上面的例子里一眼就能看到「92% 的时间花在循环内的 re.compile」。它的开销比 cProfile 还大得多,所以只用来分析已经锁定的少数函数,不能全程序开。另一个强力工具是火焰图:横轴是时间占比而非时间顺序(这点常被误解),纵轴是调用栈深度,找「最宽的平顶」就是热点。工具组合成实战路径就是:线上用 py-spy top 秒级定位 → 本地 cProfile -o 拿完整调用关系 → snakeviz/pstats 找可疑函数 → line_profiler 精确到行 → 改 → 用 timeit 或重新 profile 验证。最后一步最容易被跳过,但「改完到底快了多少」必须有数字,否则你可能只是把慢的地方挪了个位置。
六、内存维度与优化决策
CPU 之外的另一半:内存
tracemalloc(标准库,3.4+):
tracemalloc.start()
snap1 = tracemalloc.take_snapshot()
...业务...
snap2 = tracemalloc.take_snapshot()
for s in snap2.compare_to(snap1, "lineno")[:10]:
print(s) # e.g. app.py:42: size=+38.1 MiB, count=+100001, average=399 B
→ 找"内存增量最大的分配点",是排查内存泄漏的第一工具
memory_profiler:@profile 装饰函数,逐行看内存占用(类似 line_profiler)
★ 常见内存问题:全局缓存只增不减、lru_cache 用在方法上导致实例无法回收、
一次性把大文件 readlines() 进内存、循环里累积中间列表
定位到热点之后,优化手段按"性价比"排序:
① 减少调用次数 / 换算法(O(n²)→O(n log n)) ← 收益最大,永远先做
例:循环内 re.compile 提到循环外 = 20 万次变 1 次
例:list 里 in 查找(O(n))改 set(O(1))
② 换数据结构 / 用标准库的 C 实现
例:手写循环求和 → sum();字符串拼接 → "".join()(不是 += )
例:频繁头部插入 list → collections.deque
③ 缓存(functools.lru_cache / 预计算查表)
★ 前提是"同样输入反复出现",否则只是白占内存
④ 批量化 / 并发(I/O 密集用 asyncio 或线程池,CPU 密集用进程池)
⑤ 换实现(numpy 向量化、Cython、PyPy、Rust 扩展) ← 成本最高,最后考虑
★ 决策纪律:
- 占比 <5% 的部分不优化(阿姆达尔定律:优化到 0 也只快 5%)
- 每次只改一处,改完立刻测(同时改三处就说不清是哪处起作用)
- 优化不能牺牲可读性时,先问"这段真的在热点上吗"
- 记录优化前后的数字("从 8.4s 降到 0.6s"),这是评审时唯一有说服力的东西
CPU 之外还有内存这条线:标准库的 tracemalloc 通过对比两个快照,直接给出「哪一行分配了多少内存」,是排查内存泄漏和内存暴涨的首选工具(常见元凶是只增不减的全局缓存、用在方法上的 lru_cache 导致实例无法回收、readlines() 把大文件一次读进内存)。定位完成后,优化手段应按性价比排序:先减少调用次数和换算法(把循环内的 re.compile 提出去,20 万次变 1 次,收益是数量级的;in 查找从 list 换成 set 是 O(n)→O(1)),然后才是换数据结构和用标准库的 C 实现("".join() 代替 +=、sum() 代替手写循环),再往后是缓存(前提是同样的输入确实反复出现)、批量化与并发,最后才考虑 numpy 向量化、Cython 这类高成本方案。最后是三条纪律:占比小于 5% 的地方不要碰、每次只改一处并立刻测量、记录优化前后的具体数字——「从 8.4 秒降到 0.6 秒」这样的结论才有说服力,也才能证明改动没有白做。
记忆钩子:「性能优化的顺序是『测量 → 定位 → 改 → 再测量验证』,凭直觉猜热点几乎总是错的(真凶常是循环里重复 re.compile、ORM 的 N+1、日志字符串格式化这类不起眼的东西)。★三层工具:cProfile 找函数、line_profiler 找行、timeit 验证改动,线上用 py-spy(采样式、可 attach、开销 1%~5%)。★读 cProfile 只需分清两列:tottime 是函数自身耗时(不含子函数)→ 找『真正烧 CPU 的热点』;cumtime 是累计耗时(含子函数)→ 找『时间流向哪条调用链』;再配 print_callers 回答『它为什么被调了 20 万次』——很多时候该优化的是调用方而不是函数体。★timeit 的纪律:取 min(repeat(…)) 不取平均(噪声只会让结果变慢不会变快)、stmt/setup 是字符串、它执行时关掉了 GC(结果偏乐观)、被测代码带缓存或数据规模不真实会让结论完全反过来。★cProfile 会骗你的四种情况:插桩开销 30%~100% 且对『调用多而快』的函数严重放大(只看数量级差异)、C 扩展内部是黑盒、默认只统计主线程、异步里 await 的等待时间会计入 cumtime。★最后两条纪律:占比 <5% 的地方不优化(阿姆达尔定律),每次只改一处并用数字记录前后差异。」
七、常见误区与追问
- 误区:
cumtime最大的函数就是最该优化的函数。cumtime包含所有子函数的时间,所以main()的cumtime必然接近程序总时长——但它自己可能一行有效计算都没做。按cumtime排序的用途是追踪时间流向哪条调用链(从上往下逐层追),真正要下手优化的是tottime(函数自身耗时,不含子函数)最大的那个函数。完整的推理链是三步:cumtime看时间流向 →tottime找链末端真正烧 CPU 的函数 →print_callers()看是谁在疯狂调用它。很多真实案例的结论是「这个函数本身没问题,是被调用了 20 万次」,此时该改的是调用方(把重复计算提到循环外、加缓存、批量化),改函数体本身收效甚微。 - 误区:cProfile 报告的时间就是真实耗时。
cProfile是确定性 profiler,对每一次 Python 函数调用/返回都插桩记录,整体开销 30%~100%,更麻烦的是开销分布不均:调用次数极多但每次极快的小函数,插桩开销可能比函数本身还大,在报告里被严重放大;而调用次数少、每次很慢的函数几乎不受影响。结果是绝对时间不可信、极端情况下相对排名也会失真。使用纪律是只看数量级差异(相差 10 倍以上才有把握),怀疑失真时用采样式 profiler(py-spy、pyinstrument)复核——它们每隔几毫秒抓一次栈,开销只有 1%~5%,对短函数不失真。 - 误区:
timeit的结果应该看平均值,更有代表性。 应该看min(timeit.repeat(...))。原因是所有测量噪声——GC 触发、其他进程抢 CPU、CPU 频率调节、缓存未命中、页错误——只会让某次运行变慢,不可能让它变快。因此最小值是「最接近纯粹计算耗时」的估计,而平均值会被离群的慢样本拉高,且方差越大越不可比。附带三个使用要点:timeit执行期间会关闭 GC,所以对大量创建对象的代码,结果比真实场景乐观;stmt/setup是在独立命名空间 exec 的字符串,变量必须在setup里准备好;被测代码若含lru_cache之类缓存,第二次起全部命中,会测出虚假的高速。 - 误区:微基准测出 A 比 B 快,就该在项目里把 B 全换成 A。 两个前提常常不成立。① 数据规模:微基准通常用很小的数据,而不同实现的复杂度不同,小数据快的实现在百万级数据上可能反而慢(比如线性查找 vs 建哈希表,小规模时建表的开销占主导)。② 占比:即使 A 确实比 B 快 3 倍,如果这段代码只占总时长的 2%,全项目替换的收益也不到 1.4%,却引入了 review 成本和 bug 风险——阿姆达尔定律要求你只优化占比大的部分。正确做法是:先 profile 确认这段代码确实在热点上,再用接近生产规模的数据做对比,最后在真实场景(而非微基准)里验证端到端的收益。
- 误区:异步程序也可以用 cProfile 找瓶颈。 有两个问题。①
await的等待时间会计入cumtime:一个协程await数据库返回花了 500ms,profile 里它就显示 500ms,但这段时间 CPU 完全空闲、事件循环在跑别的任务——把它当成「代码慢」去优化方向就全错了(正确方向是并发度、连接池、批量查询)。② cProfile 默认只统计主线程,多线程程序里其他线程的时间完全看不到(要用yappi)。异步/多线程服务的正确工具是py-spy:py-spy top --pid看CPU 实际在忙什么,py-spy dump --pid打印所有线程的当前调用栈(排查卡死、死锁的神器),py-spy record出火焰图——全部无需改代码、可直接 attach 到线上进程。 - 追问:确定性 profiler 和采样式 profiler 有什么区别,各自适合什么场景? 确定性(
cProfile、profile)在每次函数调用和返回时都插桩记录,能给出精确的调用次数和完整调用关系图,适合本地开发时深入分析(尤其是想知道「这个函数到底被调了多少次」);代价是开销 30%~100%、对短小高频函数严重失真、不能用于生产。采样式(py-spy、pyinstrument、austin)每隔固定时间(如 10ms)抓一次当前调用栈,用统计分布来估计各函数的耗时占比,开销只有 1%~5%、对短函数不失真、py-spy还能attach 到已运行的进程(不改代码、不重启),适合线上排查和长时间运行的服务;代价是没有精确调用次数,且运行时间太短时样本不足、结果不稳定。实践中两者互补:线上先用 py-spy 定位大致方向,本地用 cProfile 拿到精确的调用次数和调用关系。 - 追问:火焰图应该怎么看?横轴是时间顺序吗? 不是时间顺序——这是最常见的误读。火焰图的横轴是「占用时间的比例」,同一层的函数按名字排序(或合并同名帧)后横向铺开,越宽表示占用的总时间越多;纵轴是调用栈深度,一个格子上方的格子是它调用的函数。看图的方法是:先找最宽的格子(占比最大的调用路径),然后向上找到「宽且顶部平坦」的那一层——顶部平坦意味着它自己就在消耗 CPU(没有把时间转交给子函数),那就是热点。反过来,一个很高很窄的塔只是调用链很深但不耗时,不用管。
py-spy record -o fg.svg可以直接生成,snakeviz能把 cProfile 的.prof文件转成交互式的类似视图(它用的是 sunburst/冰柱图,读法相同)。 - 追问:定位到热点之后,优化手段应该按什么顺序尝试? 按性价比从高到低:① 减少调用次数或换算法——把循环内的
re.compile/属性查找/重复计算提到循环外(20 万次变 1 次)、把list里的in查找换成set(O(n)→O(1))、把 O(n²) 的嵌套循环换成哈希表,这类改动收益常常是数量级的,且几乎不牺牲可读性。② 换数据结构或改用标准库的 C 实现——"".join()代替循环+=、sum()/any()代替手写循环、频繁头部插入用collections.deque。③ 缓存(lru_cache、预计算查表),前提是同样的输入确实会反复出现,否则只是白占内存。④ 批量化与并发——I/O 密集用 asyncio 或线程池,CPU 密集用进程池(GIL 决定了多线程对 CPU 密集无效)。⑤ 换实现——numpy 向量化、Cython、Rust 扩展、PyPy,成本和维护负担最高,放最后。全程遵守两条纪律:占比小于 5% 的不碰,每次只改一处并立刻用数字验证。
八、加强记忆
性能工作的骨架是「测量 → 定位 → 改 → 再测量验证」,凭直觉猜热点几乎总是错的——真凶常常是循环内重复的 re.compile、ORM 的 N+1 查询、日志字符串格式化这类不起眼的东西,而程序的时间分布极度倾斜(少数几行吃掉 90%)。三层工具:cProfile 找函数、line_profiler 找行(开销大,只用于已锁定的少数函数)、timeit 验证改动,线上用 py-spy(采样式、开销 1%~5%、可直接 attach 到运行中的进程,top 看热点、dump 查卡死、record 出火焰图),内存维度用标准库 tracemalloc 对比快照找分配大户。读 cProfile 只需分清两列:tottime 是函数自身耗时(不含子函数)→ 找真正烧 CPU 的热点;cumtime 是累计耗时(含子函数)→ 追时间流向哪条调用链;再用 print_callers() 回答「它为什么被调用了 20 万次」——很多时候该优化的是调用方而非函数体。timeit 的纪律:取 min(repeat(...)) 而非平均值(噪声只会让结果变慢不会变快)、stmt/setup 是字符串、执行期间关闭了 GC(结果偏乐观)、被测代码带缓存或数据规模不真实会让结论完全反过来。cProfile 会骗你的四种情况:插桩开销 30%~100% 且对「调用多而每次快」的函数严重放大(只看数量级差异)、C 扩展(numpy/json/re)内部是黑盒、默认只统计主线程(多线程用 yappi)、异步里 await 的等待时间会计入 cumtime(那是在等 I/O,不是代码慢)。优化手段按性价比排序:减少调用次数/换算法 → 换数据结构/用 C 实现 → 缓存 → 批量化与并发 → numpy/Cython/PyPy。两条铁律:占比小于 5% 的部分不优化(阿姆达尔定律),每次只改一处并用具体数字记录前后差异。