一个团队用 benchmark 比较"两种 JSON 序列化",ns/op 差了 3 倍。结论写进文档——半年后有人复测,发现"差 1.2 倍"。原因是:那次基准的输入太小,于是"测的是"函数调用与分配",而不是"序列化的差异";而"小输入"恰好让"编译器内联"成了主角。
Go 的 benchmark 很容易"跑出一个数",而"这个数在测什么"是另一件事。四个常见偏差:输入太小(测到开销)、结果被优化掉(测到空循环)、并行度错(测到单核)、分配被复用(测到池).
1. "输入规模"决定"测到什么"
| 规模 | 主要测到 |
|---|---|
| 极小(几字节) | 调用开销、内联效果 |
| 中等(KB 级) | 逻辑与分配 |
| 大(MB 级) | 内存带宽、GC |
第一项是"最常见偏差":"比较两个库"时输入 10 字节第一项的"后果"是"结论不可迁移":"10 字节下 A 快 3 倍",在"10KB 下可能只差 1.2 倍"——**而文档里写的是"3 倍".
——测到的是"包一层"的成本,而不是"库的能力".
2. "结果被优化掉"的三种情形
| 情形 | 表现 |
|---|---|
| 结果未被使用 | 编译器删除整个调用 |
| 循环体内联后被常量折叠 | ns/op 小得离谱 |
| 逃逸分析成功 | 0 分配(与真实场景不符) |
第一项的"隐蔽"在于"代码看起来在跑":_ = f(x) 只保证"求值一次",而"循环里每次"的结果没人要,编译器可删.
第一项是"经典坑":b.N 次调用但结果丢给 _——_ = f(x) 仍可能被优化;正确做法是"赋给包级变量"或 b.Fatal 用一下.
// 反例:结果被丢弃,可能整个调用被优化掉
for i := 0; i < b.N; i++ { json.Marshal(v) }
// 正解:结果落到包级变量(或 sink),防止被删除
var sink []byte
for i := 0; i < b.N; i++ { sink, _ = json.Marshal(v) }
"并行基准"的写法有个模板,缺一样就"测不准":
// RunParallel + 真实规模的输入
func BenchmarkMarshalParallel(b *testing.B) {
v := makePayload(10 << 10) // 10KB,不是 10 字节
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() { sink, _ = json.Marshal(v) }
})
}
// 跑法:go test -bench=BenchmarkMarshalParallel -cpu=1,4,8 -benchmem -count=5
3. "并行度"的三种写法
| 写法 | 测什么 |
|---|---|
普通 for |
单核单线程 |
b.RunParallel |
多核 + 竞争 |
-cpu=1,4 |
不同并行度对比 |
第二项在"有锁/有共享状态"时是必需的:单线程基准"看不到锁竞争"第二项的"必要性"很直接:"库 A 单线程快 3 倍、并发下反而慢 30%" 是完全可能的——因为"它用了全局锁".
,于是"库 A 快 3 倍"可能在并发下"反而更慢".
4. "分配"的三个观察
| 观察 | 手段 |
|---|---|
| 分配次数 | -benchmem 的 allocs/op |
| 分配字节 | B/op |
| 逃逸行为 | -gcflags=-m |
第三项的"检验"很便宜:go build -gcflags=-m 看"moved to heap"——而"基准里 0 分配、线上 GC 压力大"的矛盾,就来自"逃逸分析的场景差异".
第三项能解释"为什么 0 分配":如果对象"逃逸到栈上",就不会堆分配——而"真实服务"里对象常常"逃逸到堆".
"输入规模"要"跟着真实分布",而不是"只有一个值":
// 反例:只测"理想样本"
v := []byte(`{"a":1}`) // 10 字节
// 正解:用"真实分布"的样本集(含长字段、嵌套、中文)
samples := loadSamples("fixtures/*.json") // 平均 8KB,P99 60KB
for _, s := range samples { /* 分别跑,报加权平均 */ }
// 判据:报"平均规模"与"P99 规模",而不是"一个数字"
5. "基准可信"的三个前提
| 前提 | 做法 |
|---|---|
| 多次运行取中位 | -count=5 + 中位 |
| 固定输入(不随机) | 预生成,别在循环里构造 |
| 丢弃"准备阶段" | b.ResetTimer() |
第三项很实用:在"构造输入"之后调 b.ResetTimer()第三项的"错误后果"很具体:"构造 10MB 输入"要 50ms,而 b.N=100——于是"每次操作"被算成"50ms + 真实耗时",数字全错.
——否则"准备时间"被算进"每次操作".
"环境抖动"要"用统计量"处理,而不是"取一次最好成绩":
// 三种"取数"方式(从差到好)
1 单次运行 → 噪声全收
2 取最好一次 → 乐观偏差("最好一次"不是"典型")
3 -count=5 + 中位数 → 推荐
// 判据:报"中位 + 区间",而不是"一个最优值"
6. 排错:五种"基准问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 数字好得离谱 | 结果被优化掉 | 用 sink |
| 两库差 3 倍(复测 1.2 倍) | 输入太小 | 用真实规模 |
| 单线程快、线上慢 | 锁竞争 | RunParallel |
| 0 分配 | 逃逸成功 | 看真实场景 |
| 每次跑都不同 | 环境抖动 | -count + 中位 |
"微基准"与"端到端压测"的分工,值得写清:
// 两种"测"的适用
微基准:比"实现细节"(算法、分配、内联)——快、便宜、易误导
端到端:比"真实链路"(网络、DB、缓存)——慢、贵、可信
// 判据:微基准"定方向",端到端"定结论"
7. 边界:什么情况"微基准"不必较真
| 场景 | 为什么 | 做法 |
|---|---|---|
| 差异 > 5 倍 | 量级碾压细节 | 一次就够 |
| 只判断"方向" | 不需精确 | -count=3 |
| 已被"端到端压测"覆盖 | 微基准只是参考 | 以压测为准 |
| 一次性比较(选型前) | 结论会被复测 | 记清输入规模 |
最后一条与"协作"有关的边界:"基准结论"要写成"可复测的三行"——输入规模、并行度、-count——否则"半年后复测"只能得到"另一个数",然后争论"哪个对".
判断口径一句话:"这个基准的输入,与我线上真实的输入,差几个数量级"**。差得多(10 字节 vs 10KB),它测的是"另一件事";差不多,它才有参考价值——而"报数字时把输入规模写上",是让结论可复测的最低要求.
再补一个"最容易忽略"的点:"基准里的 b.N 不是你能控制的"——框架会"自动调整 N 让测试跑 1 秒左右",于是"你的初始化代码"如果"每次迭代都跑"(写在循环里),它会被"放大 N 次"。正确做法是"循环外准备、ResetTimer 之后只留被测逻辑"。这一条能解释"为什么有的基准"数字随"机器快慢"剧烈变化:快的机器 N 更大,于是"循环内的准备代码"被执行更多次,而"它本不该计入".
还有一个"跨版本"的坑:"换了 Go 版本,基准数字变了 15%"不一定是"你的代码变了"——编译器与运行时都在变(内联策略、GC、调度器)。所以"基准结论"要"标版本":go1.21 vs go1.22 的对比,和"同一版本内的两库对比"是两件不同的事。而"选型"时真正要问的是"在我的版本、我的输入规模下,谁更合适",而不是"谁在论文里更快".
最后一个"报告"建议:把"输入规模 + 并行度 + 版本 + count"四行写在结论旁边——它们是"结论的边界",而不是"脚注";而"缺了它们"的结论,会在"下一次复测"里变成争论。
而"写在旁边",只需要多打一行字。
8. 自测
- "输入规模"如何决定"测到什么"?为什么"10 字节输入"测的是"包一层"的成本?
- "结果被优化掉"的三种情形里,哪一种最常见?正确写法是什么?
- 为什么"单线程基准看不到锁竞争"?什么场景必须
RunParallel? - "0 分配"为什么可能与真实场景不符?
b.ResetTimer()解决什么问题?不调会怎样?- 判断"微基准是否可信"的那个问题是什么?