Python 基础体系 · 第 72/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 性能剖析:timeit、cProfile、tracemalloc、采样和证据链

性能问题不是“代码看起来慢”,而是一个需要被测量、解释和复现的问题。

一次请求耗时增加,可能来自:

  • Python 字节码执行时间增加;
  • 调用了更多函数;
  • 某个函数本身变慢;
  • 等待网络、磁盘、锁或线程调度;
  • 创建了大量临时对象;
  • 垃圾回收频率上升;
  • 内存增长导致缺页、换页或容器被 OOM;
  • C 扩展、数据库驱动或解释器外部的代码占用了时间。

因此,“性能剖析”不是单独运行一个工具,而是建立一条从现象到原因、从原因到修复、从修复到回归判断的证据链

现象
  ↓
定义测量对象与指标
  ↓
基准测试确认差异
  ↓
确定性剖析定位调用路径
  ↓
内存追踪定位 Python 分配来源
  ↓
采样观察真实运行态与等待位置
  ↓
修改实现
  ↓
用相同输入和环境复测
  ↓
判断改进是否真实、稳定、值得

下面的示例以 Python 3.14 为范围。标准库文档当前对应 Python 3.14.7;具体补丁版本、解释器构建方式、操作系统和硬件仍可能影响结果。


一、先区分四个问题:测多快、慢在哪里、分配了什么、运行时在等什么

1. 执行时间不是单一概念

一个操作的墙钟时间可以粗略表示为:

Twall=Tpython+Tnative+Tio+Twait+TscheduleT_{\text{wall}} = T_{\text{python}} + T_{\text{native}} + T_{\text{io}} + T_{\text{wait}} + T_{\text{schedule}}

其中:

  • TpythonT_{\text{python}}:Python 代码自身执行时间;
  • TnativeT_{\text{native}}:C、C++、Rust 或其他原生扩展执行时间;
  • TioT_{\text{io}}:磁盘、网络、数据库等 I/O 时间;
  • TwaitT_{\text{wait}}:等待锁、条件变量、队列或其他同步对象的时间;
  • TscheduleT_{\text{schedule}}:操作系统调度、抢占和进程竞争带来的时间。

timeit 主要回答“一个小片段在当前条件下执行多快”;cProfile 主要回答“程序调用了哪些函数、每个函数占用了多少时间”;tracemalloc 主要回答“Python 内存块从哪里分配”;采样剖析则回答“程序运行的大部分时间,调用栈通常停在哪里”。

它们观察的是不同投影,不能互相替代。

例如,一个数据库查询很慢:

  • timeit 可以测一条查询封装函数的端到端时间;
  • cProfile 可能显示时间集中在驱动函数;
  • tracemalloc 可能只显示结果对象的分配;
  • 采样工具可能显示线程长期停在 socket 读取或驱动的原生栈中。

如果把“某行分配了很多内存”直接解释成“某行耗时最多”,或者把“某函数累计耗时最高”直接解释成“该函数本身最慢”,都是证据越界。


二、timeit:为小片段建立可比较的时间测量

2.1 timeit 测量的对象

timeit 是一个小型基准测试工具,用于测量短代码片段的执行时间。它既提供 Python API,也提供命令行接口。默认计时器是 time.perf_counter(),也就是墙钟高分辨率计时器。命令行的 -p 选项可以改用 time.process_time() 测量进程 CPU 时间。(docs.python.org)

最简单的例子是:

python -m timeit "'-'.join(map(str, range(100)))"

输出形如:

10000 loops, best of 5: 23.2 usec per loop

这里不能把 23.2 usec 理解为程序只执行了一次。命令行会先选择一个循环次数 N,让一次重复运行的总时间达到足够长的量级,然后重复若干次,报告最快重复中的平均单次耗时。未指定 -n 时,命令行会尝试 1、2、5、10、20、50 ...,直到总耗时至少达到约 0.2 秒。(docs.python.org)

设第 jj 次重复运行的总时间为 RjR_j,循环次数为 NN,命令行报告的近似值是:

tloop=min(R1,R2,,Rk)Nt_{\text{loop}} = \frac{\min(R_1, R_2, \dots, R_k)}{N}

这不是一般意义上的均值,而是最快重复的平均每次耗时。

2.2 为什么默认关闭垃圾回收

