> 如果 ns/op 随 -benchtime 变大,那它报的不是单次开销

Systems & Infrastructure Intermediate 2026-10-12 04:23 2026-10-12
#benchmarking #go #performance #measurement #failure-modes

一份测试报告里有这么一行分析:

// TABLE_OF_CONTENTS

结论先行

一份测试报告里有这么一行分析:

带驱逐的 Store 操作(958 ns/op)比普通 Store(1,729 ns/op)更快, 因为驱逐释放内存后减少了 GC 压力。

一个做了更多工作的操作更快,这种结论出现时,第一怀疑对象不是 GC, 是两个基准不可比。去读那两个函数,它们相差两处,而且是一起变的:

Manager_Store Manager_StoreWithEviction
MemoryCapacity 256 MB 1 MB(约 256 条)
token 序列 {i % 10000, i}(2 token) {i}(1 token)

把这两个因子拆开跑 2×2(同机,每格 -benchtime 200000x -count 3,取中位):

1 MB 容量 256 MB 容量
1 token 1,468 ns(= 原 WithEviction) 17,631 ns
2 token 1,589 ns 3,861 ns(= 原 Store)

两个因子不可加:key 形状在大容量下值 4.6 倍,在小容量下只值 8%。 所以连"减法求驱逐的贡献"都做不了。

然后是真正要紧的那一步 —— 把 -benchtime 当自变量扫一遍:

b.N 256 MB / 1 token 256 MB / 2 token 1 MB / 1 token
25,000 1,113 2,001 1,607
50,000 3,936 1,388 2,278
100,000 10,030 3,166 1,555
200,000 18,274 2,921 1,349
400,000 22,324 4,329 1,712

容量受限的那一列是平的(1.3k–2.3k,无趋势)。容量不受限的那一列 单调涨了 20 倍。而且在 b.N=25,000 时它 1,113 ns,比受限的 1,607 ns 更快;在 b.N=400,000 时它慢 13 倍。

同一对基准的比较方向,会随 -benchtime 翻转。

为什么

Manager_Store 没有容量上限,所以 radix 树随 b.N 一直长。节点的 children 是按 token 排好序的切片,插入要 memmove:

n.children = append(n.children, childEntry{})
copy(n.children[lo+1:], n.children[lo:])
n.children[lo] = childEntry{token: token, node: child}

1 token 的序列让每个 i 都成为根的直接子节点,于是根的 children 长到 b.N, 每次插入平均 memmove 一半。这是 O(n) 的单次开销 —— 报出来的 ns/op 自然 随 b.N 线性涨。2 token 的 {i % 10000, i} 把根限制在 10,000 宽,所以同样 不受限,但涨得慢得多。

于是那张表里的 1,729 ns 并不是 Store 的单次开销,它是 Go 在那台机器上恰好选了那个 b.N 时的平均值。换一台机器、换一次 -benchtime,数字就不一样 —— 而围绕它写的 GC 解释完全没有被这些数据支持。

go test -bench 的 b.N 是自动放大到够跑满一段时间为止的。这意味着: 任何带不受限状态的基准,它的 ns/op 都是一个关于机器速度的函数,而不是关于代码的。

真正的机制也不是 GC。受限(1,468 ns)比不受限(17,631 ns)快 12 倍,差距来自 根节点那条 20 万元素的切片,不来自垃圾回收 —— 容量上限顺带把树剪小了而已。 驱逐本身是代价,不是优化。

怎么用

一条两行的自检:任何基准都跑两个 -benchtime,差 4 倍以上就行。

go test -bench X -benchtime 50000x
go test -bench X -benchtime 400000x   # ns/op 动了 → 这个数不是单次开销

ns/op 跟着动,说明基准里有随 b.N 累积的状态。要么在循环里把状态清掉 (b.StopTimer() / 重建),要么给它一个上限,要么把这个数标注成 "在 b.N=N 下的均值"而不是"单次开销"。Rust 的 criterion、Python 的 pytest-benchmark 同理:凡是迭代次数由框架决定的,都适用。

成对的基准要用 2×2 验证,不是靠命名。 两个函数名字只差 WithEviction,不代表它们只差驱逐。差异数 ≥ 2 时,报告里任何 "所以 X 更快是因为 Y" 都是在一个没有做控制的比较上编故事 —— 见 同一台机器上 6/9 个指标是双峰的——进程启动时二选一,然后整轮不变,那里是同一个毛病的另一面: 数字对,排列方式不对。

反直觉的结果是检查工具的信号,不是写解释的机会。 "做得更多反而更快" 在系统里确实存在(省掉了一次拷贝、命中了更小的工作集),但它要先排除 "两个基准测的不是同一件事",才轮得到解释。

出处

~/Engineering/s3-like-storage:docs/TEST_REPORT.md §5.1 的分析段与 benchmarks/kvcache_bench_test.go:53-121(两个基准的全文), internal/storage/kvcache/radix_tree.go:47-63(有序切片插入的 memmove)。 2×2 与 b.N 扫描是我自己在 2026-10-11 加了一个临时 _test.go 跑出来的(go1.26.0 / Ryzen 9 5950X / 32 线程),跑完已删除,仓库未改动。