《Python编程入门》18.2 性能剖析与优化入门

先测量再优化:用 perf_counter 与 timeit 做微基准并识别四大陷阱,用 cProfile/pstats 定位热点、tracemalloc 剖析内存,实测 list 与 set 查找、sum/any、__slots__、lru_cache、array 的真实收益,并讲清 sys.getsizeof 的局限与何时不该优化。

本节目标:掌握「先测量、再优化」的完整方法论——微基准、CPU 剖析、内存剖析,并用实测数据判断哪些优化真有用、哪些只是心理安慰。
适用版本:Python 3.12+(实测 3.14.6)

18.2 性能剖析与优化入门

上一节我们把项目做出来了,它「能跑」。但能跑不等于跑得快。优化最大的坑是凭直觉改代码:你以为是热点的函数可能根本不慢,你精心微优化的循环可能毫无收益。铁律:先测量,再优化——流程固定为「测量 → 定位热点 → 只改热点 → 再测量验证」,没有测量就没有优化,只有猜测。

18.2.1 微基准:perf_counter 与 timeit

测一段代码的耗时,用 time.perf_counter()(单调、高精度,回指第 1.3 节):

import time, timeit
def work(n: int) -> int:
    total = 0
    for i in range(n):
        total += i * i
    return total
start = time.perf_counter()
work(100_000)
print(f"perf_counter 单次: {(time.perf_counter() - start) * 1000:.2f} ms")
t = timeit.timeit(lambda: work(100_000), number=20)
print(f"timeit 平均: {t / 20 * 1000:.2f} ms")
perf_counter 单次: 4.33 ms
timeit 平均: 3.92 ms

timeit 会自动重复并取最快的一次,抵消启动噪声。命令行版更省事:

python -m timeit -s "total=0" "for i in range(1000): total += i*i"
10000 loops, best of 5: 34.9 usec per loop

18.2.2 微基准的四个陷阱

陷阱一:只用一次结果。 量一个快到接近时钟分辨率的东西:

start = time.perf_counter()
s = sum(range(100))
print(f"sum(range(100)) = {s}, 耗时 {(time.perf_counter()-start)*1e6:.2f} us")
sum(range(100)) = 4950, 耗时 4.37 us

单次结果里混着解释器调度、时钟抖动,必须重复多次取统计值。

陷阱二:缓存预热。 首次调用往往在建立缓存,慢得离谱:

_cache = {}
def cached_square(n):
    if n not in _cache:
        time.sleep(0.01)          # 模拟一次昂贵初始化
        _cache[n] = n * n
    return _cache[n]

t0 = time.perf_counter(); cached_square(7)
print(f"首次 {(time.perf_counter()-t0)*1000:.2f} ms")
t0 = time.perf_counter(); cached_square(7)
print(f"再次 {(time.perf_counter()-t0)*1000:.2f} ms")
首次 14.13 ms
再次 0.00 ms

陷阱三:GC 干扰。 制造循环引用垃圾时,垃圾回收会混进耗时(churn 反复造 5000 个自引用 Node):

import gc
class Node:
    def __init__(self): self.ref = None
def churn():
    for _ in range(400):
        nodes = [Node() for _ in range(5000)]
        for n in nodes: n.ref = n     # 自引用环
gc.enable(); t0 = time.perf_counter(); churn(); t_gc = time.perf_counter() - t0
gc.disable(); t0 = time.perf_counter(); churn(); t_no = time.perf_counter() - t0
gc.enable()
print(f"GC 开启 {t_gc*1000:.1f} ms vs 关闭 {t_no*1000:.1f} ms")
GC 开启 350.2 ms vs 关闭 152.3 ms

陷阱四:循环里做无关工作。 一个被无关字符串格式化淹没的测量:

def clean(target):
    total = 0
    for i in range(target): total += i
    return total
def noisy(target):
    total = 0
    for i in range(target):
        total += i
        _ = f"step-{i:>6d}-{total:>10d}"   # 与目标无关
    return total
print(f"干净 {timeit.timeit(lambda: clean(20000), number=50)*1000:.1f} ms "
      f"vs 混入格式化 {timeit.timeit(lambda: noisy(20000), number=50)*1000:.1f} ms")
干净 20.7 ms vs 混入格式化 323.9 ms

18.2.3 cProfile:找到热点

微基准只测「一段已知代码」,但真实程序你根本不知道慢在哪。用 cProfile 做函数级剖析:

import cProfile, pstats
profiler = cProfile.Profile()
profiler.enable()
run()                    # 你的程序入口
profiler.disable()
profiler.dump_stats("profile.out")
pstats.Stats(profiler).sort_stats("tottime").print_stats(8)

