基准测试:把变快变成可信实验 / Benchmarking as Evidence
性能优化的第一步不是打开 Profiler,而是让“慢”变成可重复观察的实验。
如果基准不可信,后面的火焰图、计数器和优化结论都会跟着漂。你以为自己优化了代码,实际可能只是输入变小、缓存变热、后台进程停了,或者编译器把没有被使用的计算消掉了。
1. 什么是基准测试
基准测试是用固定工作负载和固定口径衡量系统表现。
它不只是计时。
它要回答:
- 测的是哪个用户路径;
- 输入规模和数据分布是什么;
- 运行环境是否记录清楚;
- 结果是否正确;
- 波动范围有多大;
- 新版本是否真的超过噪声。
一个好的基准像一把尺子。尺子本身如果会伸缩,就不能拿来判断代码长短。
2. 结果指标和诊断指标
结果指标直接对应目标。
诊断指标解释原因。
结果指标
+-- 用户等待了多久
+-- 每秒完成多少任务
+-- 峰值内存多少
+-- 单个任务成本多少
诊断指标
+-- CPU 利用率
+-- Cache miss
+-- 锁等待
+-- I/O 队列
+-- GPU Occupancy结果指标决定优化是否成功。
诊断指标帮助判断下一步该查哪里。
例如 CPU 利用率从 40% 到 90% 不一定是好事。如果吞吐没变、延迟变差,它可能只是忙等或无效重算。反过来 CPU 利用率下降也不一定是坏事,如果吞吐相同且成本下降,可能正是优化成果。
3. 工作负载决定结论边界
工作负载是基准里最容易被低估的部分。
微基准可以研究一个函数、一种数据结构、一段循环。
组件基准可以研究解析器、求解器、渲染器、数据库访问层。
端到端基准可以研究真实用户路径。
微基准
-> 机制清楚,容易复现,但可能脱离真实调用方式
组件基准
-> 保留模块边界,适合工程优化
端到端基准
-> 最接近用户价值,但归因更难同一个项目至少保留三类输入:
- 小输入:用于快速回归;
- 典型输入:代表常见真实场景;
- 压力输入:暴露内存、I/O、并发或算法极限。
对工程软件、CAE、可视化、编译器、数据导入这类项目,还要记录输入文件的校验和。否则“同名文件”后来被改过,历史性能数据就失去意义。
4. 冷启动和稳态不能混在一起
冷启动包含首次加载、动态库初始化、页面缓存、JIT、GPU Context、Shader 编译、连接建立等成本。
稳态运行更多反映核心路径的持续性能。
冷启动
= 进程启动
+ 动态库加载
+ 初始化
+ 首次打开资源
+ 第一次有用结果
稳态
= 已初始化系统
+ 重复执行核心工作
+ 输出结果如果用户抱怨“第一次打开项目慢”,你不能预热后只测稳态。
如果目标是长时间求解吞吐,你也不能把一次性初始化混进每一步迭代。
报告时把两者分开写,别用一个平均值糊过去。
5. 噪声与样本
性能数据有噪声。
噪声来自 CPU 动态频率、温度、后台进程、操作系统调度、磁盘缓存、网络状态、虚拟化、内存布局、地址随机化、分支预测历史和输入随机性。
最低限度要多次运行。
baseline: 101 ms, 105 ms, 99 ms, 104 ms, 100 ms
candidate: 96 ms, 98 ms, 97 ms, 99 ms, 96 ms这种结果比“某一次从 101 ms 到 96 ms”更可信。
如果基线自然波动有 8%,候选版本快 3%,结论应该是证据不足。工程上可以继续观察,但不要宣布优化成功。
6. A/B 交错运行
长时间测试容易遇到环境漂移。
例如机器温度升高、后台索引开始、远端服务变慢。此时先跑完 A 再跑 B,可能把时间漂移误判成版本差异。
更稳妥的方法是交错:
A B A B A B A B每轮都记录原始样本。
如果结果对环境很敏感,可以在报告中说明“本实验只能证明这个机器和这个负载下的差异”。
7. 正确性护栏
基准不能奖励错误行为。
常见虚假优化包括:
- 编译器消除了未使用结果;
- 跳过输入校验;
- 缓存了本该重新计算的数据;
- 降低浮点精度或收敛标准;
- 少写日志但破坏审计;
- 把工作延迟到基准计时之后;
- 丢弃慢请求;
- 峰值内存暴涨但只看时间。
C++ 微基准里尤其要避免死代码消除。
Google Benchmark 通常这样写:
#include <benchmark/benchmark.h>
#include <vector>
static void BM_sum(benchmark::State& state) {
std::vector<int> data(state.range(0), 1);
for (auto _ : state) {
long long sum = 0;
for (int value : data) {
sum += value;
}
benchmark::DoNotOptimize(sum);
}
}
BENCHMARK(BM_sum)->Arg(1024)->Arg(1024 * 1024);
BENCHMARK_MAIN();benchmark::DoNotOptimize 告诉编译器这个结果对观察者有意义,不能简单删除。
但它不等于正确性测试。真实项目还要检查输出、误差、校验和、异常路径和资源释放。
8. 编译模式要诚实
性能分析通常应使用 Release 构建,并保留符号。
Debug 构建会改变内联、优化、断言、迭代器检查、运行库和内存布局。它适合调试正确性,不适合推断最终性能。
推荐形态是:
Release 优化
+ 调试符号
+ 可复现构建信息
+ 关闭不属于生产的诊断开关在 CMake 中,常见选择是 RelWithDebInfo。
如果开启 Sanitizer、日志、统计计数、Trace 宏,也要在报告里写清楚。它们可能改变性能。
9. 基准记录模板
一条可复查的基准记录可以这样写:
目标:
大模型导入 P95 低于 12 s。
工作负载:
case-large-01.bdf,5.2 GB,冷启动,校验和 0x...
环境:
Windows 11,i9-13900K,64 GB,NVMe SSD,电源高性能。
构建:
commit abc123,MSVC 19.xx,RelWithDebInfo,LTO off。
步骤:
每轮重启进程,运行 15 次,丢弃首次失败样本但保留记录。
正确性:
节点、单元、材料、属性数量一致;错误日志一致。
结果:
baseline median 14.8 s, P95 18.4 s
candidate median 11.1 s, P95 12.7 s
结论:
P95 接近目标但峰值内存增加 420 MB,需要进入下一轮内存分析。这个模板让别人能复现,也能反驳。
10. 微基准什么时候会骗人
微基准最大的问题是上下文太干净。
真实程序里,一个函数可能被不同输入、不同线程、不同缓存状态、不同分配器、不同调用频率包围。微基准单独跑得快,不代表端到端变快。
它常见的误导包括:
- 数据一直在 L1/L2 Cache,真实工作集却远大于 Cache;
- 分支模式过于规律;
- 分配器没有并发压力;
- 编译器看到更多常量;
- 没有系统调用和 I/O;
- 没有锁竞争;
- 没有 NUMA 远端访问;
- 没有真实错误路径。
微基准的正确用法是验证机制。
例如“连续数组比链表遍历更容易被缓存和预取器利用”,微基准很合适。要证明产品导入变快,还要回到组件基准和端到端基准。
11. 什么时候进入 Profiler
当基准已经能稳定复现问题,就进入归因阶段。
基准稳定
|
+-- 不知道慢在哪:系统追踪
|
+-- CPU 忙:采样 Profiler
|
+-- 热点原因不明:硬件计数器
|
+-- 线程在等:Off-CPU / 锁 / I/O
|
+-- GPU 相关:Nsight Systems -> Nsight Compute如果你还不知道慢在哪个阶段,先用系统级时间线。
如果你知道 CPU 正在忙,使用 CPU 采样。
如果热点明确但原因不明,使用硬件计数器。
如果线程空洞很多,查调度、锁、I/O 和 Off-CPU。
如果 GPU 空洞很多,先查 CPU/GPU 流水线,再查单个 Kernel。
12. 本篇总结
基准测试的核心不是“测一次时间”,而是让性能结论具有证据资格。
你要固定工作负载,区分冷启动和稳态,保存原始样本,记录环境,校验正确性,并承认噪声边界。
只有这样,后面的 WPR、WPA、VTune、perf、Nsight 才知道该解释什么。