timeit 在测量期间默认临时关闭自动垃圾回收。这样做的目的是让多个候选实现更容易比较,避免某一次恰好触发 GC 而产生额外延迟;代价是,如果被测函数的真实性能依赖垃圾回收,那么关闭 GC 就改变了问题本身。官方文档明确建议:如果 GC 是被测行为的一部分,应在 setup 中重新启用它。(docs.python.org)

import gc
import timeit

def create_cycles():
    a = []
    a.append(a)
    return a

without_gc = timeit.Timer(create_cycles).repeat(
    repeat=5,
    number=10_000,
)

with_gc = timeit.Timer(
    create_cycles,
    setup="gc.enable()",
).repeat(
    repeat=5,
    number=10_000,
)

print("GC disabled:", without_gc)
print("GC enabled :", with_gc)

这个实验有两个重要前提:

  1. create_cycles() 返回的列表如果没有外部引用,会形成不可由引用计数立即回收的循环;
  2. 当 GC 被禁用时,循环对象可能积累,后续测量的内存状态也会变化。

因此,两个数组不仅反映“函数执行速度”,还反映了“测量期间是否允许周期性垃圾回收”。不能在一个 GC 关闭的结果上推断生产环境中的端到端延迟。

Python 使用引用计数处理大量对象的即时释放,同时由循环 GC 补充处理引用循环。gc.collect() 不带参数时执行一次完整回收;在已经进行 GC 时再次调用 gc.collect() 的行为是未定义的,因此不要在回调或复杂诊断钩子中无条件嵌套调用。(docs.python.org)

2.3 setup、stmt 和输入状态

setup 只执行一次,其耗时不计入被测部分;stmt 会执行多次。这个边界决定了测试是否测到了正确的对象。

错误示例:

import timeit

bad = timeit.timeit(
    "data.sort()",
    setup="data = [3, 1, 2]",
    number=100_000,
)

第一次执行后,data 已经有序,后续执行测量的是“对已经排序的数据再次排序”,而不是“排序一个无序列表”。

正确做法是在每次迭代中生成独立输入,或者复制基准数据:

import timeit

setup = """
data = [3, 1, 2]
sort = list.sort
"""

result = timeit.repeat(
    "candidate = data[:]; sort(candidate)",
    setup=setup,
    number=100_000,
    repeat=5,
)

print(result)
print("best seconds:", min(result))

这里的 data[:] 属于测量范围。如果目标是比较“复制加排序”的端到端成本,这样是正确的;如果目标只想比较排序本身,则需要设计另一个实验,并保证每次传入的列表都处于相同的无序状态。测试边界必须和问题边界一致。

2.4 callable 比字符串更适合复杂代码

timeit 接受无参数 callable:

import timeit

def squares_loop():
    result = []
    for value in range(1_000):
        result.append(value * value)
    return result

def squares_comp():
    return [value * value for value in range(1_000)]

for function in (squares_loop, squares_comp):
    samples = timeit.repeat(
        function,
        repeat=7,
        number=1_000,
    )
    print(function.__name__, min(samples) / 1_000)

callable 形式避免了字符串拼接和 globals 管理,但会引入额外的函数调用开销。官方文档明确指出,使用 callable 时计时开销会略高。(docs.python.org)

当单次操作极短时,应该增加 number,使总测量时间远大于计时器和循环本身的开销。可以用 Timer.autorange() 自动寻找循环次数:

import timeit

timer = timeit.Timer("1 + 1")
number, total = timer.autorange()

print("number =", number)
print("total  =", total)
print("per loop =", total / number)

autorange() 会不断尝试不同循环次数,直到一次测量至少达到约 0.2 秒。(docs.python.org)

2.5 repeat() 的结果如何解释

import timeit

samples = timeit.repeat(
    "sum(range(1_000))",
    repeat=9,
    number=10_000,
)

print("all samples:", samples)
print("best:", min(samples))
print("worst:", max(samples))

repeat() 返回的是多个总耗时样本。官方文档不建议把这些样本简单地汇总成均值和标准差,然后把它当作唯一结论;在典型本机微基准中,较大的值通常来自其他进程、调度或系统噪声,最小值更接近机器在低干扰状态下执行该片段的下界。文档同时要求查看完整样本并结合常识判断。(docs.python.org)

