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

Python 基准测试:预热、噪声、统计、pyperf 和回归判断

性能优化最容易犯的错误,不是不会写更快的代码,而是把一次偶然的计时结果当成了事实。

例如:

from time import perf_counter

start = perf_counter()
result = function(data)
elapsed = perf_counter() - start

print(elapsed)

这段代码可以回答“这一次调用大约花了多久”,却不能直接回答:

  • 修改前后是否真的有性能差异;
  • 差异是否超过测量误差;
  • 差异是否能在另一台机器、另一个进程中复现;
  • 优化是否只改善了微基准,却损害了真实工作负载;
  • 观测到的变化来自 Python 代码,还是来自 CPU 频率、调度、缓存、内存分配或垃圾回收。

基准测试(benchmark)是一种受控的重复实验:固定输入、环境和执行路径,重复测量目标操作,再用统计方法估计其典型表现和不确定性。它不是“给函数加一个计时器”,而是一条从问题定义、实验设计、数据采集到回归判断的证据链。

本文以 CPython 3.14 为范围,重点讨论:

  1. 如何定义可比较的基准;
  2. 预热为什么必要,以及预热并不等于“让 CPU 热起来”;
  3. 噪声从哪里来,如何识别和降低;
  4. 均值、标准差、中位数、分位数和置信区间各自能说明什么;
  5. timeitpyperf 的边界;
  6. 如何保存基准结果并判断性能回归;
  7. 如何把基准测试与 pytestunittest、性能剖析和原生扩展开发连接起来。

一、先区分测试、剖析和基准测试

三者都与性能有关,但问题不同。

1. 功能测试:结果是否正确

功能测试验证:

f(x)=yf(x) = y

也就是输入 x 是否产生预期结果 y,以及错误输入是否抛出预期异常。

例如:

def normalize_name(value: str) -> str:
    return " ".join(value.split()).casefold()

对应的 pytest 测试可以写成:

import pytest

from app import normalize_name


@pytest.mark.parametrize(
    ("value", "expected"),
    [
        (" Alice  Smith ", "alice smith"),
        ("BOB", "bob"),
        ("", ""),
    ],
)
def test_normalize_name(value, expected):
    assert normalize_name(value) == expected

pytestassert、参数化和 fixture 适合组织功能测试与测试数据;unittest 则提供标准库内置的测试用例、断言、fixture 生命周期和测试运行器。两者的核心职责都是验证行为,而不是为微秒级差异建立统计模型。pytest 也可以直接运行基于 unittest 的测试套件,因此功能测试框架和基准测试工具可以共存。(docs.pytest.org)

2. 性能剖析:时间花在哪里

剖析回答:

程序把时间和内存消耗在了哪些函数、调用路径或分配点上?

常见工具包括:

  • cProfile:函数调用级别的确定性剖析;
  • 采样剖析器:周期性采样当前调用栈;
  • tracemalloc:跟踪 Python 内存分配;
  • Linux perf:观察更低层的 CPU、缓存和系统事件;
  • py-spy 等外部采样工具。

剖析器通常会改变被测程序的执行成本,因此剖析结果不能直接当作基准结果。pyperf 也支持通过 --profile 在基准执行期间运行 cProfile,但文档明确指出此时实际计时会变得不准确;这种模式适合定位热点,不适合得出性能结论。(pyperf.readthedocs.io)

3. 基准测试:修改前后是否有可重复的差异

基准测试回答:

H0:修改前后的性能相同H_0: \text{修改前后的性能相同}

还是:

H1:修改前后的性能不同H_1: \text{修改前后的性能不同}

它首先需要定义“性能”:

  • 单次调用延迟;
  • 每秒处理请求数;
  • 每个对象的构造时间;
  • 固定数据量下的总耗时;
  • 峰值内存;
  • CPU 时间;
  • 端到端吞吐量。

如果没有明确的测量对象,后续的数字即使精确,也可能没有意义。


二、什么是一个有效的基准

一个基准至少应明确以下变量:

B=(I,O,E,P,M)B = (I, O, E, P, M)

其中:

  • II:输入数据(Input);
  • OO:输出和正确性约束(Output);
  • EE:执行环境(Environment);
  • PP:执行协议(Protocol);
  • MM:测量指标(Metric)。

例如,要比较两种字符串规范化实现,不能只写“比较两个函数”,而应定义为:

  • 输入:长度为 1 KiB、包含 ASCII 和非 ASCII 字符的字符串;
  • 输出:两种实现产生完全相同的规范化字符串;
  • 环境:CPython 3.14、固定依赖版本、相同机器;
  • 协议:每个进程预热若干次,重复多个独立进程;
  • 指标:每次调用的墙上时钟时间,以纳秒表示。

2.1 输入必须代表真实成本

输入规模会改变算法行为。

例如,下面两个函数的比较:

def contains_loop(items, target):
    for item in items:
        if item == target:
            return True
    return False


def contains_set(items, target):
    return target in set(items)

如果每次调用都把列表转换成集合,那么集合构造成本属于被测工作的一部分。若生产代码会重复查询同一批数据,正确的基准可能是:

def contains_set_reused(index, target):
    return target in index

这不是“谁更快”的抽象问题,而是两个不同工作负载:

Tset-each-time=Tbuild-set+TlookupT_{\text{set-each-time}} = T_{\text{build-set}} + T_{\text{lookup}}

Treused-set=TlookupT_{\text{reused-set}} = T_{\text{lookup}}

把二者混为一谈,会导致优化方向错误。

2.2 输出必须被消费

编译器优化在 Python 微基准中通常不像 C 编译器那样直接删除整个函数调用,但未使用结果仍然会改变程序语义和执行路径,尤其是涉及惰性对象、缓存、引用计数或副作用时。