在一个 20 万词的统计任务上,真实输出:

         4006848 function calls in 0.758 seconds
   Ordered by: internal time
   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
   200000    0.278    0.000    0.459    0.000 profile_demo.py:11(normalize)
  1200000    0.092    0.000    0.092    0.000 {method 'lower' of 'str' objects}
  1200000    0.089    0.000    0.089    0.000 {method 'isalpha' of 'str' objects}
   200000    0.077    0.000    0.190    0.000 random.py:347(choice)
        1    0.053    0.053    0.538    0.538 profile_demo.py:19(count_words)

逐列解读:

列含义
ncalls被调用次数。1200000 说明 lower/isalpha 被调了 120 万次
tottime本函数自身耗时(不含子调用)。normalize 的 0.278s 就是它自己
cumtime累计耗时(含它调用的所有子函数)。count_words 是 0.538s
percall每次调用的平均耗时(tottime/ncalls 与 cumtime/ncalls 两列)

结论很清楚:normalize 里逐字符的 += 拼接是热点(tottime 最高),而它的 cumtime 0.459s 又说明大部分时间花在了它调用的 lower/isalpha 上。

18.2.4 pstats:排序与过滤

pstats.Stats("profile.out") 让你换视角看同一份数据:.sort_stats("cumtime").print_stats(5) 按累计耗时排序,.print_callers("normalize") 列出谁调用了它。按 cumtime 排序时 run 排第一(0.758s,因为它调用了所有东西)——这正是 tottime 与 cumtime 的区别:找热点用 tottime,理清调用链用 cumtime。print_callers 则直接告诉你 normalize 是被 count_words 调了 20 万次。

18.2.5 line_profiler:逐行剖析(只讲不跑)

函数级还不够细时,line_profiler 能把耗时精确到每一行。用法是在函数上加 @profile,再跑 kernprof -l -v script.py,输出每行的 Hits / Time / %Time。它需要 pip install line_profiler,本环境未预装,故本节不实跑。原则不变:只有当 cProfile 指到某个函数、但你不知道是函数里哪一行时,才值得上逐行剖析。

18.2.6 tracemalloc:内存剖析

CPU 之外,内存同样是瓶颈。tracemalloc 追踪每一次分配:

import tracemalloc
def build_records(n):
    return [{"id": i, "name": f"user-{i}", "scores": [i, i+1, i+2]} for i in range(n)]
tracemalloc.start()
keep = build_records(20_000)
blob = "".join(f"{i}," for i in range(20_000))
current, peak = tracemalloc.get_traced_memory()
print(f"当前 {current/1024/1024:.2f} MiB, 峰值 {peak/1024/1024:.2f} MiB")
for i, stat in enumerate(tracemalloc.take_snapshot().statistics("lineno")[:10], 1):
    print(f"{i}. {stat.size/1024:8.1f} KiB  {stat.traceback[0].filename.split('/')[-1]}:{stat.traceback[0].lineno}")
当前 8.07 MiB, 峰值 9.12 MiB
1.   8157.9 KiB  tm2.py:3
2.    106.4 KiB  tm2.py:6
3.      0.4 KiB  tm2.py:5
4.      0.1 KiB  tm2.py:8
5.      0.1 KiB  tm2.py:7

第 3 行(那个列表推导)独占 8.2 MiB——因为 2 万个 dict 每个又挂了 3 个元素的 list。statistics("lineno") 按代码行聚合,直接告诉你内存在哪一行被吃掉。对比两个快照还能定位泄漏(snapshot2.compare_to(snapshot1, "lineno"))。

18.2.7 sys.getsizeof 的局限

sys.getsizeof 只算对象本身,不算它引用的其他对象:

import sys
lst = list(range(1000))
print(f"列表本身    : {sys.getsizeof(lst)} 字节")
print(f"其中的 int  : {sum(sys.getsizeof(x) for x in lst)} 字节")
print(f"指针合计    : {sys.getsizeof(lst) - sys.getsizeof([])} 字节 ≈ 1000 × 8")
print(f"一个 int    : {sys.getsizeof(5)} 字节, 一个 float: {sys.getsizeof(1.0)} 字节")
列表本身    : 8056 字节
其中的 int  : 28000 字节
指针合计    : 8000 字节 ≈ 1000 × 8
一个 int    : 28 字节, 一个 float: 24 字节

列表本身只有 8056 字节(8 字节指针 × 1000 + 头部),但它指向的 1000 个 int 又是 28000 字节。所以算一个容器的真实内存,必须递归累加,getsizeof 只给了第一层。

18.2.8 gc 与引用循环

Python 靠引用计数回收内存,但引用计数解不开循环。先测量再优化同样适用于内存:

import gc, sys
class Node:
    def __init__(self, name): self.name = name; self.peer = None
