性能求证方法论
# 性能求证实验方法论
📊 学习成本预估 | 难度:⭐⭐⭐⭐⭐(5/5)| 阅读:约 30 分钟 | 实操:2 小时 🔗 前置阅读:卷零·01-02 | ➡️ 后续延伸:所有专项卷
本文是专栏"科学家求证"风格的核心。任何"优化建议"在写入文档前,必须经过本文定义的实验流程产出可证伪的数据。否则视为经验主义,不予采纳。
# 目录介绍
# 01.为什么需要"实验"
# 1.1 经验性结论的不可靠性
性能领域充斥着"民间智慧",例如:
- ❓ "ArrayList 比 LinkedList 快"
- ❓ "局部变量比成员变量访问快"
- ❓ "RecyclerView 比 ListView 快"
- ❓ "WebP 一定比 JPG 省流量"
- ❓ "线程池一定比 new Thread 快"
这些结论部分情况下成立,但作为"通用建议"是危险的:
- ArrayList vs LinkedList:取决于访问模式(随机访问 vs 频繁插入)。
- RecyclerView vs ListView:在数据量小时,RecyclerView 的 ViewHolder 创建开销反而更高。
- WebP vs JPG:解码 CPU 开销显著高于 JPG,弱机型下"省了流量、卡了体验"。
- 线程池 vs new Thread:高频短任务确实如此,但单次长任务两者无差异。
结论的有效性永远依赖前提条件。求证实验的目的,就是把这些前提条件显式量化。
# 1.2 性能优化是一门可证伪的工程学
引入卡尔·波普尔(Karl Popper)的科学哲学:
一个命题是科学的,当且仅当它可被证伪。
反例(不可证伪):
- "异步加载提升性能" —— 提升多少?什么场景下成立?无法证伪。
正例(可证伪):
- "在 Android 8.0+、4GB 内存机型上,将首页 12 张图片改为 IO 线程预解码,首屏 P95 帧时长从 32ms 降至 18ms,置信区间 ±1.5ms" —— 任何人可重复实验,发现不符即证伪。
本专栏所有"建议"都必须以可证伪的形式陈述。
# 02.实验的科学框架
# 2.1 假设的可证伪性
实验从假设出发。一个合格的性能假设包含 5 个要素:
H₁: [变更 X] 在 [场景 S] 下,会使 [指标 M] 改善 [幅度 Δ],置信水平 [C]。
示例对比:
| 不合格假设 | 问题 |
|---|---|
| "用 ProtoBuf 替换 JSON 会更快" | 缺少场景、指标、幅度 |
| "ProtoBuf 比 JSON 快 50%" | 缺少场景、指标、置信水平 |
| ✅ "在 1000 字段大对象的反序列化上,ProtoBuf 较 Gson 的 P95 耗时降低 40%(±5%,95% CI),样本 n=10000" | 完整 |
配套零假设 H₀:
H₀: 变更 X 对指标 M 没有显著影响(差异由随机波动产生)
实验目标是用数据拒绝 H₀,而非"证明 H₁"。
# 2.2 控制变量原则
只允许一个变量变化,其他变量必须严格一致。
典型违规:
- "我升级了 SDK 版本,顺便重构了缓存策略,性能提升了 20%" —— 无法判断是哪个变更带来的提升,甚至可能两个变更互相抵消。
控制变量清单(性能实验必查):
- [ ] 设备型号、SoC、内存容量
- [ ] 系统版本(OS Build 号)
- [ ] 应用版本、Build Type(Debug / Release)
- [ ] 编译选项(-O0 / -O2 / R8 等级)
- [ ] 充电状态、温度(移动设备会触发降频)
- [ ] 网络类型 / 模拟弱网参数
- [ ] 后台进程、内存压力
- [ ] 数据集(图片、列表数据)
- [ ] 启动状态(冷 / 温 / 热)
# 2.3 对照组与实验组
最少需要两组:
| 组别 | 含义 |
|---|---|
| 对照组(Control) | 不做变更(baseline) |
| 实验组(Treatment) | 应用待测变更 |
进阶:
- 多实验组:变更 A vs 变更 B vs 不变更,找出最优。
- 交叉实验:同一台设备先跑 A 后跑 B,再换 B → A,消除设备序列偏差。
反例:"改完代码跑了一下,比之前快了 200ms" —— 没有对照组,无法排除环境差异。
# 2.4 重复性与可复现性
重复性(Repeatability):同一实验者、同一环境,多次运行结果一致。 可复现性(Reproducibility):他人按文档复刻实验,结果一致。
最低要求:
- 单次实验内部,至少重复 30 次取分布。
- 整体实验,至少在 2 台同型号设备上独立验证。
- 关键结论,鼓励他人复刻(开源样例工程)。
# 03.实验设计七步法
# 3.1 第一步:明确命题
写下一句话命题,用 [01.总论] 的"四步归因"反推:
现象:用户反馈滚动列表卡
↓
命题候选:
- H₁ₐ: 图片解码在主线程,导致单帧 > 16ms
- H₁ᵦ: ViewHolder 创建过于频繁
- H₁ᵧ: 业务回调里有同步 IO
↓
选择最可能的一个作为本实验命题
2
3
4
5
6
7
8
一次实验只验证一个命题。多命题混跑等于没跑。
# 3.2 第二步:选择度量指标
参考 [02.指标体系] 选择主指标 + 副指标 + 护栏指标:
| 类型 | 作用 | 示例(首屏卡顿实验) |
|---|---|---|
| 主指标 | 直接验证假设 | 首屏 5s 内丢帧率 |
| 副指标 | 辅助理解机制 | 主线程 on-CPU%、IO Wait |
| 护栏指标 | 防止顾此失彼 | 首屏内存峰值、CPU 总耗时 |
护栏指标的作用:避免"优化了主指标,但其他指标劣化"。
# 3.3 第三步:设计实验环境
环境必须稳定且接近真实:
| 维度 | 推荐设置 |
|---|---|
| 设备 | 至少高 / 中 / 低三档机型 |
| 系统 | 主流版本 + 最低支持版本 |
| 编译 | Release 包(与线上一致) |
| 监控 | 关闭其他采集 SDK,避免互相干扰 |
| 状态 | 充电至 50% 以上、温度 < 35°C、清空后台 |
线下复现真实环境的技巧:
- 模拟弱网:Charles / 系统 Network Link Conditioner
- 模拟内存压力:
ActivityManager.setProcessImportance/memory_pressure_simulator - 模拟低端机:CPU 限频、关闭核心、
ThrottlingOptions
# 3.4 第四步:确定样本量
样本量决定结论的统计力(Statistical Power)。
经验法则(参见 [02.指标体系] 5.3):
| 目标 | 最小样本量 |
|---|---|
| 估计均值 | n ≥ 30 |
| 估计 P95 | n ≥ 1000 |
| 估计 P99 | n ≥ 10000 |
| 显著性检验(中等效应量) | n ≥ 100 / 组 |
正式公式(双样本 t 检验):
n = 2 × (Z_{α/2} + Z_β)² × σ² / Δ²
其中:
Z_{α/2} —— 显著性水平(α=0.05 时为 1.96)
Z_β —— 统计力(β=0.2 即 power=0.8 时为 0.84)
σ —— 指标标准差(先做 pilot 估计)
Δ —— 期望检测的最小差异
2
3
4
5
6
7
不知道 σ?做一次 30 样本的预实验估计一下,再算正式样本量。
# 3.5 第五步:执行采样
采样规范:
- 预热:先跑 N 次丢弃数据(消除冷启动 / JIT 编译影响)。
- 随机化:交替执行对照组与实验组,避免时间漂移(设备温度变化等)影响一组。
- 隔离:每次实验后清理状态(清缓存、重启应用),保证起点一致。
- 记录原始数据:保留每次原始耗时,不要直接保存均值。
典型采样脚本结构:
def run_experiment(group, n_warmup=10, n_sample=100):
samples = []
for _ in range(n_warmup):
run_once(group) # 丢弃
for _ in range(n_sample):
reset_state()
t = run_once(group)
samples.append(t)
return samples
# 交替执行避免时间漂移
results_a, results_b = [], []
for _ in range(rounds):
if random.random() < 0.5:
results_a += run_experiment("A")
results_b += run_experiment("B")
else:
results_b += run_experiment("B")
results_a += run_experiment("A")
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 3.6 第六步:统计分析
必做的四件事:
# A. 描述性统计
组 A: n=1000, mean=18.2ms, P50=17ms, P95=24ms, P99=38ms, σ=4.1
组 B: n=1000, mean=14.5ms, P50=14ms, P95=19ms, P99=29ms, σ=3.5
2
# B. 可视化分布
- 直方图 / 密度曲线:看分布形状(是否双峰、是否长尾)。
- 箱线图:看四分位与离群点。
- CDF 曲线:跨组对比时最直观。
# C. 显著性检验
| 数据特性 | 推荐检验 |
|---|---|
| 样本量大、近似正态 | t 检验 |
| 长尾分布(性能数据常见) | Mann-Whitney U 检验(非参数) |
| 多组对比 | Kruskal-Wallis |
判定:p < 0.05 即拒绝 H₀,认为差异显著。
# D. 效应量
显著 ≠ 重要。还要看效应大小:
Cohen's d = (μ_A - μ_B) / σ_pooled
d ≥ 0.2 小效应
d ≥ 0.5 中等效应
d ≥ 0.8 大效应
2
3
4
5
"显著且效应量大"才值得上线。"显著但效应量极小"是统计噪声。
# 3.7 第七步:边界探查
任何结论都有适用边界。明确以下问题:
- 在哪些条件下结论不再成立?(机型 / 数据规模 / 网络条件)
- 是否存在反例场景?
- 是否会与其他优化冲突?(如预解码可能与内存压力冲突)
记录到结论的"适用边界"章节。没有边界声明的结论就是错的。
# 04.基准测试(Benchmark)的陷阱
线下做微基准(micro-benchmark)时,以下陷阱极易出错:
# 4.1 编译器优化导致的"代码消失"
// 想测试 expensiveCalc 耗时
long start = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) {
expensiveCalc(i);
}
long cost = System.nanoTime() - start;
2
3
4
5
6
如果 expensiveCalc 的返回值未被使用,编译器(JIT / R8 / LLVM)可能完全消除调用,测出几乎 0ms。
对策:
- 使用专业框架:JMH(Java)、XCTest measure(iOS)、Benchmark.js(Web)
- 强制使用返回值(
Blackhole.consume(result)) - 关闭可能影响测量的优化等级
# 4.2 缓存预热与冷启动
第一次调用慢(JIT 未编译、Cache Miss、Page 未加载),后续快。
对策:
- 区分冷态和热态测试,分别上报。
- 预热样本(warmup)丢弃。
# 4.3 测量本身扰动被测对象
Heisenberg 效应:
System.nanoTime()调用本身有开销。- 频繁打日志拖慢被测路径。
- Profiler 插桩使代码慢 5-10 倍(但比例可能改变结论)。
对策:
- 测量调用尽量稀疏,外层包大循环平摊开销。
- 使用低开销采样(如 perfetto sampling profiler)。
- 测量"绝对值"用低开销工具,测量"相对差异"才用 profiler。
# 4.4 设备状态干扰
- 移动设备温度上升触发降频(CPU 时钟降低 30%+)。
- 后台进程抢占 CPU / IO。
- 电池低于 20% 触发省电模式。
对策:
- 实验前检查温度、电量、后台进程。
- 单次实验时长 < 10 分钟,避免温度漂移。
- 同设备多组实验间隔休息(降温)。
# 05.线上 A/B 实验
线下基准能验证"机制层"的因果,但最终需要线上 A/B 验证用户层效果。
# 5.1 与线下基准的差异
| 维度 | 线下基准 | 线上 A/B |
|---|---|---|
| 控制度 | 高(可控制变量) | 低(环境多样) |
| 真实性 | 低(数据集理想) | 高(真实用户) |
| 样本量 | 千级 | 万级到亿级 |
| 反馈速度 | 分钟级 | 天级 |
| 关注 | 机制是否成立 | 业务指标是否真改善 |
最佳实践:先线下验证机制成立,再线上验证业务有效。
# 5.2 流量切分与样本平衡
- 随机切分:基于稳定 hash(用户 ID)保证用户在各组的稳定性。
- 同质性检查(A/A 实验):上线前先做 A/A 实验,确认两组在没有变更时也无显著差异。若有差异,说明分流或采集存在偏差。
- 切片对齐:实验组与对照组在机型、地域、版本等维度上的分布要一致。
# 5.3 显著性检验
线上由于样本量极大,几乎任何差异都会"显著",因此重点不是 p 值,而是:
- 置信区间:差值的 95% CI 是否完全在期望方向上。
- 效应量:差异是否值得(业务上有意义)。
- 持续时长:实验跑够 1 个完整业务周期(至少 7 天,覆盖工作日 / 周末)。
# 5.4 副作用监控
性能优化常见的"按下葫芦浮起瓢":
| 优化目标 | 可能的副作用 |
|---|---|
| 启动时长 ↓ | 内存峰值 ↑、首屏崩溃 ↑(资源加载竞争) |
| 内存占用 ↓ | CPU ↑(更频繁 GC、解码)、卡顿 ↑ |
| 包体积 ↓ | 启动 / 首屏耗时 ↑(动态加载) |
| 网络流量 ↓ | CPU ↑(解压)、首屏耗时 ↑ |
护栏指标必须配齐:上线前明确"哪些指标劣化超过 X% 即回滚"。
# 06.求证案例完整示范
下面用一个真实风格的案例演示完整流程。
# 6.1 命题:图片预解码是否真的能减少首屏卡顿
背景:列表页首屏存在 12 张图片,发现首屏 P95 帧时长 32ms,疑似图片解码占主线程。
初步假设:
H₁: 在主线程外预解码图片,首屏 P95 帧时长降低 ≥ 30%
H₀: 预解码对首屏 P95 帧时长无显著影响
2
# 6.2 实验流程演示
# Step 1 - 度量基线
| 指标类型 | 指标 | 当前值 |
|---|---|---|
| 主指标 | 首屏 5s 内 P95 帧时长 | 32ms |
| 副指标 | 主线程 on-CPU% | 78% |
| 副指标 | 解码耗时占帧时长比 | 41% |
| 护栏指标 | 首屏内存峰值 | 86MB |
| 护栏指标 | 总解码 CPU 耗时 | 920ms |
# Step 2 - 设计实验
| 项 | 配置 |
|---|---|
| 设备 | A 机:旗舰;B 机:中端;C 机:低端,各 2 台 |
| 系统 | Android 12 / 14;iOS 16 / 17 |
| 编译 | Release,相同混淆等级 |
| 数据集 | 固定 12 张图片(300x400,JPG),首次进入清缓存 |
| 控制变量 | 充电、35°C 以下、关闭其他 App |
| 样本量 | 每机型 / 每组 100 次,共 600 样本 |
# Step 3 - 采样
对照组(主线程解码):旗舰 100 / 中端 100 / 低端 100
实验组(IO 线程预解码):旗舰 100 / 中端 100 / 低端 100
交替执行,每组之间间隔 10 秒等待降温。
2
3
# Step 4 - 数据呈现
总体(n=600):
对照组 P95 帧时长:32.4ms ±1.1ms
实验组 P95 帧时长:18.7ms ±0.9ms
改善幅度:-42.3%
Mann-Whitney U 检验 p < 0.001(显著)
Cohen's d = 1.34(大效应)
按机型切片:
旗舰:32 → 14 ms (-56%)
中端:33 → 19 ms (-42%)
低端:32 → 22 ms (-31%) ← 改善最小,因 IO 线程也慢
2
3
4
5
6
7
8
9
10
11
# Step 5 - 护栏检查
| 指标 | 对照组 | 实验组 | 变化 | 是否可接受 |
|---|---|---|---|---|
| 内存峰值 | 86MB | 112MB | +30% | ⚠️ 低端机风险 |
| 总解码 CPU 耗时 | 920ms | 950ms | +3% | ✅ |
| 首屏崩溃率 | 0.02% | 0.04% | +100% 相对 | ⚠️ 需排查 |
# Step 6 - 边界探查
✅ 在 ≥ 4GB 内存设备上,结论稳定成立
⚠️ 在 2GB 内存机型上,预解码导致内存压力,反而触发更多 GC,反向劣化
❌ 在 1GB 机型上,可能触发 OOM
2
3
# Step 7 - 结论
对外结论:
"在内存 ≥ 4GB 的 Android / iOS 设备上,将首屏 12 张图片改为
IO 线程预解码,可使首屏 P95 帧时长降低 40%-56%(95% CI),
代价是内存峰值 +30%。
不适用于 ≤ 2GB 机型,需通过 Build.MODEL 黑名单兜底。"
2
3
4
5
# 6.3 数据呈现规范
实验结论必须包含以下结构(任何环节缺失都视为不合格):
- ✅ 假设 H₁ / H₀
- ✅ 实验环境(机型 / 系统 / 编译 / 数据集)
- ✅ 样本量与重复次数
- ✅ 主指标 + 副指标 + 护栏指标 的前后数据(带 P50/P95/P99 + 置信区间)
- ✅ 显著性检验(p 值)+ 效应量(Cohen's d)
- ✅ 适用边界(什么条件下不成立)
- ✅ 上线策略(灰度比例 / 回滚指标)
# 07.求证报告模板
以下模板可直接复制到具体优化文档中:
## 求证报告:[变更名称]
### 一、命题
- H₁:[5 要素假设]
- H₀:[零假设]
### 二、实验设计
| 项 | 值 |
|---|---|
| 设备 | |
| 系统版本 | |
| 编译配置 | |
| 数据集 | |
| 控制变量 | |
| 样本量 | |
| 重复次数 | |
### 三、指标
- 主指标:
- 副指标:
- 护栏指标:
### 四、原始数据
(直方图 / CDF 图 / 数据表)
### 五、统计分析
- 描述性统计(mean / P50 / P95 / P99 / σ)
- 显著性检验:(方法 + p 值)
- 效应量:(Cohen's d)
### 六、护栏检查
| 护栏指标 | 阈值 | 实测 | 是否通过 |
|---|---|---|---|
### 七、边界
- 适用:
- 不适用:
### 八、结论与上线建议
- 结论:
- 上线策略:
- 回滚指标:
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 一句话总结
没有实验的优化是巫术,没有边界的结论是谎言。
性能工程师的核心能力,不是"想到办法",而是**"证明办法"**。