建议让结果参与校验:

def benchmark_target(data):
    result = transform(data)
    if len(result) != EXPECTED_LENGTH:
        raise AssertionError("benchmark output is invalid")

校验本身不能放进每次最小操作的计时区域,否则测到的是:

Tobserved=Ttarget+TvalidationT_{\text{observed}} = T_{\text{target}} + T_{\text{validation}}

可以在基准外校验一次,也可以让基准函数返回结果后由框架统一处理。关键是不要为了追求低数字而测量一个与真实语义不同的空操作。

2.3 设置、拆卸和被测区间必须分离

假设目标是比较字典查找:

data = {str(i): i for i in range(100_000)}
key = "99999"

正确的实验应将数据构造放在 setup 中:

import timeit

setup = """
data = {str(i): i for i in range(100_000)}
key = "99999"
"""

stmt = "data[key]"

print(timeit.timeit(stmt, setup=setup, number=100_000))

这里测量的是字典查找,而不是“构造字典加查找”。timeitsetup 默认不计入被测执行时间;命令行模式还会自动选择循环次数,使一次测量达到一定时间预算。(docs.python.org)

但如果生产代码确实每次都要构造字典,那么构造就不应被排除。setup 是否排除,取决于基准问题,而不是取决于哪个数字更好看。


三、时间测量:墙上时间、CPU 时间和时钟

3.1 perf_counter() 测量经过的时间

time.perf_counter() 用于测量短时间间隔,包含进程在等待、睡眠或被操作系统挂起期间经过的时间。它返回的是单调性能计数器的值,只有两个读数之差有意义;如果不需要浮点精度损失,可以使用 perf_counter_ns()。(docs.python.org)

from time import perf_counter_ns

start = perf_counter_ns()
result = function(data)
elapsed_ns = perf_counter_ns() - start

这通常对应用户实际感受到的延迟:

Twall=TCPU+T调度等待+TI/O等待+T锁等待T_{\text{wall}} = T_{\text{CPU}} + T_{\text{调度等待}} + T_{\text{I/O等待}} + T_{\text{锁等待}}

因此,墙上时间适合测量端到端请求、文件处理、网络调用和包含等待的真实流程。

3.2 process_time() 只测当前进程消耗的 CPU 时间

time.process_time() 返回当前进程的用户态和内核态 CPU 时间之和,不包含睡眠和被调度出去的时间。(docs.python.org)

当目标是纯 CPU 计算时,它可以帮助区分:

墙上时间变慢,CPU 时间不变

这往往意味着调度、I/O、锁竞争或系统噪声增加。

反过来:

墙上时间和 CPU 时间都变慢

更可能说明执行路径本身发生了变化,或者 CPU 频率、缓存、内存访问等因素影响了实际计算成本。

不过 CPU 时间不是端到端延迟的替代品。一个服务即使只消耗很少 CPU,也可能因为锁或网络等待而延迟很高。

3.3 不要使用 time.time() 测量微基准

time.time() 表示系统实时时钟,可能因为时间同步或人工调整而跳变。基准测试需要测量持续时间,通常应使用 perf_counter()perf_counter_ns(),而不是日历时间。

可以检查当前平台的时钟属性:

import time

for name in ("perf_counter", "process_time"):
    print(name, time.get_clock_info(name))

可能输出:

perf_counter namespace(implementation='clock_gettime(CLOCK_MONOTONIC)', monotonic=True, adjustable=False, resolution=1e-09)
process_time namespace(implementation='clock_gettime(CLOCK_PROCESS_CPUTIME_ID)', monotonic=True, adjustable=False, resolution=1e-09)

具体实现和分辨率由平台决定,不能把某台 Linux 机器上的底层时钟名称当作 Python 跨平台规范。


四、预热:为什么第一次结果通常不代表稳定状态

预热(warmup)是指在正式采样前,先执行目标代码,使程序进入接近稳定的运行状态。

预热并不只是“让 CPU 变热”。它可能影响:

  • Python 模块导入和惰性初始化;
  • 正则表达式、编码器、解析器等内部缓存;
  • 操作系统页缓存;
  • CPU 指令缓存和数据缓存;
  • 内存分配器的空闲块;
  • 引用计数和垃圾回收状态;
  • JIT Python 实现中的代码编译与优化;
  • CPython 的自适应解释器状态。

因此,单次测量实际上可能包含多个阶段:

T1=T初始化+T目标T_1 = T_{\text{初始化}} + T_{\text{目标}}

T2,T3,T稳定目标T_2, T_3, \ldots \approx T_{\text{稳定目标}}

如果把 T1T_1 和稳定阶段混合计算,就会得到一个没有明确语义的平均值。

4.1 预热必须与正式样本分离

一个简单的手写基准可以这样写:

from time import perf_counter_ns
from statistics import mean

def run_once(func, arg):
    start = perf_counter_ns()
    result = func(arg)
    elapsed = perf_counter_ns() - start
    return elapsed, result


def benchmark(func, arg, warmups=20, samples=100):
    for _ in range(warmups):
        func(arg)

    values = []
    for _ in range(samples):
        elapsed, result = run_once(func, arg)
        values.append(elapsed)

    return values

这里有三个重要边界:

  1. 预热调用不进入 values
  2. 输入 arg 在测量外构造;
  3. 目标函数结果必须通过其他方式确认正确。

如果目标函数会修改输入,那么预热会改变正式样本的输入状态。此时必须在每轮恢复状态,或为每轮准备等价的独立输入。

4.2 预热不能掩盖冷启动成本

