跳到内容

第 3 卷 / 第 18 章 / 第 1 课

18.1 从测量到性能结论 ​

指令、内存、缓存和并发知识在性能工程中汇到同一项工作上:用测量找到真正瓶颈,再判断优化是否有效。“这行看起来很慢”只是怀疑,可复现的证据才是结论。

一个可用的结论至少要说明:哪个工作负载、哪项指标、在什么环境、相对哪条基线、误差多大,以及修改后瓶颈是否转移。

本章建立性能实验的方法;具体 Linux perf 采样与计数器用法见Linux 性能剖析。

1. 先把“快”写成目标 ​

不同系统的性能目标可能相互冲突:

  • 批处理关心每小时完成量和成本;
  • API 关心 p50/p95/p99 latency 与超时率;
  • 交互应用关心帧时间和卡顿;
  • 数据库关心吞吐、提交延迟和恢复承诺;
  • 嵌入式系统还关心功耗与 worst-case latency。

把“优化接口”改写成可验证目标:

text
在 production-like 数据分布和 500 并发连接下,
checkout 的 p99 server latency 从 420 ms 降到 250 ms,
错误率不升高,单请求 CPU 时间不增加超过 10%。

这是目标示例,不是已有测量结果。还需指定到达模型、测试时长与完成量;500 条连接可能闲置,不能直接当作 500 个在途请求。若只盯平均值,少量极慢请求可能被掩盖;若只盯 p99,又可能忽略吞吐下降或资源成本翻倍。

2. 端到端时间先分账 ​

一次请求的 wall-clock latency 可以粗略拆成:

text
排队
+ CPU 运行
+ 内存停顿
+ 锁/调度等待
+ 文件与网络 I/O
+ 下游服务等待
+ runtime 暂停

这是一张排查清单,不是可直接相加的分区:CPU 时间通常包含线程仍被调度时的内存停顿,下游等待也可能包含网络与排队。同一请求的并行分支应按关键路径计算,异步等待时其他请求还能使用 CPU。分账的意义是提出下一步测量,而不是凭一张饼图宣布因果。

先建立三层观测:

  1. 业务层:请求率、成功率、延迟分布、队列深度;
  2. 进程层:CPU 时间、线程状态、分配、GC、系统调用;
  3. 系统层:调度、缺页、磁盘、网络、内存压力与同机干扰。

CPU profiler 只能解释正在 CPU 上运行的样本。线程大部分时间睡在锁或网络上时,纯 on-CPU 火焰图会很“平静”,却不是程序没有瓶颈。

3. 性能循环:测量、假设、验证 ​

一个稳健迭代是:

text
定义目标
  ↓
固定工作负载与环境
  ↓
建立可重复基线
  ↓
定位主要成本
  ↓
提出单一、可证伪假设
  ↓
做最小改动
  ↓
重复测量正确性、性能与副作用
  ↓
保留结果或回退

每轮只改变一个主要因素,能减少归因混乱。编译器版本、CPU governor、NUMA 放置、数据集、并发度和后台任务都可能改变结果,应在实验记录中保存。

测完优化点后要重新 profile。旧热点变小,排队、锁或下游限制可能成为新瓶颈;性能工程不是一次性的热点清单。

4. Amdahl 定律给出局部优化上限 ​

若总时间中比例 p 的部分能加速 N 倍,其余不变,总加速比为:

S=1(1−p)+pN

例如 40% 的时间能变快 4 倍:

S=10.6+0.4/4=10.7≈1.43

局部快 4 倍,总耗时变成原来的 70%,也就是减少 30%;加速比约 1.43,并非耗时减少 43%。这能阻止团队在占比很小的函数上花数周。

下面接受可表示为 float 的 int/float 输入,并允许用正无穷表示理想的局部无限加速;bool、NaN 和无效范围会拒绝:

python
import math

