> 如果 ns/op 随 -benchtime 变大,那它报的不是单次开销
一份测试报告里有这么一行分析:
结论先行¶
一份测试报告里有这么一行分析:
带驱逐的 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 线程),跑完已删除,仓库未改动。