预热适合回答“稳定运行状态下的每次调用成本”。它不适合回答:

  • CLI 程序从启动到完成需要多久;
  • Serverless 实例冷启动需要多久;
  • 第一个请求延迟是多少;
  • 导入模块和初始化连接池耗时多少。

这些问题应单独定义冷启动基准:

冷启动基准:
  启动新进程
  导入模块
  初始化资源
  执行一次请求
  记录总时间

稳态基准:
  启动进程
  完成预热
  重复执行请求
  记录稳定调用时间

不能用稳态基准的结果替代冷启动指标,也不能用冷启动成本解释每次热路径调用的差异。

4.3 pyperf 如何处理预热

pyperf 通过独立 worker 进程运行基准。每个 worker 会先执行一次被测代码作为预热,该结果不进入最终结果;随后执行多个正式值,并汇总多个进程的结果。它还会先进行校准,以估计每个外层值应执行多少循环。(pyperf.readthedocs.io)

这比在当前测试进程中简单调用函数几十次更可靠,因为进程隔离可以减少前一个测试对后一个测试的状态污染。


五、噪声:测量值为什么会波动

设第 ii 次观测为:

Xi=μ+εiX_i = \mu + \varepsilon_i

其中:

  • μ\mu 是我们真正关心的稳定平均成本;
  • εi\varepsilon_i 是噪声。

噪声可能来自多个层次。

5.1 操作系统调度

进程可能被临时切换出去,导致墙上时间增加:

目标执行 80 ns
进程被挂起 2 ms
观测结果约为 2 ms + 80 ns

这类样本通常表现为右侧长尾,而不是所有样本均匀变慢。

5.2 CPU 频率和温度

现代 CPU 会根据负载、温度、电源策略改变频率。基准刚开始时可能处于高频状态,长时间运行后也可能因为温度升高降频。

因此,在不同时间、不同电源模式或不同容器宿主机上运行,同一代码可能得到不同的绝对时间。

5.3 缓存和内存访问

第一次访问数据可能触发:

  • 页面映射;
  • CPU cache miss;
  • TLB miss;
  • 内存分配;
  • 操作系统页缓存读取。

如果输入数据太小,整个工作集可能始终位于高速缓存中;如果输入太大,则测量会包含内存带宽和随机访问成本。两者代表不同的工作负载。

5.4 Python 垃圾回收与分配器

创建大量短生命周期对象会改变垃圾回收行为。某一轮恰好触发回收,耗时就可能出现尖峰。

不能简单地说“关闭 GC 就更准确”。关闭 GC 得到的是:

TGC-disabledT_{\text{GC-disabled}}

而生产环境通常是:

Tproduction=Tallocation+Toccasionally-GCT_{\text{production}} = T_{\text{allocation}} + T_{\text{occasionally-GC}}

如果目标是比较纯粹的对象分配路径,可以单独做 GC-disabled 基准;如果目标是模拟长期运行服务,应保留与生产一致的 GC 配置,并把 GC 状态记录为元数据。

5.5 地址随机化和哈希随机化

独立进程的内存地址、哈希种子等可能不同,从而影响缓存行为、哈希冲突和访问路径。pyperf 文档将多进程运行作为减少这类随机因素影响的一部分,并指出单进程结果只代表一个特定进程状态。(pyperf.readthedocs.io)

5.6 并发背景负载

后台编译、浏览器、同步服务、虚拟机、容器限额和其他 CI job 都可能污染结果。

降低噪声不是为了“制造好看的数字”,而是为了让不同版本之间的差异更容易归因。pyperf system tune、CPU 绑定、CPU 隔离和进程优先级等手段可以减少系统抖动,但它们本身改变了实验环境,必须记录在结果中。(pyperf.readthedocs.io)


六、统计量:不要只看一个最小值

6.1 最小值适合估计“无干扰下限”

设观测值为:

x1,x2,,xnx_1, x_2, \ldots, x_n

最小值是:

xmin=min(x1,,xn)x_{\min} = \min(x_1, \ldots, x_n)

它可能接近“没有被调度打断时的最快路径”,因此对微基准有一定价值。但它不能代表典型表现。

例如:

观测值:100, 101, 102, 103, 160 ns
最小值:100 ns
平均值:113.2 ns
中位数:102 ns

如果系统偶尔产生 160 ns 的长尾,最小值完全看不出尾部问题。

timeit 的命令行输出使用“多次重复中的 best-of”形式,并明确提醒其他程序可能影响时钟;重复测量和取最佳值适合寻找较少干扰的下限,但不等于完整的分布分析。(docs.python.org)

6.2 均值衡量总成本的平均水平

样本均值为:

xˉ=1ni=1nxi\bar{x} = \frac{1}{n}\sum_{i=1}^{n}x_i

如果每次调用在生产中都同等重要,均值可以估计单次调用的长期平均成本。

但均值对异常值敏感:

[100, 101, 102, 103, 160]

一个调度尖峰会明显抬高均值。

6.3 中位数描述典型样本

中位数是排序后位于中间的值。它对少量异常值不敏感:

[100, 101, 102, 103, 160]
中位数 = 102

如果目标是“典型请求”,中位数比最小值更有解释力。但中位数也可能隐藏尾部,不能替代 P95 或 P99。

6.4 分位数描述尾延迟

P95P_{95} 表示约 95% 的观测不超过该值,P99P_{99} 表示约 99% 的观测不超过该值。

对于服务请求,常见指标是:

latencyp50,latencyp95,latencyp99\text{latency}_{p50}, \quad \text{latency}_{p95}, \quad \text{latency}_{p99}

对于纯微基准,P99 可能主要反映调度噪声;对于真实端到端服务,P99 可能正是用户体验和容量规划的关键。