def amdahl_speedup(fraction, local_speedup):
    if type(fraction) not in (int, float) or type(local_speedup) not in (int, float):
        raise ValueError("inputs must be int or float, not bool")
    try:
        fraction, local_speedup = float(fraction), float(local_speedup)
    except OverflowError as error:
        raise ValueError("input exceeds float range") from error
    if not math.isfinite(fraction) or not 0 <= fraction <= 1:
        raise ValueError("fraction must be finite and between 0 and 1")
    if math.isnan(local_speedup) or local_speedup <= 0:
        raise ValueError("local speedup must be positive, with +inf allowed")
    if fraction == 0:
        return 1.0
    if fraction == 1:
        return local_speedup
    return 1 / ((1 - fraction) + fraction / local_speedup)

assert round(amdahl_speedup(0.40, 4), 2) == 1.43
assert amdahl_speedup(0, 100) == 1
assert amdahl_speedup(1, 8) == 8

limit = amdahl_speedup(0.95, float("inf"))
print(f"5% unchanged work caps speedup at {limit:.1f}x")

Amdahl 模型假设 workload 大小固定,比例和加速成本已知。现实中并行化会增加同步、通信和内存带宽争用;扩大问题规模时,比例也会变化。公式是上限推理工具,不是容量规划器。

5. Little 定律连接并发、吞吐与延迟 ​

对处于稳定状态的系统:

L=λW
  • L:系统内平均在途请求数;
  • \lambda:平均完成率;
  • W:平均停留时间。

如果稳定吞吐是每秒 1000 个请求,平均停留时间 0.2 秒,系统内平均约有 200 个请求。

L、λ、W 必须使用同一系统边界、请求类别和观察窗口;离开系统的失败或取消请求不能只从某一个量里删掉。它不要求到达是 Poisson,也不能把 p99 代替平均 W。

这个关系能做一致性检查,却不保证达到该并发就能维持吞吐。接近资源饱和后,队列增长会推高 W;输入率长期高于完成率时,系统不再稳定,Little 定律的稳态解释也失去前提。

限制无限队列、设置 deadline 和提供 backpressure,常比再优化几条指令更能保护尾延迟。

6. 百分位需要正确采样 ​

平均 latency 无法表达长尾。p99 表示样本中约 99% 不超过该值,但它也有边界:

  • 样本太少时高百分位不稳定;
  • 合并不同 endpoint、payload 或租户会混淆分布;
  • client latency 与 server processing time 不同;
  • 超时请求若被丢弃,分位数会显得更好;
  • 负载生成器自身饱和会降低实际到达率。

还要警惕 coordinated omission:如果目标是模拟独立于响应的到达流,负载工具却等上一个慢请求完成才发下一个,就会漏掉原计划期间的排队需求,低估尾延迟。闭环模型本身并没有错,关键是它是否对应真实使用方式。开放式到达率与闭环并发模型回答不同问题,测试报告必须说明模型。

7. 微基准最容易测到错误东西 ​

微基准适合比较一个小操作的成本,不适合直接预测完整服务。常见陷阱包括:

编译器删除或折叠工作 ​

结果未被观察时,优化器可以删除整个循环;输入全是常量时,还可能在编译期算出答案。优先使用成熟 benchmark framework 提供的 black-box/consume 机制,并检查汇编。随手把结果写进 volatile 可能阻止部分优化,也会改变要测的代码,不是万能答案。

把 setup 算进目标 ​

每轮重新分配容器、生成随机数或读取文件,会把 setup 成本混进操作。若 setup 本来就是生产路径的一部分,应保留;否则应在计时区外准备,并分别报告。

预热与运行时分层编译 ​

JVM、JavaScript 和某些 Python runtime 会解释、编译、去优化再编译。冷启动与稳态都可能是产品指标,不能只说“先热身”而不说明测哪个阶段。

C/C++ 也有页首次触碰、动态链接、cache 热身和 CPU 频率变化,不能因为没有 JIT 就忽略 warmup。

不真实的输入分布 ​

全零、已排序或恰好装进 cache 的数据,可能让预测、压缩和内存访问表现远离生产。数据集既要可复现,也要覆盖真实大小和偏斜。

