18.1 从测量到性能结论
指令、内存、缓存和并发知识在性能工程中汇到同一项工作上:用测量找到真正瓶颈,再判断优化是否有效。“这行看起来很慢”只是怀疑,可复现的证据才是结论。
一个可用的结论至少要说明:哪个工作负载、哪项指标、在什么环境、相对哪条基线、误差多大,以及修改后瓶颈是否转移。
本章建立性能实验的方法;具体 Linux perf 采样与计数器用法见Linux 性能剖析。
1. 先把“快”写成目标
不同系统的性能目标可能相互冲突:
- 批处理关心每小时完成量和成本;
- API 关心 p50/p95/p99 latency 与超时率;
- 交互应用关心帧时间和卡顿;
- 数据库关心吞吐、提交延迟和恢复承诺;
- 嵌入式系统还关心功耗与 worst-case latency。
把“优化接口”改写成可验证目标:
在 production-like 数据分布和 500 并发连接下,
checkout 的 p99 server latency 从 420 ms 降到 250 ms,
错误率不升高,单请求 CPU 时间不增加超过 10%。这是目标示例,不是已有测量结果。还需指定到达模型、测试时长与完成量;500 条连接可能闲置,不能直接当作 500 个在途请求。若只盯平均值,少量极慢请求可能被掩盖;若只盯 p99,又可能忽略吞吐下降或资源成本翻倍。
2. 端到端时间先分账
一次请求的 wall-clock latency 可以粗略拆成:
排队
+ CPU 运行
+ 内存停顿
+ 锁/调度等待
+ 文件与网络 I/O
+ 下游服务等待
+ runtime 暂停这是一张排查清单,不是可直接相加的分区:CPU 时间通常包含线程仍被调度时的内存停顿,下游等待也可能包含网络与排队。同一请求的并行分支应按关键路径计算,异步等待时其他请求还能使用 CPU。分账的意义是提出下一步测量,而不是凭一张饼图宣布因果。
先建立三层观测:
- 业务层:请求率、成功率、延迟分布、队列深度;
- 进程层:CPU 时间、线程状态、分配、GC、系统调用;
- 系统层:调度、缺页、磁盘、网络、内存压力与同机干扰。
CPU profiler 只能解释正在 CPU 上运行的样本。线程大部分时间睡在锁或网络上时,纯 on-CPU 火焰图会很“平静”,却不是程序没有瓶颈。
3. 性能循环:测量、假设、验证
一个稳健迭代是:
定义目标
↓
固定工作负载与环境
↓
建立可重复基线
↓
定位主要成本
↓
提出单一、可证伪假设
↓
做最小改动
↓
重复测量正确性、性能与副作用
↓
保留结果或回退每轮只改变一个主要因素,能减少归因混乱。编译器版本、CPU governor、NUMA 放置、数据集、并发度和后台任务都可能改变结果,应在实验记录中保存。
测完优化点后要重新 profile。旧热点变小,排队、锁或下游限制可能成为新瓶颈;性能工程不是一次性的热点清单。
4. Amdahl 定律给出局部优化上限
若总时间中比例 p 的部分能加速 N 倍,其余不变,总加速比为:
例如 40% 的时间能变快 4 倍:
局部快 4 倍,总耗时变成原来的 70%,也就是减少 30%;加速比约 1.43,并非耗时减少 43%。这能阻止团队在占比很小的函数上花数周。
下面接受可表示为 float 的 int/float 输入,并允许用正无穷表示理想的局部无限加速;bool、NaN 和无效范围会拒绝:
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:系统内平均在途请求数;\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. 优化顺序从数量级开始
常见收益顺序是:
- 删除不需要的工作;
- 改善算法复杂度;
- 减少跨进程/网络往返;
- 批处理和缓存可复用结果;
- 改善数据布局与 I/O 模式;
- 降低锁竞争和共享热点;
- 最后才是指令级微调。
这不是绝对清单。实时系统或极小内核可能从一开始就关心指令;分布式服务往往先被网络与存储支配。原则是先追占比大且证据明确的成本。
cache 不是无条件收益。缓存引入失效、容量、击穿、一致性和内存占用。批处理降低单位固定成本,却可能增加单个请求等待时间。优化方案必须回到最初的 SLO。
10. 性能回归门禁
适合 CI 的门禁应对噪声有容忍度:
- 选稳定、代表性的 benchmark 子集;
- 固定机器类型、核心集合与依赖;
- 保存原始样本,而非只保存汇总;
- 多次运行并使用统计比较;
- 设置兼顾实际影响与方差的阈值;
- 预先定义复测次数与合并判据,保留首次失败,避免“重跑直到变绿”掩盖回归;
- 定期用生产 profile 检查 benchmark 是否仍代表真实热点。
微基准通过,不代表端到端 SLO 通过。分层门禁可以同时保留算法级 benchmark、组件 load test 和少量端到端场景。
11. 一份可复核实验记录
每次优化至少保存:
目标与指标:
代码版本与构建参数:
硬件、内核、runtime:
数据集与输入分布:
负载模型与持续时间:
预热和采样窗口:
基线原始结果:
profile 证据:
改动与假设:
新结果及方差:
正确性检查:
CPU/内存/I/O 副作用:
结论与适用范围:没有原始数据和环境,漂亮的“快 37%”很难复现。没有适用范围,它又很容易被错误推广到另一台 CPU、另一种数据或另一条请求路径。
12. 小结
性能工程是一条证据链:
- 用业务 SLO 定义“快”;
- 把端到端时间分成可测的运行、等待与排队;
- 用 Amdahl 估算局部优化能影响多少总时间;
- 用 Little 定律检查并发、吞吐与延迟是否相容;
- 用真实分布、正确负载模型和重复样本避免基准幻觉;
- 每次改动同时验证语义与资源副作用;
- 优化后重新 profile,让新瓶颈暴露出来。
下一节进入Linux 性能剖析,把这些方法落实为 perf stat、采样调用栈、火焰图和 off-CPU 分析。完成这一节后,第 3 卷就会把读者交给第 4 卷:计算机网络。