2790 字
约 9 分钟
22
S10-A06 · Python 性能优化顺序:先定位再动手

优化了一圈,跑起来没变快——这件事几乎每个 Python 项目都发生过一次。典型过程是:看到一段循环不顺眼,换成列表推导;看到字符串拼接,改成 join;看到字典访问,改成局部变量。三天后跑基准,端到端耗时几乎没动——因为那几秒里绝大部分时间在等一个外部 HTTP 接口。

问题不在技术,在顺序:先动手,后定位。这篇给的是顺序本身——每个阶段该回答什么问题、用什么工具、什么时候应当停下来承认"这不是本地能优化的"。


1. 动手前必须回答的三个问题

问题 不合格的回答 合格的回答
瓶颈在哪一段 "感觉在计算" "占最大头的函数是 X,其内部耗时主要在 json.loads"(要给占比,不是感觉)
目标是多少 "越快越好" "P95 降一半"或"单批耗时进到秒级以内",须可测
可接受的改动范围 未想过 "不改接口契约、不引入新运行时"

第二个问题尤其容易被跳过。没有目标就没有停止条件,优化会一直做到"看起来没问题"为止,然后在下一次需求变更时被覆盖掉。第三个问题决定方案池:如果"不改接口"是硬约束,那么缓存、批量化、异步化里有一部分从一开始就不在候选里。


2. 测量四层:从粗到细,逐层下钻

层 工具 回答 开销
端到端 time、time.perf_counter、hyperfine 到底慢不慢、波动多大 近零
函数级 cProfile、py-spy record、scalene 哪个函数占大头 中到高
行级 line_profiler 函数内哪一行重 高(逐行计时)
系统级 py-spy dump、strace、perf 在等 IO、等锁,还是真在算 低
$ hyperfine --warmup 3 --runs 10 'python -m app.batch'      # 端到端,先确认值得优化
$ python -m cProfile -o prof.out -m app.batch               # 函数级,落文件便于统计
$ python -c "import pstats; pstats.Stats('prof.out').sort_stats('cumtime').print_stats(20)"
$ py-spy record -o prof.svg --pid 12345 --duration 30       # 生产可用,无需改代码
$ py-spy dump --pid 12345                                   # 一次性看栈,判断是否卡在 IO

顺序必须是端到端 → 函数级 →(必要时)行级。跳过第一层直接上 profiler 的典型后果是:花了两小时优化一个只占 3% 的函数。


3. cProfile 的开销会改变结论

cProfile 是确定性的函数级插桩:每次调用与返回都要记账。它的开销不是常数,而与被调用次数成正比——一个执行 10 万次的小函数,在被 profiler 观察时可能从 5% 膨胀成 40%。

现象 原因 应对
小函数突然排第一 调用次数放大开销 换 py-spy(采样型,不插桩)
总耗时远大于真实值 插桩 + 序列化开销 只信相对排名,不信绝对秒数
递归深度失真 递归调用计数膨胀 用 cumtime 而非 tottime 判断
C 扩展内部不可见 profiler 只看到 Python 帧 结合 perf 或 scalene
# 采样型测量在生产上更诚实:py-spy 直接读进程内存的栈,不改代码、不加插桩
# $ py-spy top --pid 12345          # 实时看热点
# $ py-spy record -f speedscope -o s.json --pid 12345

判据:要看"谁被调用得最多",用采样;要看"谁调用了谁",用 cProfile。两者混用会把"调用次数多"误读为"耗时长"。


4. IO 密集还是 CPU 密集:算等待占比

观察 IO 密集 CPU 密集
栈顶常见 select、read、recv、wait 业务函数、json、hashlib
CPU 使用率 低(单核远未饱和) 接近 100%
加并发是否有收益 有(线程/协程都行) 只在多进程下有
加机器是否有收益 有 有
# 等待占比的粗算:wall clock 与 CPU time 的比值
$ /usr/bin/time -v python -m app.worker 2>&1 | grep -E 'Elapsed|User time|System time'
# Elapsed 30s / User 3s -> 90% 在等,优化计算毫无意义

比值是最便宜的判据:wall / cpu > 5 就是 IO 密集,此时所有"减少 Python 解释开销"的努力都被等待吞掉。这也是最常见的误判来源——本地跑批时是 CPU 密集(数据在内存),线上跑时是 IO 密集(数据在网络),用本地的结论去优化线上的代码,方向一开始就错。


5. 收益排序:五档,从高到低

档 手段 典型收益 风险
1 算法与数据结构 数量级 低(但要重构)
2 减少调用次数(N+1、循环内查询) 10 倍以上 低
3 批量化(executemany、批量 HTTP) 数倍 中(错误处理变复杂)
4 换实现(numpy、C 扩展、orjson) 数倍 中高(依赖与兼容)
5 局部微优化(局部变量、推导式) 百分之几 低但收益也低
# 第 2 档的典型:循环里查数据库,把 1000 次往返压成 1 次
# 优化前
for oid in order_ids:
    order = db.query_one("SELECT * FROM orders WHERE id=%s", oid)   # 1000 次 RTT
