2136 字
约 7 分钟
15
S09-A25 · `go test -bench`:为什么你的基准测试在测"编译器"
2024-12-31
2026-11-10

一个团队用 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. 自测

  1. "输入规模"如何决定"测到什么"?为什么"10 字节输入"测的是"包一层"的成本?
  2. "结果被优化掉"的三种情形里,哪一种最常见?正确写法是什么?
  3. 为什么"单线程基准看不到锁竞争"?什么场景必须 RunParallel?
  4. "0 分配"为什么可能与真实场景不符?
  5. b.ResetTimer() 解决什么问题?不调会怎样?
  6. 判断"微基准是否可信"的那个问题是什么?
S09-A25 · `go test -bench`:为什么你的基准测试在测"编译器"
https://www.clxhxhhr.top/posts/6682/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。