因此,分位数的意义取决于采样单位。如果一次样本内部包含数百万次循环,P99 描述的是“某个批次的平均时间尾部”,而不是单次请求尾延迟。

6.5 标准差表示离散程度

样本标准差为:

s=1n1i=1n(xixˉ)2s = \sqrt{ \frac{1}{n-1} \sum_{i=1}^{n}(x_i-\bar{x})^2 }

标准差大,说明样本波动大;标准差小,说明观测更集中。

但标准差不能单独判断“差异是否真实”。还要看差异大小与样本量:

Δ=xˉnewxˉold\Delta = \bar{x}_{\text{new}} - \bar{x}_{\text{old}}

SE(xˉ)snSE(\bar{x}) \approx \frac{s}{\sqrt{n}}

即使单次观测波动较大,只要独立样本足够多,均值估计也可能比较稳定。反过来,标准差很小但只测了两次,也不能构成可靠证据。

6.6 变异系数适合比较不同量级

变异系数为:

CV=sxˉCV = \frac{s}{\bar{x}}

例如:

基准 A:平均 100 ns,标准差 5 ns,CV = 5%
基准 B:平均 10 ms,标准差 0.2 ms,CV = 2%

虽然 B 的绝对波动更大,但相对稳定性更好。


七、完整统计算例:什么时候可以说“变快了”

假设修改前后各得到 10 个独立样本,单位为微秒:

旧版本:
[100, 101, 99, 100, 102, 98, 100, 101, 99, 100]

新版本:
[96, 97, 95, 96, 98, 95, 96, 97, 96, 95]

旧版本:

xˉold=100.0\bar{x}_{old}=100.0

新版本:

xˉnew=96.1\bar{x}_{new}=96.1

绝对变化:

Δ=96.1100.0=3.9 μs\Delta = 96.1 - 100.0 = -3.9\ \mu s

相对变化:

r=96.1100.01=3.9%r = \frac{96.1}{100.0}-1=-3.9\%

因为耗时下降,所以这是约 3.9% 的加速。

但“平均值下降”还不够。需要检查:

  1. 输入是否完全相同;
  2. 版本、解释器和编译选项是否相同;
  3. 旧版本和新版本是否以相同顺序运行;
  4. 是否有预热;
  5. 样本是否来自多个独立进程;
  6. 分布是否存在异常长尾;
  7. 差异是否大于实验的历史噪声。

如果旧版本和新版本不是一一配对的样本,可以把两组样本视为独立样本,比较均值差异及其不确定性。

如果每次实验都可以在相同输入、相同环境下先运行旧版本再运行新版本,那么更好的设计是配对实验

第 1 轮:旧版本 100 ns,新版本 96 ns,差值 -4 ns
第 2 轮:旧版本 101 ns,新版本 97 ns,差值 -4 ns
第 3 轮:旧版本 99 ns,新版本 95 ns,差值 -4 ns

对每一对计算:

di=xnew,ixold,id_i = x_{new,i} - x_{old,i}

再分析 did_i 的均值和离散程度。这样可以抵消该轮实验共同受到的 CPU 频率、系统负载等因素。

不过配对实验要求两次运行确实共享可比条件。若旧版本和新版本分别在不同机器、不同时间段运行,强行配对反而会制造虚假的精确性。


八、统计显著不等于工程上值得优化

假设基准从 100 ns 变为 99.9 ns,样本很多,统计检验可能认为差异显著。

但工程上还要问:

收益=单次节省时间×调用次数\text{收益} = \text{单次节省时间} \times \text{调用次数}

如果一次请求只调用 10 次,那么每次节省 0.1 ns 几乎没有实际价值。相反,如果一个批处理任务调用 10 亿次,该差异可能值得关注。

因此应同时设定:

  • 统计阈值:差异需要足以排除随机波动;
  • 工程阈值:差异需要超过实际业务可接受的收益门槛。

可以定义回归策略:

性能回归 = 统计上显著变慢
        且 相对变慢超过 3%
        且 该基准属于发布阻断集合

这里的 3% 不是 Python 的规范,也不是普适常数,而是项目决策。不同项目可能使用 1%、5%、10%,或者按绝对延迟定义,例如:

P95 增加超过 5 ms 才阻断发布

九、timeit:小代码片段的第一层工具

timeit 是标准库中用于测量小段 Python 代码的工具,提供命令行接口和 Python API。它会处理一些常见陷阱,例如使用适合测量短间隔的计时器,并将 setup 从被测语句中分离。(docs.python.org)

9.1 命令行示例

python -m timeit \
  -s "items = list(range(1000))" \
  "sum(items)"

典型输出类似:

50000 loops, best of 5: 5.8 usec per loop

含义是:自动选择每次重复的循环数,重复若干组,输出其中最快一组的平均每循环时间。

可以显式指定:

python -m timeit -n 10000 -r 7 -u nsec "sum(range(10))"

参数含义:

  • -n:每组执行多少次;
  • -r:重复多少组;
  • -u:输出单位;
  • -p:使用进程 CPU 时间而不是墙上时间;
  • -v:显示更详细的原始结果。

这些参数属于 timeit 命令行接口。(docs.python.org)

9.2 Python API 示例

import timeit

setup = """
data = list(range(1000))
"""

stmt = "sum(data)"

timer = timeit.Timer(stmt=stmt, setup=setup)
values = timer.repeat(repeat=7, number=10_000)

print("每组总耗时:", values)
print("最快每次耗时:", min(values) / 10_000)

这里的 values 是每组执行 10,000 次的总时间,除以 number 后才是每次循环的平均时间。