这条建议有明确边界:

  • 对“单机微基准中候选 A 是否比 B 快”,最小值常常有用;
  • 对“线上请求 P99 是否改善”,不能只看最小值;
  • 对“包含 I/O、锁竞争、GC 抖动”的行为,尾部延迟本身就是问题,不能被最小值隐藏;
  • 对统计回归判断,应使用固定输入、重复运行、分布和效应大小,而不是只比较某两个偶然数字。

可以把每个候选实现的结果写成:

R={r1,r2,,rk}R = \{r_1, r_2, \ldots, r_k\}

至少保留:

  • min(R):低干扰下界;
  • median(R):典型重复结果;
  • max(R) 或高分位数:噪声和抖动线索;
  • 每次运行的解释器版本、平台、输入规模和提交版本。

timeit 适合回答“候选实现之间是否存在稳定的局部差异”,不适合单独回答“完整服务为什么变慢”。


三、cProfile:把执行时间映射到调用图

3.1 确定性剖析是什么

cProfileprofile 都提供确定性剖析。它们在函数调用、返回等事件发生时记录调用统计,因此可以知道某个函数被调用多少次、在自身内部耗时多少、连同子函数累计耗时多少。cProfile 是 C 扩展,通常开销较低,适合大多数用户;profile 是纯 Python 实现,开销明显更高,主要用于需要扩展剖析器行为的场景。(docs.python.org)

确定性剖析的基本数据可以抽象为调用边:

ABA \rightarrow B

以及函数节点的两个时间:

  • Tself(A)T_{\text{self}}(A):函数 A 自身执行时间,不包括调用的子函数;
  • Tcum(A)T_{\text{cum}}(A):函数 A 从进入到返回的累计时间,包括子函数。

例如:

def parse(values):
    normalized = [value.strip().lower() for value in values]
    return sorted(normalized)

def workload():
    values = [" Python ", "Cpython", " profiling "] * 20_000
    return parse(values)

if __name__ == "__main__":
    workload()

运行:

python -m cProfile -o profile.dat demo.py
python -c "
import pstats
from pstats import SortKey

stats = pstats.Stats('profile.dat')
stats.strip_dirs().sort_stats(SortKey.CUMULATIVE).print_stats(15)
"

-o 把统计结果写入文件,pstats.Stats 再读取并排序。官方文档也提供了 SortKey.CUMULATIVESortKey.TIME 两种典型视角:前者适合寻找调用链上的总耗时,后者适合寻找函数自身内部耗时较多的代码。(docs.python.org)

常见输出列的含义是:

含义
ncalls 调用次数
tottime 函数自身耗时,不含子调用
percall tottime / ncalls
cumtime 函数及其所有子调用的累计耗时
第二个 percall cumtime / primitive calls
filename:lineno(function) 函数来源位置

如果函数递归,ncalls 可能显示成 总调用数/原始调用数,例如 3/1 表示总共进入三次,但只有一次是由外部调用直接触发的。(docs.python.org)

3.2 tottimecumtime 的推导

假设调用关系如下:

request()
└── parse()
    ├── normalize()
    └── sort()

假设一次运行得到:

normalize: tottime=0.20 ms, cumtime=0.20 ms
sort     : tottime=0.50 ms, cumtime=0.50 ms
parse    : tottime=0.10 ms, cumtime=1.00 ms
request  : tottime=0.05 ms, cumtime=1.05 ms

则:

Tcum(parse)=Tself(parse)+Tcum(normalize)+Tcum(sort)T_{\text{cum}}(\text{parse}) = T_{\text{self}}(\text{parse}) + T_{\text{cum}}(\text{normalize}) + T_{\text{cum}}(\text{sort})

代入就是:

1.00=0.10+0.20+0.50+0.20其他调用1.00 = 0.10 + 0.20 + 0.50 + 0.20_{\text{其他调用}}

因此,parsecumtime 很高,并不说明 parse 函数体自身很慢;它可能只是调用了真正耗时的下游函数。

反过来,如果一个函数 tottime 很高而 cumtime 与之接近,说明时间主要消耗在函数自身。优化方向可能是减少循环、改变数据结构、避免重复转换,而不是继续分析它的子函数。

3.3 为什么 cProfile 不能代替基准测试

官方文档特别提醒:剖析器用于生成执行剖析,不是用于基准测试。剖析会给 Python 函数增加事件记录开销,但对 C 级函数的影响不同,因此直接比较 Python 实现和 C 实现时,C 代码可能因为受到的剖析干扰较少而显得异常快。(docs.python.org)