# 优化后
rows = db.query_all("SELECT * FROM orders WHERE id = ANY(%s)", (order_ids,))  # 1 次

微优化之所以排最后,不是因为它无效,而是因为它的收益上限被前四档决定:把占 2% 的代码优化 30%,端到端提升 0.6%,落在测量噪声里。先做能改数量级的事。


6. "Python 慢"的三种真实原因

原因 表现 解法 边界
解释执行 纯 Python 循环占大头 向量化、C 扩展、换算法 循环体本身很轻时,向量化收益有限
对象开销 大量小对象、字符串、字典构造 复用、__slots__、生成器 对象生命周期短时,GC 压力才是主因
GIL 争用 多线程 CPU 密集无加速 多进程、释放 GIL 的扩展 IO 密集时 GIL 不是瓶颈

三者要分开诊断,因为解法互斥:一个"多线程没提速"的服务,可能瓶颈是解释执行(该向量化),也可能是 GIL(该多进程),还可能是锁(该改共享方式)。用 py-spy dump 看栈就能区分——栈顶集中在业务代码是前者,集中在 acquire 是后者。


7. 明知会炸的写法,与缓存的失效代价

写法 代价 替代
循环内查库/调接口 往返次数 × 延迟 批量查询、预取
循环内字符串 += 平方级复制 join / StringIO
循环内编译正则 重复解析 提到模块级常量
循环内 json.loads 同一份数据 重复解析 解析一次传对象
无界缓存 内存增长、失效混乱 有界 + TTL
import re, functools
PATTERN = re.compile(r"\d{4}-\d{2}-\d{2}")          # 模块级,编译一次

@functools.lru_cache(maxsize=1024)                  # 有界,避免无界增长
def normalize(code: str) -> str:
    return code.strip().upper()

缓存的成本不在存储,在失效:只要加了缓存,就必须回答"什么时候它会变脏"。答不出来,就等于用一致性换速度,而且这笔交换是隐式的——半年后某个用户看到旧数据,没人会想到是当初那行 lru_cache。


8. 基准测试与回归防护

要素 错误做法 正确做法
预热 直接跑一次 先跑几轮让 JIT/缓存/连接池就绪
重复 取单次最快值 多次取中位数与分位数
隔离 边跑边刷网页 固定机器、固定负载、关闭同机服务
度量 只看平均值 中位数 + P95,避免长尾被平均掉
对比 改完凭感觉 保留旧实现做 A/B,同进程交替跑
# pyperf / timeit:把"测量"写成可复现的脚本,而不是临时敲的命令
import timeit
base = timeit.timeit("impl_a(data)", globals=globals(), number=200, repeat=5)
new  = timeit.timeit("impl_b(data)", globals=globals(), number=200, repeat=5)
print(f"median a={sorted([base])[2]:.4f}s  b={sorted([new])[2]:.4f}s")

回归防护的成本要明说:把性能基准放进 CI 意味着每次构建多花几分钟,并且要维护阈值。可行的最小做法是只对 2–3 个核心路径设"不得慢于基线 20%"的守门线,而不是给每个函数都设阈值——后者会因为机器噪声不断误报,最终被整体关掉。


9. 排错:五种典型现象

现象 原因 方向
优化后没变快 瓶颈在外部服务 先看等待占比
本地快、线上慢 数据规模或 IO 形态不同 用线上数据采样复现
profiler 结果每次都不同 采样噪声 + 未预热 多次取中位、固定环境
加了并发更慢 任务粒度过小、GIL 争用 增大批量、换进程
内存随运行时间增长 无界缓存、对象泄漏 有界缓存 + 对象计数

10. 边界:这些情况不该在本地优化

场景 判断 理由
一次性脚本 不优化 跑完就扔,优化时间超过节省时间
总耗时在毫秒级 不优化 噪声大于收益,测不出来
瓶颈在外部服务/数据库 不优化本地 本地改 10% 也抵不过一次慢查询
目标不明确 先定目标 无停止条件等于无限投入
代码即将重写 不优化 优化成果会随重写一起消失

一句话:性能优化的第一产出不是更快的代码,而是一个能重复的测量。有了测量,顺序自然清楚;没有测量,所有努力都在赌方向。


11. 自测

  1. 端到端 8 秒的批处理,你会先测什么再决定优化哪一段?
  2. cProfile 为什么可能让你误判"最该优化的函数"?
  3. 用什么最便宜的指标判断任务是 IO 密集还是 CPU 密集?
  4. 为什么"减少调用次数"排在"局部微优化"前面?
  5. 加缓存时必须同时回答哪个问题?答不出来会付什么代价?
  6. 性能基准进 CI,最小可行的守门方式是什么?
S10-A06 · Python 性能优化顺序:先定位再动手
https://www.clxhxhhr.top/posts/6183/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。