← 返回模块
3.3.1.1beta 可读 · 未来免费内容校验中内容版本 2026-05-27

性能剖析与内存分析

3.3.1 · 并发与性能 · 编程

周二上午十点的滚动均值

某私募研究台周二上午十点。你刚把 3.2.2 收尾那条 8 步管道交给量化团队,篮子是沪深300 ETF(510300.SH)和三只 A 股票面 {600519.SH, 000001.SZ, 600036.SH},日收益矩阵 (252, 4)。PM 把它拉到全市场场景版本——篮子扩到 100 只票、回溯 100 个交易日——结果纯 Python 嵌套循环算出来的 20 日滚动均值跑了 11 秒,下游 T+1 收盘报表过不去。「你猜瓶颈在哪?」PM 问。你直觉是内层那段均值——但​​别凭直觉改代码​​。多数生产 bug 长在「我以为我知道」上。这一课只立一条纪律:​​没测量过的代码不优化​​。本课给你三层测量工具(墙钟、函数级、行级)和一层内存归因,并把它们拼成一条可重复的优化闭环。

第一层:墙钟计时(perf_counter 与 timeit)

最便宜的工具是 time.perf_counter()——一个单调(monotonic)的高精度计时器,单位秒。不要用 time.time():那是挂钟,NTP 校时时可能往回跳,你会看到 dt<0dt < 0 的诡异结果。模板就一段:

from time import perf_counter
t0 = perf_counter()
result = work(1_000_000)
dt = perf_counter() - t0
print(f'work took {dt:.3f} s')

perf_counter 适合一次性测一段足够长(>10 ms)的代码。微秒级别的片段——一次列表推导、一次 dot 运算——单次读数会被时钟分辨率与系统抖动淹没。这时换用 timeit:它把目标语句重复 N 次取总耗时,默认关闭垃圾回收(garbage collection, gc.disable())以降噪,IPython / Jupyter 里直接用 %timeit expr%%timeit 魔法。下面这段是纯 Python 推导式与 numpy 内积的标准对照微基准(microbenchmark):

import timeit

py_time = timeit.timeit(
    stmt='sum(x*x for x in range(1000))',
    setup='',
    number=10_000,
)
np_time = timeit.timeit(
    stmt='np.dot(arr, arr)',
    setup='import numpy as np\nfrom numpy import arange, dot\narr = arange(1000)',
    number=10_000,
)
print(f'py={py_time:.3f}s  np={np_time:.3f}s')

两者通常相差两个数量级——这是 L4「numba / Cython / cffi」的伏笔,本课只负责测出比值。

第二层:函数级归因(cProfile + pstats)

perf_counter 告诉你「这段慢」,但不告诉你「哪个函数慢」。cProfile 是标准库自带的确定性剖析器(deterministic profiler),逐函数累计自用时间(tottime)与累计时间(cumulative time)。推荐写法是先落盘再回看,避免输出淹没终端:

python -m cProfile -o profile.out script.py
python -c "import pstats; pstats.Stats('profile.out').sort_stats('cumulative').print_stats(20)"

两种排序看不同维度。cumulative 把一个函数及其调用链的总耗时归一起,最适合​​自顶向下​​找入口热点;tottime 只算函数自己(不含子调用),适合​​自底向上​​找叶子开销。两者都看,不只看一种。.prof 文件再丢给 snakeviz profile.out 能在浏览器里出一张交互式调用图——社区标配的可视化器。回到开头那张 100×100 滚动均值:cProfilecumulative 排序会把锅扣到外层 rolling_mean(),再按 tottime 重排,会指向内层那个被反复调用的 mean()

第三层:行级归因(line_profiler)

cProfile 锁定到函数粒度。要进一步看到​​哪一行​​最贵,装一个 line_profilerpip install line_profiler。给目标函数加上 @profile 装饰器(这个名字由工具运行时注入,无需 import),用 kernprof -l -v script.py 运行,输出会列出每一行的 Hits、Time、Per Hit、% Time。继续上面的例子:line_profiler 直接把 100×100 滚动均值的 95% 时间钉死在内层 sum(window) / len(window) 那一行——这就是你下一步要替换为 df.rolling(20).mean() 的精确坐标。把向量化重写留给 L4 与 3.2.2;本课的产出物是行号。