错误流程是:

运行 cProfile
→ 看到函数 A 占 60%
→ 修改 A
→ 再运行 cProfile
→ 认为占比下降就是性能提升

正确流程是:

cProfile:定位可能的热点
→ 修改 A
→ 不启用剖析器,用 timeit 或端到端测试测量
→ 在相同 workload 下确认总时间是否下降

剖析结果中的百分比是“在剖析器观测模型下的时间分布”,不是脱离工具后的绝对真实性能。

3.4 一个完整的函数级剖析示例

from __future__ import annotations

import cProfile
import io
import pstats
from pstats import SortKey


def transform(rows: list[str]) -> list[str]:
    return sorted(
        row.strip().casefold()
        for row in rows
        if row.strip()
    )


def workload() -> list[str]:
    rows = [" Python ", " CPython ", " profiling "] * 100_000
    return transform(rows)


def main() -> None:
    profiler = cProfile.Profile()

    try:
        profiler.enable()
        result = workload()
        assert len(result) == 300_000
    finally:
        profiler.disable()

    stream = io.StringIO()
    stats = (
        pstats.Stats(profiler, stream=stream)
        .strip_dirs()
        .sort_stats(SortKey.CUMULATIVE)
    )
    stats.print_stats(15)
    print(stream.getvalue())


if __name__ == "__main__":
    main()

这里使用 try/finally 保证即使 workload 抛出异常,剖析器也会被关闭。cProfile.Profile 支持显式 enable()disable(),也支持上下文管理器;显式写法适合只包围目标阶段,避免把导入、配置和日志初始化混入结果。(docs.python.org)


四、tracemalloc:追踪 Python 内存块,而不是读取进程 RSS

4.1 tracemalloc 记录什么

tracemalloc 是 Python 内存分配追踪工具。它可以记录:

  • Python 内存块的分配回溯;
  • 按文件、行号统计分配总大小、数量和平均大小;
  • 两个快照之间的差异;
  • 当前追踪内存和峰值追踪内存。

它应尽可能早地启动:可以通过 PYTHONTRACEMALLOC 环境变量、-X tracemalloc 命令行选项,或运行时调用 tracemalloc.start()。默认每个分配只保存最近一帧,也可以保存更多回溯帧。(docs.python.org)

最小示例:

import tracemalloc

tracemalloc.start(10)

before = tracemalloc.take_snapshot()

items = [
    {"id": index, "name": f"user-{index}"}
    for index in range(100_000)
]

after = tracemalloc.take_snapshot()

for stat in after.compare_to(before, "lineno")[:10]:
    print(stat)

compare_to() 输出的是后一个快照相对于前一个快照的变化。例如:

example.py:8: size=14.2 MiB (+14.2 MiB), count=299742 (+299742), average=49 B

它表示该位置当前比快照前多出约 14.2 MiB 的被追踪内存块,而不是说这一行在整个进程生命周期中只分配了 14.2 MiB。

4.2 当前值和峰值是两个不同状态

下面的示例制造一个大的临时列表:

import tracemalloc

tracemalloc.start()

large_sum = sum(list(range(100_000)))
current_1, peak_1 = tracemalloc.get_traced_memory()

tracemalloc.reset_peak()

small_sum = sum(list(range(1_000)))
current_2, peak_2 = tracemalloc.get_traced_memory()

print(f"{current_1=}, {peak_1=}")
print(f"{current_2=}, {peak_2=}")

get_traced_memory() 返回:

(current, peak)

其中:

  • current:当前仍被追踪的内存块大小;
  • peak:从开始追踪或上次 reset_peak() 后观察到的峰值。

大列表在 sum() 完成后可能已经释放,所以最终 current 很小,但执行期间的 peak 很大。reset_peak() 只重置峰值记录,不清除已有追踪,因此可以分别观察两个阶段的峰值。(docs.python.org)

这对应两个不同的性能问题:

Mlive=某时刻仍然存活的内存M_{\text{live}} = \text{某时刻仍然存活的内存}

Mpeak=maxtMlive(t)M_{\text{peak}} = \max_t M_{\text{live}}(t)

  • M_live 持续增加,常见于引用仍然存在、缓存无界增长或对象泄漏;
  • M_peak 很高但最终下降,常见于临时列表、重复拷贝或批处理粒度过大;
  • 两者都不高但 RSS 很高,可能是原生扩展、内存分配器保留、内存映射或其他进程级资源。