a = Node("a"); b = Node("b")
a.peer = b; b.peer = a          # 循环引用
print(f"a 引用计数: {sys.getrefcount(a)}")
del a, b
print(f"gc.collect() 回收了 {gc.collect()} 个对象")
a 引用计数: 3
gc.collect() 回收了 120 个对象

循环引用让两个对象的计数都停在 1,只能靠 gc 的分代回收。weakref 是打破循环的标准手段(不增加引用计数)。第 12 章讲过 GIL:纯 Python 计算密集型任务用多线程也快不了,那是另一类性能问题。

18.2.9 常见优化手段与实测收益

① 换数据结构:list 查找 vs set/dict。 10 万次成员判断各跑 5 遍:

list : 35.194s
set  : 0.010s  (3693× 更快)
dict : 0.010s  (3441× 更快)

x in list 是 O(n),x in set/dict 是 O(1)。这是性价比最高的一类优化:几乎零成本,收益上千倍。

② 用内置函数。 同样求和 100 万个数:

sum 手写循环      : 0.115s
sum 生成器表达式  : 0.083s
sum 列表推导      : 0.056s
sum 直接传列表    : 0.016s
any 手写循环      : 0.055s
any 生成器表达式  : 0.095s

内置 sum/any/all 在 C 层循环,普遍快于手写循环。注意 any 的生成器版反而更慢——它每项都有 yield 开销,而手写循环能提前 return 短路。没有普适答案,测了才知道。

③ 循环不变量外提。 把常量提到循环外几乎无收益(0.112s vs 0.117s,属噪声);但把属性查找提到循环外有约 10% 收益(0.124s → 0.112s)。属性查找才是值得外提的对象。

④ 避免不必要的拷贝。 deepcopy 递归复制一切,代价远高于浅拷贝:

deepcopy 5 万行  : 302.9 ms
浅拷贝每行(推导): 29.0 ms
完全不复制的代价 : 0.0017 ms (仅引用)

deepcopy 递归复制一切,代价是浅拷贝的 10 倍。能传引用就别复制。

⑤ __slots__ 省内存。 造 20 万个只有两个属性的对象:

PointDict   峰值  27.49 MiB
PointSlots  峰值  19.86 MiB

__slots__ 省下约 28% 内存。注意:别用 sys.getsizeof(instance.__dict__) 去测——同一个实例上它会浮动,必须用 tracemalloc 测整体分配。

⑥ 缓存。 functools.lru_cache 把重复调用变成查表:

缓存未命中: 126.3 ms
缓存命中  : 0.1 ms

命中比未命中快约 1000 倍,但前提是「相同参数会被反复调用」;参数每次都不同时,缓存只会白占内存。

⑦ array / numpy。 100 万个整数的容器内存对比:

list 总计         : 34.33 MiB   # 8 MiB 指针 + 26.7 MiB int 对象
array('l') 总计   :  7.80 MiB

array 把数值内联存储,省下大量 int 对象。数值密集计算还可以上 numpy(本环境未预装,故只讲不跑)——它把整块数据交给 C 层向量化,是数据科学的默认选择。

18.2.10 什么时候不该优化

优化有代价:可读性下降、bug 增多、维护成本上升。以下情况不该优化:

  • 还没测出瓶颈:没有 cProfile 数据支撑的优化都是猜测。
  • 代码只跑一次:启动脚本、迁移脚本,快 10ms 毫无意义。
  • 瓶颈在 I/O 或网络:CPU 微优化救不了慢查询,先优化 SQL / 加缓存 / 并发。
  • 收益低于噪声或牺牲可读性:像「常量外提」那样测不出差异的,别写。

正确顺序永远是:先保证正确,再保证可读,最后才优化真正的热点。

小结

  • 铁律:先测量,再优化;流程是「测量 → 定位热点 → 只改热点 → 再测量」。
  • 微基准用 perf_counter/timeit,警惕四大陷阱:单次结果、缓存预热、GC 干扰、循环里的无关工作。
  • cProfile + pstats 定位 CPU 热点(找热点看 tottime,理调用链看 cumtime);tracemalloc 剖析内存分配,sys.getsizeof 只算对象本身、必须递归累加。
  • 高性价比优化:用 set/dict 替换 list 查找、用内置函数、避免 deepcopy、__slots__ 省内存、lru_cache 缓存重复计算;没测出瓶颈、只跑一次、瓶颈在 I/O、收益低于噪声时——不要优化。

到这里,你已经掌握了「让代码跑得更快」的方法论。但技术的世界很广,Python 生态更是庞大——下一节我们聊聊学习路径与生态选型:读完之后该往哪走、面对一堆库该怎么选。

阅读导航:上一节:从零构建一个完整项目 · 下一节:学习路径与生态选型 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时