9.3 timeit 的边界

timeit 适合:

  • 比较两个表达式;
  • 测量一个很小的纯 Python 操作;
  • 快速验证优化方向;
  • 辅助解释某个局部变化。

它不适合单独承担:

  • 跨机器性能基线;
  • 长时间运行服务;
  • 多进程隔离实验;
  • 发布阻断级回归判断;
  • 复杂端到端流程;
  • 需要完整元数据和历史比较的性能系统。

另外,timeit 会改变某些运行条件。pyperf 文档指出,timeit 的标准使用方式包括单进程、有限重复次数和关闭垃圾回收等特征,因此其结果与保留 GC、使用多个 worker 进程的 pyperf 结果可能具有不同含义。(pyperf.readthedocs.io)


十、pyperf:把基准从脚本升级为实验

pyperf 是用于编写、运行和分析 Python 基准测试的工具。它提供自动校准、多 worker 进程、均值和标准差计算、不稳定性检查、统计分析以及结果比较等能力。(pyperf.readthedocs.io)

10.1 安装

python -m pip install pyperf

应在专用虚拟环境中安装,并记录:

python --version
python -m pip show pyperf
python -m pip freeze

pyperf 文档给出了通过 python3 -m pip install pyperf 安装的方式;使用 python -m 可以确保调用的是当前解释器环境中的模块。(pyperf.readthedocs.io)

10.2 命令行微基准

python -m pyperf timeit \
  --name list-copy \
  --setup "data = list(range(1000))" \
  "data.copy()" \
  --output list-copy.json

运行过程中可能看到:

.....................
Mean +- std dev: 2.31 us +- 0.04 us

这里的均值和标准差描述的是 pyperf 收集到的多组测量值,而不是单次 perf_counter() 的结果。

查看结果:

python -m pyperf show list-copy.json

检查稳定性:

python -m pyperf check list-copy.json

查看更详细的分布:

python -m pyperf stats list-copy.json
python -m pyperf hist list-copy.json

pyperf stats 可以显示最小值、最大值、均值、中位数和分位数等统计量;check 用于识别可能不稳定的基准。(pyperf.readthedocs.io)

10.3 为什么 pyperf 要使用多个进程

一个 worker 进程可能受到:

  • 当前哈希种子;
  • ASLR 地址布局;
  • 当前 CPU 状态;
  • 当前垃圾回收状态;
  • 进程生命周期中的缓存状态;

的影响。

多个独立进程可以把这些因素纳入样本,而不是只观察某一个进程的偶然状态。pyperf 的典型架构包括校准 worker、多个正式 worker、每个 worker 的预热值和正式值;具体运行数量可由命令行选项和版本默认值影响,不应把某一次版本中的默认数字硬编码为项目规范。(pyperf.readthedocs.io)

10.4 使用 Python API 编写基准

项目级基准不应长期依赖复杂的 shell 字符串。可以写成 Python 文件:

import pyperf


def normalize_name(value: str) -> str:
    return " ".join(value.split()).casefold()


def main():
    runner = pyperf.Runner()

    value = "  Alice   Smith  " * 20

    runner.bench_func(
        "normalize-name",
        normalize_name,
        value,
    )


if __name__ == "__main__":
    main()

运行:

python benchmarks/normalize.py \
  --output results/normalize.json

bench_func() 负责调用函数并由 Runner 管理运行过程。输入数据在 benchmark 注册前创建,避免把数据构造混入每次测量;但如果生产场景包含数据构造,则应把它显式放入另一个端到端基准。

异步函数可以使用对应的异步基准 API;阻塞 I/O、事件循环调度和网络环境应单独定义,不能把异步函数简单包装成同步 lambda 后就认为测量了异步性能。


十一、内层循环、外层循环和测量开销

当目标操作只需要几十纳秒时,计时器调用和 Python 循环本身可能与目标操作同量级。

设:

Tone=Ttimer+Tloop+TtargetT_{\text{one}} = T_{\text{timer}} + T_{\text{loop}} + T_{\text{target}}

如果 TtargetT_{\text{target}} 很小,则测到的是框架开销和目标开销的混合。

解决方法是一次测量执行多个目标操作:

Tbatch=Tfixed+kTtargetT_{\text{batch}} = T_{\text{fixed}} + kT_{\text{target}}

估计单次目标成本:

T^target=TbatchTfixedk\hat{T}_{\text{target}} = \frac{T_{\text{batch}}-T_{\text{fixed}}}{k}

timeit 通过增加循环次数降低固定开销;pyperf 还支持 --inner-loops--duplicate,用于减少外层循环相对于目标语句的影响。(docs.python.org)

例如:

python -m pyperf timeit \
  --name strip \
  --inner-loops 1000 \
  '" abc ".strip()'

但批量执行会改变缓存、分支预测和分配器状态。于是它估计的是“连续执行时的平均成本”,不一定等于真实请求之间间隔较大的单次调用成本。

因此,低层微基准可以使用 inner loop,但应同时保留一个更接近真实调用间隔的基准。


十二、基准结果必须包含元数据

只有一个数字的性能报告无法复现。

至少应记录:

Python 实现:CPython
Python 版本:3.14.x
构建方式:release/debug,是否 PGO/LTO
操作系统和内核
CPU 型号、核心数、频率策略
内存大小
基准代码版本
依赖版本
输入规模和数据特征
环境变量
CPU affinity
是否启用 tracemalloc
是否启用 profiling
GC 配置
日期和时间
运行命令

pyperf 的 JSON 结果会保存 benchmark 数据及相关元数据,Runner 也提供输出文件、CPU affinity、环境继承等选项。(pyperf.readthedocs.io)