4.3 快照比较比单次 top 列表更接近泄漏诊断

单次快照只能说明“当前哪些位置持有较多被追踪分配”。要诊断疑似泄漏,应把测试分成阶段:

import gc
import tracemalloc


def handle_batch() -> None:
    cache = []
    for index in range(20_000):
        cache.append({"index": index, "payload": "x" * 100})


tracemalloc.start(10)
gc.collect()

baseline = tracemalloc.take_snapshot()

for _ in range(5):
    handle_batch()
    gc.collect()

current = tracemalloc.take_snapshot()

for stat in current.compare_to(baseline, "traceback")[:10]:
    print(stat)

这里的 gc.collect() 是为了在两个阶段之间尽量减少尚未回收的循环对象干扰,但它不能证明不存在泄漏。它只能改变 GC 状态,使比较更容易解释。

真正的泄漏证据需要满足更强条件:

  1. 每轮 workload 相同;
  2. 阶段结束后应该释放的对象确实没有业务引用;
  3. 多轮运行后,某些分配位置持续正增长;
  4. 增长与输入规模或轮数具有稳定关系;
  5. 修复后增长趋势消失;
  6. 端到端 RSS 或容器内存也符合预期。

4.4 tracemalloc 的边界

tracemalloc 追踪的是 Python 分配器管理的内存块,不是所有进程内存。

因此下面的推断是不成立的:

tracemalloc 只增加 2 MiB
→ 进程一定只增加 2 MiB

进程 RSS 还可能受到以下因素影响:

  • NumPy、PyTorch、数据库驱动等原生扩展的分配;
  • C 库自己的 allocator;
  • 内存映射文件;
  • 线程栈;
  • Python 内存分配器保留但暂时未归还操作系统的页面;
  • 动态加载库和共享库;
  • 子进程。

tracemalloc 的准确表述应是:“Python 代码分配来源和被追踪内存变化”。若问题是容器 RSS、系统换页或 OOM,必须把 tracemalloc 与操作系统层面的 RSS、容器指标或原生内存工具结合起来。


五、采样剖析:不记录每次事件,而是周期性观察调用栈

5.1 采样的基本模型

采样剖析器每隔一段时间观察线程当前的调用栈,而不是记录每次函数调用和返回。

假设总运行时间为 TT,每隔 Δt\Delta t 采样一次,样本数约为:

nTΔtn \approx \frac{T}{\Delta t}

如果某段代码实际占用 CPU 时间 tit_i,它被采到的概率近似为:

pi=tiTp_i = \frac{t_i}{T}

于是样本数 XiX_i 可以近似看作:

XiBinomial(n,pi)X_i \sim \text{Binomial}(n, p_i)

样本比例:

p^i=Xin\hat p_i = \frac{X_i}{n}

p^i\hat p_i 估计时间占比。样本越多,估计越稳定;某函数只运行几微秒,即使很重要,也可能在低频采样中完全没有样本。

采样的优点是开销通常较低、适合长时间运行的真实进程,并且不需要在每个函数调用处插入事件记录。缺点是它是统计估计,不保证捕获短暂事件,也不能天然告诉你每次函数调用的精确次数。

5.2 使用 py-spy 观察运行中的进程

py-spy 是一个独立于目标进程的 Python 采样剖析器,提供 toprecorddump 子命令,并支持 Python 3.14。它可以对正在运行的进程采样,也可以直接启动目标程序。(github.com)

启动一个持续运行的程序:

py-spy top -- python app.py

对已有进程采样:

sudo py-spy top --pid 12345

生成火焰图:

py-spy record -o profile.svg -- python app.py

这里的火焰图不是“时间轴上的完整调用日志”。横向宽度通常代表样本数量或估计时间占比,纵向表示调用栈深度。一个宽而高的栈表示程序经常处于该调用路径,但不能据此推断每次调用都耗时相同。

采样已有进程还涉及权限边界。py-spy 通过读取目标进程内存获取 Python 线程和栈信息,在 Linux、macOS、Windows 等系统上可能受到 ptrace、容器能力或安全策略限制;容器中常见的错误是 Permission denied,此时需要检查进程可读权限和 SYS_PTRACE 等运行时配置。(github.com)

5.3 采样结果中的“宽”不等于“函数本身慢”

假设采样结果显示:

90%  socket.recv
85%  database_driver.execute
80%  application.handle_request

这表示采样时线程经常停在这些调用路径附近,但可能有多种原因:

  • 真正在执行 CPU 密集型原生代码;
  • 等待数据库返回;
  • 阻塞在 socket;
  • 线程持有 GIL 或正在释放 GIL 的扩展中运行;
  • 采样工具把等待状态和运行状态按特定规则聚合。

因此,采样必须结合:

  • 请求耗时和超时指标;
  • CPU 使用率;
  • 数据库服务端耗时;
  • 网络连接状态;
  • 线程状态;
  • cProfile 对同一 workload 的函数级结果。

采样的价值是告诉你“真实运行态的主要栈是什么”,而不是替代所有其他测量。

5.4 Python 3.14 的 sys.monitoring 与采样不是一回事

Python 3.14 提供了 sys.monitoring 命名空间,用于接收执行事件回调,例如函数开始、返回、调用、行事件和 C 函数返回等。它是事件监控接口,不是采样剖析器;调用方需要注册工具 ID、事件和回调。(docs.python.org)

两者的区别是:

sys.monitoring:
每次发生指定事件 → 调用回调

采样:
每隔一段时间 → 读取当前状态

如果需要覆盖率、调试器、细粒度执行事件,事件监控更合适;如果需要低干扰地观察生产进程长时间处于哪些栈,采样通常更合适。启用大量 LINEINSTRUCTION 事件会改变运行开销,因此不能把启用监控后的时间直接当作无监控性能。


六、建立证据链:从一个 CPU 与内存混合问题开始

下面构造一个同时包含 CPU 工作和临时内存峰值的 workload:

from __future__ import annotations


def transform(rows: list[str]) -> list[str]:
    return sorted(
        row.strip().casefold()
        for row in rows
        if row.strip()
    )


def workload(size: int = 100_000) -> list[str]:
    rows = [f" User-{index % 10_000} " for index in range(size)]
    return transform(rows)


if __name__ == "__main__":
    result = workload()
    print(len(result))

第一步:先用端到端计时确认问题存在

python -m timeit -s "from demo import workload" "workload()"

如果结果从旧版本的 80 ms 变成新版本的 110 ms,说明差异存在,但还不知道原因。

此时必须记录:

  • Python 版本;
  • 操作系统和 CPU;
  • workload 参数;
  • 输入是否固定;
  • 是否在虚拟环境中;
  • 依赖版本;
  • 是否启用了调试器、覆盖率或 profiler;
  • 是墙钟时间还是进程 CPU 时间。

第二步:用 cProfile 判断时间集中在哪里

python -m cProfile -o profile.dat demo.py

查看累计时间:

python -c "
import pstats
from pstats import SortKey
pstats.Stats('profile.dat').strip_dirs().sort_stats(
    SortKey.CUMULATIVE
).print_stats(20)
"

可能得到这样的结论:

transform() 占据大部分 cumulative time
sorted() 占据大部分 tottime

这说明优化方向可能是排序、字符串规范化或输入数据结构,而不是盲目修改 workload() 的外层代码。

如果 cProfile 显示大量时间在驱动函数、文件读取或数据库调用上,则局部 Python 循环优化可能无法改善总耗时。

第三步:用 tracemalloc 区分存活内存和峰值内存

import tracemalloc

tracemalloc.start(10)

current_before, peak_before = tracemalloc.get_traced_memory()

result = workload()

current_after, peak_after = tracemalloc.get_traced_memory()

print("before:", current_before, peak_before)
print("after :", current_after, peak_after)

snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics("lineno")[:10]:
    print(stat)

如果 peak_after 很高,但 current_after 在释放 result 后明显下降,问题更像是临时峰值;如果每轮处理后 current 持续增加,则需要继续查找引用链、缓存或全局容器。

第四步:对长时间服务进行采样

py-spy record -o profile.svg --pid 12345

如果火焰图显示线程大部分时间在:

handle_request
└── database.execute
    └── socket.recv

那么当前证据更支持“等待外部服务”而不是“Python 排序算法变慢”。

如果显示:

handle_request
└── transform
    └── sorted

并且 CPU 利用率接近一个核心饱和,则可以把 cProfile 的函数热点与采样结果相互印证。

第五步:修改后必须重新验证同一个问题