计时器与批量 ​

被测操作比计时器开销还短时,应批量执行再除以次数,并测量空基线。注意编译器能否把批量循环合并或向量化。批量耗时除以次数得到平均每操作成本,不是单次操作的延迟分布或 p99。报告重复测量的分布和不确定性,并区分迭代、批次与独立进程样本,避免把相关样本当作独立证据。

环境噪声 ​

CPU 迁移、频率调节、SMT 邻居、NUMA、后台任务、温度和虚拟化 steal time 都会带来方差。固定环境不是为了制造漂亮数字,而是让对照实验的差异可归因。上线前仍要回到真实环境验证。

8. 对照实验要同时守住正确性 ​

“优化后更快”可能因为少做了工作。每次性能比较都应先验证:

  • 输出一致或满足新的明确语义;
  • 错误、超时和取消路径没有消失;
  • 数据持久性与同步保证没有被削弱;
  • 内存峰值、CPU 和 I/O 没转嫁到其他组件;
  • 输入规模和完成请求数相同;
  • 编译选项与依赖版本可比。

例如删除 fsync 会让提交快很多,却改变崩溃后数据承诺;把 mutex 换成无同步变量也会“提升吞吐”,同时引入未定义行为。正确性属于基线,不是性能取舍表里的可选项。

9. 优化顺序从数量级开始 ​

常见收益顺序是:

  1. 删除不需要的工作;
  2. 改善算法复杂度;
  3. 减少跨进程/网络往返;
  4. 批处理和缓存可复用结果;
  5. 改善数据布局与 I/O 模式;
  6. 降低锁竞争和共享热点;
  7. 最后才是指令级微调。

这不是绝对清单。实时系统或极小内核可能从一开始就关心指令;分布式服务往往先被网络与存储支配。原则是先追占比大且证据明确的成本。

cache 不是无条件收益。缓存引入失效、容量、击穿、一致性和内存占用。批处理降低单位固定成本,却可能增加单个请求等待时间。优化方案必须回到最初的 SLO。

10. 性能回归门禁 ​

适合 CI 的门禁应对噪声有容忍度:

  • 选稳定、代表性的 benchmark 子集;
  • 固定机器类型、核心集合与依赖;
  • 保存原始样本,而非只保存汇总;
  • 多次运行并使用统计比较;
  • 设置兼顾实际影响与方差的阈值;
  • 预先定义复测次数与合并判据,保留首次失败,避免“重跑直到变绿”掩盖回归;
  • 定期用生产 profile 检查 benchmark 是否仍代表真实热点。

微基准通过,不代表端到端 SLO 通过。分层门禁可以同时保留算法级 benchmark、组件 load test 和少量端到端场景。

11. 一份可复核实验记录 ​

每次优化至少保存:

text
目标与指标:
代码版本与构建参数:
硬件、内核、runtime:
数据集与输入分布:
负载模型与持续时间:
预热和采样窗口:
基线原始结果:
profile 证据:
改动与假设:
新结果及方差:
正确性检查:
CPU/内存/I/O 副作用:
结论与适用范围:

没有原始数据和环境,漂亮的“快 37%”很难复现。没有适用范围,它又很容易被错误推广到另一台 CPU、另一种数据或另一条请求路径。

12. 小结 ​

性能工程是一条证据链:

  • 用业务 SLO 定义“快”;
  • 把端到端时间分成可测的运行、等待与排队;
  • 用 Amdahl 估算局部优化能影响多少总时间;
  • 用 Little 定律检查并发、吞吐与延迟是否相容;
  • 用真实分布、正确负载模型和重复样本避免基准幻觉;
  • 每次改动同时验证语义与资源副作用;
  • 优化后重新 profile,让新瓶颈暴露出来。

下一节进入Linux 性能剖析,把这些方法落实为 perf stat、采样调用栈、火焰图和 off-CPU 分析。完成这一节后,第 3 卷就会把读者交给第 4 卷:计算机网络。

Built with VitePress | Software Systems Atlas