内存维度:getsizeof 与 tracemalloc

时间是一维,内存是另一维。sys.getsizeof(obj) 给一个对象的​​浅层​​字节占用,但​​容器不计元素​​:一个百万整数的 list 报 8 MB(指针表),真实占用约 28 MB(每个 int 对象 ~28 字节)——getsizeof 看单个对象的形状,不看总账。要回答「这段代码总共多分配了多少字节、分配在哪一行」,用 tracemalloc 拍两张快照(snapshot)做差:

import tracemalloc
tracemalloc.start()
snap1 = tracemalloc.take_snapshot()
data = [list(range(100)) for _ in range(10_000)]
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, 'lineno')[:5]:
    print(stat)

compare_to(..., 'lineno') 按代码行聚合分配差异,前五条按字节降序——开头那 100×100 滚动均值版本上跑一遍,tracemalloc 会显示中间 list 切片的临时分配占大头,这是把 list 换成 numpy.ndarray 的​​物证​​,而不是直觉。

优化闭环:测量 → 假设 → 改一处 → 再测量 → 决策

把三层工具拼成纪律,就是这条五步闭环:(1)​​测量​​:选好工具(perf_counter / cProfile / line_profiler / tracemalloc 中的​​一个​​)量出基线读数;(2)​​假设​​:基于读数写下一个可证伪的猜想,例如「90% 时间花在 inner_mean() 第 17 行」;(3)​​改一处​​:只改这一处(​​只​​一处),别顺手重命名、别顺手清理 import;(4)​​再测量​​:用同一把尺子复测;(5)​​决策​​:留下还是回滚。

一次改两处是新手最常见的自伤——你不会知道是哪一处把数字搬动了,更糟的是两处可能相互抵消,让一个真正有效的修复被你回滚掉。Linux 上若你想看到 Python 之下的硬件计数(cache miss / branch misprediction),可用 perf stat python script.py,但那是 3.4.3「Memory & Performance」(C++ 轨道)的功课,本课止步于 Python 层。生产环境无法重启的进程上可挂 py-spy / austin / Scalene 这类采样剖析器(sampling profiler),容器化部署下的实践详见 3.6.6 Observability。配套阅读:《Fluent Python》第 2 版中译第 4 部分性能相关章节;CPython 官方 cProfiletracemalloc 中文文档;line_profiler GitHub README;国内学习者可在 npmmirror / pypi.tuna 镜像上 pip install line_profiler snakeviz 避免境外站点超时。

下一课衔接

到这里你能定位​​单线程​​代码里时间与内存的热点。接下来三课各走一条不同方向的「然后呢」:L2「GIL、多线程与多进程」把 CPU 密集型工作铺到多核——例如把 510300.SH 月度 call 的 Monte Carlo 定价器跨进程并行;L3「asyncio 与 I/O 密集型并发」处理「一千条 Tushare 行情请求该怎么并发发出去」;L4「numba / Cython / cffi」专门把今天你用 line_profiler 钉死的那一行数值热循环压到原生速度。三条路径共享同一条纪律——​​先测量,再动手​​——今天这一课就是它们共同的入场券。

练习

Exercise

给定函数 def slow_sum_of_squares(n): total = 0\n for i in range(n):\n total += i * i\n return total,请按顺序完成四步:(a)用 time.perf_counter()n = 1_000_000 处测其墙钟耗时,输出 to 3 decimal places(精确到 3 位小数)的秒数;(b)将函数体替换为 return sum(i * i for i in range(n)),重测;(c)将函数体替换为 import numpy as np; arr = np.arange(n); return int((arr * arr).sum()),再次重测;(d)说明哪一版最快,相对 (a) 快了多少倍。

提示
计时模板照搬本课正文:t0 = perf_counter(); slow_sum_of_squares(n); dt = perf_counter() - t0。三个版本各自单独测一遍,不要把它们的耗时叠到同一个 dt 变量上;干净 Python 进程或干净 cell 里测最稳。
提示
「快多少倍」= 基线耗时 / 当前耗时。例如 (a) 约 0.180 s、(c) 约 0.002 s,则 (c) 比 (a) 快约 90 倍。numpy 版通常比纯 Python 循环快 50–200 倍;差异的根因(向量化、内存连续、SIMD)留到 L4「numba / Cython / cffi」展开。