修复不能只看某个函数的局部数字。至少需要重新运行:

  1. 同一份端到端 workload;
  2. 同一组 timeit 微基准;
  3. 同一套 cProfile 路径;
  4. 同一套 tracemalloc 阶段比较;
  5. 对服务问题,重新采样真实流量或等价压力。

证据链闭合的形式应当类似:

端到端耗时下降
+ cProfile 中热点路径耗时下降
+ timeit 中局部候选实现稳定变快
+ tracemalloc 中峰值或持续增长改善
+ 采样结果中的主栈比例发生符合预期的变化

如果只满足其中一项,结论仍然可能不完整。例如:

  • timeit 变快,但端到端时间不变:优化的代码不是瓶颈;
  • cProfile 占比下降,但总时间不变:可能只是其他瓶颈占比上升;
  • tracemalloc 当前值下降,但 RSS 不降:问题可能在原生分配器或内存保留;
  • 采样火焰图变窄,但请求 P99 变差:平均 CPU 路径改善,尾部等待可能恶化。

七、常见误解与失败表现

误解一:cProfile 排名第一的函数就是应该修改的函数

排名第一可能是:

  • 入口函数累计包含了所有下游时间;
  • 一个被大量调用的廉价函数;
  • 一个等待外部资源的包装函数;
  • profiler 注入开销后的结果。

先看 tottimecumtime 的关系,再看调用者和被调用者。累计时间适合找调用树上的路径,自身时间适合找函数体内部热点。

误解二:tracemalloc 的总量就是进程内存

它只覆盖被追踪的 Python 内存块,不等于 RSS,也不等于所有原生扩展分配。需要把“Python 分配来源”和“进程实际驻留内存”分别记录。

误解三:峰值内存高就是内存泄漏

泄漏的关键不是“某一时刻高”,而是“本应释放的对象在重复 workload 后仍然持续增长”。临时列表可能造成高峰值,却不是泄漏;无界缓存可能每轮只增加一点,却最终耗尽内存。

误解四:微基准快 10% 就代表服务快 10%

如果该片段只占完整请求的 2%,即使它缩短 10%,端到端理论上也最多缩短:

0.02×10%=0.2%0.02 \times 10\% = 0.2\%

这就是局部优化的边界。应先确认局部代码在总耗时中的实际占比,再决定是否值得优化。

误解五:平均值能代表线上性能

平均值适合描述总体资源消耗,但用户体验往往由高分位延迟决定。包含锁、I/O、GC、网络和调度的系统,应同时观察中位数、P95、P99 和最大值。timeit 的最小值适合观察低干扰微基准下界,但不能代替线上延迟分布。

误解六:每次测试都手动调用 gc.collect() 就更准确

手动 GC 可以帮助构造可解释的实验阶段,但也会引入一次完整回收和空闲列表处理成本。它适合诊断,不代表生产运行策略。Python 3.14 的 GC 代际相关行为还有补丁版本差异,依赖具体 generation 编号进行跨版本推理时尤其要谨慎;需要完整回收时,优先明确使用 gc.collect() 的无参数语义。(docs.python.org)


八、工具选择的实际边界

可以用下面的因果关系选择工具:

问题 首选工具 原因
两段短代码谁更快 timeit 控制 setup、重复次数和计时器
完整脚本的函数调用热点 cProfile 给出调用次数、self time、cumulative time
哪几行 Python 代码分配了内存 tracemalloc 提供分配位置和回溯
线上进程当前卡在哪里 采样剖析器 低侵入地观察真实调用栈
是否等待数据库、网络或锁 采样 + 端到端指标 需要区分执行与等待
RSS 持续增长但 tracemalloc 不明显 操作系统/原生内存工具 问题可能不在 Python 分配器
优化后是否真的回归 固定 workload + 重复基准 需要可复现的比较证据

timeit 是测量工具,不是解释工具;cProfile 是调用路径解释工具,不是低扰动基准工具;tracemalloc 是 Python 分配诊断工具,不是完整进程内存工具;采样是运行态统计观察工具,不是精确调用日志。

真正可靠的性能结论必须回答四个问题:

  1. 测量对象是什么?
  2. 观测到的数字由哪个机制产生?
  3. 该机制是否覆盖了实际故障路径?
  4. 修改后是否在同一条件下得到相反且稳定的证据?

当这四个问题都能回答时,性能剖析才从“看报告”变成了工程诊断。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。