引言
这是第十章「阶段测验与查漏补缺」的第 6 篇。前一篇我们用 ruff/black/mypy 让代码「干净」,这一篇让代码「可信地快」——注意定语:不是「快」,而是「可信地快」。程序运行得快不快,是一个需要用数据回答的问题,而数据来自测量。本篇介绍 Python 标准库自带的两个测量工具:timeit(微基准计时,专测小段代码)与 cProfile(剖析器,回答「时间都花在哪个函数里」),并用第六章的词频统计函数走一遍完整流程:测量基线 → 剖析定位热点 → 优化一处 → 二次测量对比。全程使用前九章学过的语法(文件、正则、字典、Counter、列表推导式),零第三方依赖,所有数字均为本机 Python 3.12.10 实测。读完你将有四个能力:写出规范的计时实验、读懂剖析表、用数据判断优化值不值、以及克制「拍脑袋优化」的冲动。
概念与原理
计时为什么难:噪声与开销
单次运行时间不可信:操作系统调度、CPU 频率缩放、缓存冷热、后台进程都会造成抖动——同一段代码连续跑两次,差值可能超过 20%。所以计时实验的两条铁律是:多跑几次,以及取最小值(最小值最接近「没有被外部打断」的那次理想执行)。这就是 timeit 的设计骨架:它把被测语句编译成函数,连续执行 n 次再换成单次平均;repeat 再把这套动作重复 r 轮,每轮取一个样本。
另一个容易忽略的噪声源是计时器自身的精度。Python 3 的 timeit 默认使用 time.perf_counter(),它取操作系统提供的高分辨率单调时钟(Windows 上为 QueryPerformanceCounter),适合短时测量;-p 选项可切换为 time.process_time()(只计本进程 CPU 时间,不受休眠与调度等待影响,适用于 CPU 密集型长任务)。历史上 Python 2 的 timeit 曾提供 -t/-c 选项手动选择 time.time() 与 time.clock(),3.x 已移除,统一默认 perf_counter——所以别再按老教程给 3.x 加 -t,它会直接报「option -t not recognized」。
timeit 的 setup 与语句分离
timeit 把「准备数据」放进 setup 参数、把「被测动作」放进语句参数,两个作用域的边界是刻意的:setup 只在计时外执行一次,计时范围只含语句。把准备写进语句会污染测量——这不是「变慢」,而是量错了对象。后面我们会实测这个差异。
cProfile:确定性剖析
cProfile 回答「时间花在哪」,它的原理是确定性剖析(deterministic profiling):在每个 Python 函数调用/返回的边界插入记账点,累计每个函数的四列数据:调用次数(ncalls)、本函数自身耗时(tottime)、含子调用的总耗时(cumtime)。这带来一个重要的诚实提示:记账本身有开销,剖析器运行时的绝对秒数高于真实执行(我们后文会看到证据),但「哪些函数在忙、调了多少次」的结构是可信的。Python 级函数调用越多,被放大的越厉害。所以分工是:timeit 给「判决」(真实差距多少),cProfile 给「地图」(往哪里优化)。
操作与实现
第一步:timeit 的三种惯用法
先看 API 用法:timeit.timeit(stmt, setup, number) 返回总秒数;timeit.repeat(...) 返回每轮的 number 次总耗时;样本取最小值。把「构造 1000 个数的列表」分别放进 setup 与语句,观察计时范围的区别:
import timeit
# setup 与语句分离:只有语句被计时
t1 = timeit.timeit("sum(xs)", setup="xs = list(range(1000))", number=10000)
print(f"setup 分离版(10 千次): {t1:.6f} 秒")
# 把准备工作写进语句:准备也被计时
t2 = timeit.timeit("xs = list(range(1000)); sum(xs)", number=10000)
print(f"紧凑语句版(10 千次): {t2:.6f} 秒")
# 重复多次取样本,用最小值
samples = timeit.repeat("sum(xs)", setup="xs = list(range(1000))", number=1000, repeat=3)
print("repeat 样本:", [f"{s:.6f}" for s in samples])
print("最小样本:", f"{min(samples):.6f} 秒")
setup 分离版(10 千次): 0.066679 秒
紧凑语句版(10 千次): 0.161241 秒
repeat 样本: ['0.006825', '0.006828', '0.006742']
最小样本: 0.006742 秒
本机实测:同样的 sum(xs),把构造列表放进语句后总耗时从约 0.067 秒涨到约 0.161 秒(约 2.4 倍)——注意这不是 sum 变慢,而是计时范围变大了。具体小数每次运行都会变(同一台机器多次采样在 ±10% 内浮动),稳定的是「约 2.4 倍」这个结论本身。三份 repeat 样本 0.0067-0.0068 秒,最小值相邻,说明机器稳定;当样本彼此打架(差出一倍)时,就该加 repeat 轮数或加大 number。
第二步:timeit 命令行:-n、-r 与自动校准
不写脚本也能测——python -m timeit 直接吃一条语句。注意命令行里语句若以减号开头(比如 '-'.join(...)),要用 -- 分隔「选项」与「语句」:
python -m timeit -n 20000 -r 5 -- "'-'.join(map(str, range(100)))"
20000 loops, best of 5: 8.89 usec per loop
-n(number)指每条语句执行 20000 次,-r(repeat)指把整轮重复 5 次取 best。看 python -m timeit --help 会发现更多选项:-s 提供 setup(同样不计时)、-u msec 切换输出单位、-p 用 process_time:
python -m timeit -n 20000 -r 5 -u msec -- "sum(range(1000))"
20000 loops, best of 5: 0.0103 msec per loop
不指定 -n 时,timeit 会自动校准:从小到大尝试 1、2、5、10、20、50……直到总耗时至少 0.2 秒再定档,保证结果有统计意义:
python -m timeit "sum(range(1000))"
20000 loops, best of 5: 10.6 usec per loop
默认 -r 5(重复 5 轮),输出里的「best of 5」就是「5 轮 × 每轮 20000 次、取最快轮换算出的单次耗时」。注意 sum(range(1000)) 单次约 10.6 微秒而前面的连接字符串约 8.9 微秒——微秒级差异已经在现实世界毫无感知,命令行适合「快速对比两个写法」,别拿它做精度要求过高的验收。
第三步:cProfile 剖析真实函数
把第六章的词频统计整理成独立脚本(1.34 MB 文本、约 26 万词),先用剖析器定位热点,而不是凭感觉猜:
import re
def analyze(text: str):
words = re.findall(r"[a-z']+", text.lower())
counts = {}
for w in words:
counts[w] = counts.get(w, 0) + 1
return sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:10]
def build_text():
sentence = "the quick brown fox jumps over the lazy dog python code reads fast"
return (sentence + " ") * 20000
if __name__ == "__main__":
text = build_text()
top = analyze(text)
print("总字符数:", len(text))
print("前十:", top)
python -m cProfile -o prof_base.out wordcount.py
python -c "import pstats; pstats.Stats('prof_base.out').sort_stats('cumulative').print_stats(12)"
总字符数: 1340000
前十: [('the', 40000), ('quick', 20000), ('brown', 20000), ('fox', 20000), ('jumps', 20000), ('over', 20000), ('lazy', 20000), ('dog', 20000), ('python', 20000), ('code', 20000)]
Fri Sep 4 00:24:40 2026 prof_base.out
260194 function calls (260190 primitive calls) in 0.135 seconds
Ordered by: cumulative time
List reduced from 56 to 12 due to restriction <12>
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 0.135 0.135 {built-in method builtins.exec}
1 0.004 0.004 0.135 0.135 wordcount.py:1(<module>)
1 0.044 0.044 0.130 0.130 wordcount.py:4(analyze)
1 0.000 0.000 0.050 0.050 C:\Program Files\Python312\Lib\re\__init__.py:209(findall)
1 0.049 0.049 0.049 0.049 {method 'findall' of 're.Pattern' objects}
260000 0.035 0.035 0.035 0.000 {method 'get' of 'dict' objects}
1 0.001 0.001 0.001 0.001 {method 'lower' of 'str' objects}
1 0.001 0.001 0.001 0.001 wordcount.py:12(build_text)
1 0.000 0.000 0.000 0.000 C:\Program Files\Python312\Lib\re\__init__.py:280(_compile)
1 0.000 0.000 0.000 0.000 C:\Program Files\Python312\Lib\re\_compiler.py:745(compile)
1 0.000 0.000 0.000 0.000 C:\Program Files\Python312\Lib\re\_parser.py:969(parse)
1 0.000 0.000 0.000 0.000 C:\Program Files\Python312\Lib\re\_compiler.py:578(_code)
读懂这张表三个要点。一,第一行是剖析记录生成时刻,随运行时间变化,可忽略;「0.135 秒」是带记账开销的执行时间。二,cumtime 排序下真正的主线是 analyze(含子调用 0.130 秒)。三,两处结构性证据:re.findall 一次调用吃掉 0.049 秒(正则引擎是 C 实现,几乎动不了),而手写计数循环贡献了 260000 次 Python 级 dict.get 调用、合计 0.035 秒——这是唯一能「压缩」的部分。0.035 秒本身很小,但它的成本形态(每词两次字典操作)决定了它是优化的合理靶点。
第四步:按分析结果优化,再二次测量
优化动作选一个:把手写计数换成语义相同、C 语言实现的 Counter(第六章第 7 篇学过 most_common,这里直接用):
import re
from collections import Counter
def analyze(text: str):
words = re.findall(r"[a-z']+", text.lower())
return Counter(words).most_common(10)
def build_text():
sentence = "the quick brown fox jumps over the lazy dog python code reads fast"
return (sentence + " ") * 20000
if __name__ == "__main__":
text = build_text()
top = analyze(text)
print("总字符数:", len(text))
print("前十:", top)
python -m cProfile -o prof_opt.out wordcount_opt.py
python -c "import pstats; pstats.Stats('prof_opt.out').sort_stats('cumulative').print_stats(12)"
总字符数: 1340000
前十: [('the', 40000), ('quick', 20000), ('brown', 20000), ('fox', 20000), ('jumps', 20000), ('over', 20000), ('lazy', 20000), ('dog', 20000), ('python', 20000), ('code', 20000)]
Fri Sep 4 00:24:41 2026 prof_opt.out
759 function calls (737 primitive calls) in 0.075 seconds
Ordered by: cumulative time
List reduced from 168 to 12 due to restriction <12>
ncalls tottime percall cumtime percall filename:lineno(function)
2/1 0.000 0.000 0.075 0.075 {built-in method builtins.exec}
1 0.004 0.004 0.075 0.075 wordcount_opt.py:1(<module>)
1 0.000 0.000 0.070 0.070 wordcount_opt.py:5(analyze)
1 0.000 0.000 0.050 0.050 C:\Program Files\Python312\Lib\re\__init__.py:209(findall)
1 0.050 0.050 0.050 0.050 {method 'findall' of 're.Pattern' objects}
1 0.000 0.000 0.015 0.015 C:\Program Files\Python312\Lib\collections\__init__.py:595(__init__)
1 0.000 0.000 0.015 0.015 C:\Program Files\Python312\Lib\collections\__init__.py:669(update)
1 0.015 0.015 0.015 0.015 {built-in method _collections._count_elements}
1 0.000 0.000 0.003 0.003 C:\Program Files\Python312\Lib\collections\__init__.py:618(most_common)
2/1 0.000 0.000 0.003 0.003 <frozen importlib._bootstrap>:1349(_find_and_load)
2/1 0.000 0.000 0.002 0.002 <frozen importlib._bootstrap>:1304(_find_and_load_unlocked)
2/1 0.000 0.000 0.002 0.002 <frozen importlib._bootstrap>:911(_load_unlocked)
结构变化一目了然:函数调用总数从 260194 降到 759,260000 次 Python 级 dict.get 消失,换成一次 C 实现的 _count_elements(0.015 秒)。re.findall 依旧是最大单点(0.050 秒)——它的成本没变,因为没动它。
cProfile 的绝对数字受记账开销影响、单次运行有波动(本机多次跑基线在 0.13-0.16 秒间浮动),所以判决要用 timeit。用 repeat 各取三轮最小值,同时加测一个「预编译正则」方案——很多人会顺手做,让我们用数据决定它值不值:
import re
import timeit
from collections import Counter
TEXT = ("the quick brown fox jumps over the lazy dog python code reads fast " + " ") * 20000
def v0_manual(text=TEXT):
"""基线:re.findall + 手写计数器"""
words = re.findall(r"[a-z']+", text.lower())
counts = {}
for w in words:
counts[w] = counts.get(w, 0) + 1
return counts
def v1_counter(text=TEXT):
"""优化一:re.findall 不变,计数交给 Counter"""
words = re.findall(r"[a-z']+", text.lower())
return Counter(words)
WORD_RE = re.compile(r"[a-z']+")
def v2_counter_compiled(text=TEXT):
"""优化二:预编译正则 + Counter"""
words = WORD_RE.findall(text.lower())
return Counter(words)
for name, fn in [("基线 手写计数", v0_manual), ("优化 Counter", v1_counter), ("优化 预编译+Counter", v2_counter_compiled)]:
best = min(timeit.repeat(fn, number=10, repeat=3))
print(f"{name}: 单次约 {best / 10 * 1000:.2f} 毫秒")
r0 = v0_manual()["the"]
r1 = v1_counter()["the"]
r2 = v2_counter_compiled()["the"]
print("三种实现 the 计数一致:", r0 == r1 == r2, f"({r0})")
python opt_compare.py
基线 手写计数: 单次约 67.98 毫秒
优化 Counter: 单次约 61.92 毫秒
优化 预编译+Counter: 单次约 60.27 毫秒
三种实现 the 计数一致: True (40000)
第五步:读数据,做决策
本机实测(数值随机器浮动,量级稳定):换成 Counter 后单次约 68 毫秒 → 约 62 毫秒,约 1.1 倍的稳定收益;「预编译正则」这次采样省了约 1.7 毫秒(约 3%),但另一次采样中预编译版反而比 Counter 版略慢——方向与大小都不稳定,落在三次采样的噪声范围内,不构成有效证据,放弃。为什么预编译没收益?因为 re 模块内部自带编译缓存(默认最多缓存 512 个模式),我们从头到尾只用一个模式,re.findall 每次都在命中缓存,预编译只是把缓存命中提前了一次。这个结论本身就是优化的反面教材:先查「库是不是已经替你做过了」,再动手。最终方案:Counter 版本,代码从 5 行缩到 2 行,速度还更快的那个常被低估的收益——可读性。
性能优化的决策清单,六条:
- 先有正确的程序,再谈快。输出的正确性永远优先,别用「快」换「对」。
- 测量先行。优化前必须有基线数字,优化后必须重测同一实验;没有对比的「我感觉快了」不作数。
- 优化热点,不优化直觉。用 cProfile 找出前几行,热点之外的时间不值得动。
- 复杂度优先于微优化。把 O(n²) 改成 O(n log n) 是数量级收益,微优化是百分位收益;先看算法再看语法。
- 别过早优化。程序跑在用户能感知之前,可读性与结构正确性优先;「万一以后慢」不是优化理由。
- 警惕收益被库吞掉。预编译正则、内存视图这类「看起来该优化」的操作,先查实现是否已缓存,再用数据说话。
易错点与陷阱
- 把剖析表的绝对秒数当真相。cProfile 的记账开销会放大 Python 级函数调用(手写循环那次剖析就比真实慢),判决差距请回到 timeit;剖析表的价值在 ncalls 与占比结构。
- 一次计时当结论。单次运行受调度与缓存影响,交替执行、取三轮以上的最小值,比加大单轮次数更抗噪。
- 把 setup 写进语句。计时范围被污染,测出来的是「准备 + 动作」的混合体(本文明测约 2.4 倍差异),对比两个方案时必须让 setup 完全相同且不计时。
- 给 3.x 的 timeit 传 -t/-c。那是 Python 2 的选项,3.x 已移除,默认统一
time.perf_counter();需要进程 CPU 时间用-p/--process。 - 优化后不再验证正确性。计时脚本要用「三种实现计数一致」这类断言锁住输出,防止「快了几倍但数错了」。
小结
本篇完成性能测量的完整闭环:timeit 负责微基准(语句与 setup 分离、repeat 多轮取最小、命令行 -n/-r/-s/-u/-p 与自动校准),cProfile 负责定位热点(tottime/cumtime/ncalls 四列,注意记账开销放大 Python 级调用)。用第六章词频统计实测:手写计数循环是唯一 Python 级热点(26 万次 dict.get),换成 Counter 后总调用从 260194 降到 759、timeit 测得约 1.1 倍真实收益;预编译正则因 re 模块自带缓存而收益为零。记住六条决策清单的核心:测量先行、热点优先、复杂度优先、别过早优化。下一篇进入调试训练场——把「性能问题排查」换成「bug 定位」,方法论的骨架是同一个:先复现、再定位、后修复。
练习与思考题
- 用 timeit 实测两段成员测试各 10 万次:
500000 in nums(nums 为 100 万元素列表)对比500000 in s(s 是同一批数据的集合)。setup 里构造数据,重复 3 轮取最小,报告量级差异并解释(回顾第六章集合篇的复杂度结论)。 - 给
wordcount.py加上第六章第 7 篇的停用词过滤后重测:过滤这一步让总耗时增加还是减少?用 timeit 回答并解释为什么。 - 用 Python 的
-m cProfile剖析不带缓存的fib(30)(第四章的递归函数),观察ncalls列揭示的爆炸性重复调用,再解释为什么这类问题应该先改算法(记忆化)而不是优化单次调用。 - 思考题:timeit 为什么取最小值而不是平均值?什么场景下平均值(或中位数)反而更有意义?提示:考虑「被测代码偶尔因外部原因变慢」与「被测代码本身体现了不同路径」两种情形。