例如:

python -m pyperf metadata results/normalize.json
python -m pyperf dump results/normalize.json

如果结果显示新版本变慢,第一步不是立刻回滚,而是检查:

旧结果的 Python 可执行文件是否相同?
机器和 CPU 是否相同?
输入是否相同?
是否启用了 tracemalloc?
是否更换了依赖?
是否改变了 locale?
是否改变了线程数?
是否在容器中受到 CPU quota 限制?

没有这些信息,性能回归往往只能被描述为“某次运行更慢”,不能归因于代码修改。


十三、回归判断:从差异到决策

13.1 先计算方向和幅度

对于耗时指标:

r=TnewToldToldr = \frac{T_{\text{new}}-T_{\text{old}}} {T_{\text{old}}}

  • r>0r > 0:变慢;
  • r<0r < 0:变快;
  • r|r|:相对变化幅度。

例如:

旧版本:10.0 ms
新版本:10.6 ms

则:

r=10.610.010.0=6%r = \frac{10.6-10.0}{10.0}=6\%

应表述为“耗时增加约 6%”,而不是“性能下降 6 倍”。

对于吞吐量指标,方向相反:

rthroughput=QnewQoldQoldr_{\text{throughput}} = \frac{Q_{\text{new}}-Q_{\text{old}}} {Q_{\text{old}}}

吞吐量增加 6% 是改善,而耗时增加 6% 是退化。

13.2 再判断统计显著性

pyperf compare_to 将第一个 JSON 文件作为参考,比较后续文件。其显著性判断使用双样本、双侧 Student t 检验,并以 0.95 作为置信水平;多个 benchmark 汇总时会使用归一化结果的几何平均。(pyperf.readthedocs.io)

基本命令:

python -m pyperf compare_to \
  baseline.json \
  candidate.json

生成 Markdown 表格:

python -m pyperf compare_to \
  --table \
  --table-format md \
  baseline.json \
  candidate.json

只比较某个基准:

python -m pyperf compare_to \
  --benchmark normalize-name \
  baseline.json \
  candidate.json

可能得到类似结果:

Mean +- std dev: [baseline] 10.0 ms +- 0.1 ms
                 -> [candidate] 10.6 ms +- 0.1 ms: 1.06x slower

这表示候选版本的平均耗时约为参考版本的 1.06 倍。是否阻断发布,还要结合项目阈值和业务影响。

13.3 “不显著”不等于“没有差异”

不显著可能有三种原因:

  1. 两个版本确实没有实际差异;
  2. 差异很小,小于当前噪声;
  3. 样本量不足,统计能力不够。

如果工程上关心 2% 的变化,而当前基准噪声达到 5%,继续分析同一批结果没有意义。应先降低噪声或增加样本量。

13.4 多个基准不能只看总平均

假设有三个基准:

A:快 20%
B:慢 20%
C:慢 1%

简单算术平均可能接近 0%,但这不代表整体性能不变。不同 benchmark 的绝对时间不同,算术平均还会被量纲和规模支配。

对于比率型结果,几何平均更适合汇总归一化后的速度比:

G=(i=1nri)1/nG = \left( \prod_{i=1}^{n} r_i \right)^{1/n}

但几何平均也不能替代逐项分析。一个关键路径严重变慢,不能被多个不重要的微基准变快抵消。


十四、一个可落地的回归门禁

可以将性能回归分为三层。

第一层:开发者本地快速反馈

python -m pyperf timeit \
  --fast \
  --name parse-small \
  --setup "data = 'a,b,c'" \
  "data.split(',')"

目标是快速判断方向,不作为最终发布证据。

第二层:合并请求验证

python -m pyperf timeit \
  --name parse-small \
  --setup "data = 'a,b,c'" \
  "data.split(',')" \
  --output candidate.json

python -m pyperf check candidate.json

如果结果不稳定,先修复实验环境或调整基准,不要直接依据不稳定结果评论代码。

第三层:发布级比较

python -m pyperf compare_to \
  --table \
  baseline.json \
  candidate.json

发布门禁可以采用如下决策矩阵:

统计结果 相对变化 决策
不显著 任意小幅变化 通常不阻断,保留记录
显著变快 超过工程阈值 接受优化,检查正确性和内存代价
显著变慢 超过工程阈值 阻断或要求解释
显著变慢 小于工程阈值 评估是否是关键路径
结果不稳定 任意变化 先修复实验,不作代码结论

这张表不是统计定理,而是把统计结果映射到工程流程的决策规则。


十五、反例:看似合理但结论错误的基准

15.1 反例一:只运行一次

start = perf_counter_ns()
new_result = new_function(data)
new_time = perf_counter_ns() - start

如果恰好发生进程调度,结果会显著偏大;如果恰好没有干扰,结果会显著偏小。单次观测只能说明这一次发生了什么,不能估计总体分布。

15.2 反例二:先运行旧版本,再运行新版本

old_time = measure(old_function)
new_time = measure(new_function)

如果旧版本执行时 CPU 频率较高、新版本执行时机器开始降频,差异就被环境顺序污染。

改进方法包括:

  • 多次交替运行;
  • 随机化旧、新版本顺序;
  • 使用独立进程;
  • 使用配对样本;
  • 分别在相同机器状态下采集。

15.3 反例三:基准输入只覆盖最有利情况

data = [1, 2, 3]

如果生产输入通常有 100 万条数据,这个基准测到的可能只是函数调用和小对象开销,而不是算法复杂度、内存带宽和缓存行为。

至少应覆盖:

空输入
小输入
典型输入
大输入
最坏路径输入
异常或边界输入

这些输入不一定放在一个 benchmark 中。更好的方式是分别命名:

parse-empty
parse-small
parse-typical
parse-large
parse-invalid

这样回归报告才知道是哪类负载发生变化。

15.4 反例四:优化后返回值不同

old_result = old_function(data)
new_result = new_function(data)

如果没有验证结果相等,最快的“实现”可能只是少做了工作。

assert old_result == new_result

对于浮点结果,应根据问题使用绝对误差和相对误差,而不是盲目使用严格相等。对于序列化、哈希和协议数据,则可能必须要求字节级一致。


十六、pytest 和 unittest 在基准体系中的位置

基准测试不应取代功能测试。

一个性能基准通常应配套至少三类测试:

功能测试:结果和异常是否正确
回归测试:已知缺陷是否不会复发
基准测试:性能指标是否发生变化

16.1 用 pytest 验证基准输入

import pytest

from app import normalize_name


@pytest.mark.parametrize(
    "value",
    [
        "",
        "Alice",
        "  Alice   Smith  ",
        "中文 名称",
    ],
)
def test_normalize_name_returns_str(value):
    result = normalize_name(value)
    assert isinstance(result, str)

pytest fixture 可以准备共享数据,参数化可以覆盖多种输入,测试失败时还能显示断言表达式的详细信息。(docs.pytest.org)

16.2 用 unittest 保持标准库兼容

import unittest

from app import normalize_name


class NormalizeNameTests(unittest.TestCase):
    def test_collapses_whitespace(self):
        self.assertEqual(
            normalize_name("  Alice   Smith  "),
            "alice smith",
        )

    def test_empty_string(self):
        self.assertEqual(normalize_name(""), "")


if __name__ == "__main__":
    unittest.main()

unittest 适合不希望引入第三方测试框架的库,或者需要兼容既有 TestCase 体系的项目。pytest 可以运行这类测试,但 unittest.TestCase 方法不能像普通 pytest 测试函数那样直接接收 fixture 参数。(docs.pytest.org)

16.3 不要把“耗时小于某值”写成功能断言

下面的测试非常脆弱:

def test_fast():
    assert measure() < 100_000  # 100 微秒

它会把机器速度、CI 负载、CPU 频率和调度偶然性全部变成功能测试失败。

性能阈值测试只有在环境严格固定、指标定义稳定且失败处理流程成熟时才适合使用。更常见的做法是:

  1. 功能测试保证结果正确;
  2. 独立 benchmark 采集数据;
  3. 使用历史基线进行统计比较;
  4. 由性能门禁决定是否阻断。

十七、微基准与端到端基准必须同时存在

微基准具有高定位能力,端到端基准具有高真实性。

例如,HTTP 服务一次请求的时间可以拆成:

Trequest=Trouting+Tvalidation+Tbusiness+Tserialization+TI/O+TqueueT_{\text{request}} = T_{\text{routing}} + T_{\text{validation}} + T_{\text{business}} + T_{\text{serialization}} + T_{\text{I/O}} + T_{\text{queue}}

微基准可以单独测量:

validation-only
business-only
serialization-only

端到端基准则测量完整请求:

request-through-service

如果端到端变慢而所有微基准都不变,原因可能在:

  • 网络;
  • 线程池;
  • 数据库;
  • 锁;
  • 队列;
  • 序列化边界;
  • 进程间通信;
  • 部署配置。

如果只有 serialization-only 变慢,剖析证据链可以进一步检查调用栈和对象分配。

因此,性能调查的顺序通常是:

flowchart TD
    A[观察到端到端回归] --> B{回归是否可复现}
    B -- 否 --> C[检查噪声、环境和输入]
    B -- 是 --> D[运行端到端剖析]
    D --> E[拆分为阶段基准]
    E --> F[对热点阶段做微基准]
    F --> G[检查调用栈、分配和系统事件]
    G --> H[修改实现]
    H --> I[功能测试]
    I --> J[基准重复测量]
    J --> K{统计显著且超过工程阈值}
    K -- 否 --> L[记录为无明确回归]
    K -- 是 --> M[接受优化或阻断回归]

关键路径是:先证明变化存在,再定位变化来源,最后用同一协议重新验证。直接从一次端到端慢请求跳到“某一行代码导致回归”,中间缺少证据。


十八、原生扩展边界:到底测 Python 还是边界成本

涉及 C API、Cython、PyO3 或其他原生扩展时,必须说明测量边界。

例如:

result = native_module.sum_ints(values)

这个基准可能包含:

T=TPython调用+T参数解析+T对象转换+Tnative计算+T结果封装T = T_{\text{Python调用}} + T_{\text{参数解析}} + T_{\text{对象转换}} + T_{\text{native计算}} + T_{\text{结果封装}}

如果 values 是 Python list,原生扩展可能需要逐个读取 Python 对象;如果使用连续的原生内存缓冲区,成本则不同。

因此至少应有两类基准:

native-core:
  在 C/Cython/Rust 内部使用原生数据结构执行核心计算

python-boundary:
  从 Python 调用扩展,包含参数转换和结果封装

生产用户承担的是 python-boundary 成本,而不是裸的 native-core 成本。

还应分别比较:

  • 每次传入小数据;
  • 一次传入大数据;
  • 多次跨边界调用;
  • 批量调用;
  • 返回 Python 对象;
  • 返回 buffer 或内存视图。

很多“原生扩展很快”的结论,只测了核心循环,却忽略了 Python 与原生代码之间的边界次数。若每次只处理一个整数,调用和封装成本可能占据主导;若一次处理百万个元素,边界成本可能被核心计算摊薄。

ABI 兼容性也属于实验环境的一部分。比较不同构建时,应记录:

解释器版本
构建编译器
架构
扩展构建选项
链接库版本
是否使用稳定 ABI

否则“新版本变慢”可能实际是编译器、CPU 指令集或扩展构建方式改变。


十九、使用 tracemalloc 或 profiler 时要重新解释数字

tracemalloc 和剖析器会增加运行成本。

如果打开:

python -m pyperf timeit \
  --tracemalloc \
  --name allocate \
  "[(i, i * 2) for i in range(1000)]"

测量结果回答的是:

在启用内存分配跟踪时,这段代码花费多少时间?

它不能直接与未启用 tracemalloc 的基准比较。

同样,使用:

python -m pyperf timeit \
  --profile profile.prof \
  --name target \
  "target(data)"

得到的 profile 有助于定位函数调用热点,但计时结果已经包含 profiler 开销。pyperf 文档明确将 profiling 视为分析特定基准的便利方式,并提醒此时实际 benchmark timing 不再准确。(pyperf.readthedocs.io)

正确的证据链是:

未插桩基准:
  证明性能差异

插桩剖析:
  解释时间花在哪里

再次运行未插桩基准:
  证明修复后差异仍然存在

不能用插桩后的数字证明插桩前后的性能回归。


二十、如何处理不稳定结果

pyperf check 报告结果可能不稳定时,先看原始分布:

python -m pyperf stats result.json
python -m pyperf hist result.json
python -m pyperf dump --verbose result.json

诊断顺序可以是:

1. 检查是否循环太少

如果一次样本只有几十纳秒,计时器、循环和调度噪声占比会很大。增加 loops 或 inner loops,使单个采样值达到合理时间。

2. 检查输入是否改变

目标函数是否修改了输入?是否缓存了第一次结果?是否复用了可变对象?每轮是否执行了同样的工作?

3. 检查后台负载

关闭编译、同步、浏览器标签页和其他 CI 任务。生产机器上则应承认这些负载是系统的一部分,而不是无条件删除。

4. 检查 CPU 亲和性和电源策略

固定到指定 CPU 可以降低调度差异,但也可能与真实生产部署不同。基准报告应记录是否设置 affinity。

5. 检查垃圾回收和内存

如果长尾与周期性分配或 GC 触发同步出现,应将 GC 行为作为问题的一部分,而不是简单丢弃长尾样本。

6. 检查是否需要更多样本

增加样本可以降低均值估计的不确定性,但不能修复系统性偏差。若所有候选版本都在不同 CPU 频率下运行,更多样本只会更精确地测量错误的实验。


二十一、什么时候应该丢弃异常值

异常值不是自动等于错误。

设一个样本远高于其他样本,可能有两种解释:

解释 A:进程被系统挂起,这是测量污染
解释 B:生产环境确实会发生这种暂停

如果目标是测量无外部干扰的算法下限,可以标记并分析异常值;如果目标是服务 P99,则异常值可能正是应保留的尾延迟。

不能为了让标准差变小而删除所有慢样本。任何过滤规则都应预先定义,例如:

只删除明确确认来自人工启动备份任务的样本

而不是:

看到超过均值两倍的样本就删除

后者会把真实长尾误判为噪声。


二十二、一个最小但完整的项目结构

可以将功能测试和性能基准分开:

project/
├── src/
│   └── app/
│       └── transform.py
├── tests/
│   └── test_transform.py
├── benchmarks/
│   ├── bench_transform.py
│   └── results/
│       ├── baseline.json
│       └── candidate.json
└── pyproject.toml

功能测试:

python -m pytest

基准测试:

python benchmarks/bench_transform.py \
  --output benchmarks/results/candidate.json

稳定性检查:

python -m pyperf check \
  benchmarks/results/candidate.json

回归比较:

python -m pyperf compare_to \
  --table \
  --table-format md \
  benchmarks/results/baseline.json \
  benchmarks/results/candidate.json

这里的 baseline.json 不应随意覆盖。它应对应一个明确的提交、构建产物和运行环境。候选结果则应保留提交号,使“哪个修改导致差异”可以追溯。


二十三、最终判断框架

一个可信的 Python 基准结论至少应满足以下逻辑:

结论可信问题定义清楚输入可比预热和测量分离噪声被识别样本量足够统计方法匹配工程阈值明确\text{结论可信} \Rightarrow \text{问题定义清楚} \land \text{输入可比} \land \text{预热和测量分离} \land \text{噪声被识别} \land \text{样本量足够} \land \text{统计方法匹配} \land \text{工程阈值明确}

对应到实际工作中,可以这样问:

  1. 我测量的是冷启动、稳态调用、吞吐量还是尾延迟?
  2. 被测区间是否包含了不应包含的 setup、校验或 I/O?
  3. 是否存在第一次调用特殊、后续调用稳定的状态变化?
  4. 结果波动来自代码,还是来自系统?
  5. 我看的是最小值、均值、中位数还是 P99?
  6. 修改前后的输入、解释器、构建和机器是否可比?
  7. 差异是否统计显著?
  8. 差异是否超过工程上值得关注的阈值?
  9. 端到端结果是否能被剖析证据解释?
  10. 修改后是否重新通过功能测试和未插桩基准?

timeit 适合快速验证局部假设,pyperf 适合建立更严格的多进程基准和历史比较,pytestunittest 负责功能正确性,剖析工具负责解释热点。它们不是相互替代的工具,而是同一条性能证据链上的不同环节。

真正的性能回归判断,不是“新数字比旧数字大”,而是:

在相同问题定义和可比实验条件下,候选版本的性能分布出现了可重复、可解释、超过工程阈值的变化。


系列导航与关联阅读

官方资料

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