从 Go 移植 BadgerDB 到仓颉(三)
重跑一遍基准,三个结论都翻了
Cangjie · 基准测试 · badger-cj
这是系列第三篇。前两篇讲实现 与优化 ,
本篇讲复核:把积累半年的基准数据重跑一遍之后,有三条结论被推翻,
而且方向都是反的。
一、为什么值得重跑一遍
起因很朴素:要给一篇技术文章配数据。
手上有一份积累了半年的 benchmark-report.md,里面有版本演进表、有"比 Go 快/慢多少倍"的结论、有精确到个位数的 ns/op。看起来挺完整。
但如果这些数字要给别人拿去做技术选型判断,那就得先回答一个问题:它们经得起复现吗?
重跑之后,答案是:一半经不起。而且失败的方式比"数值有偏差"严重得多——有三条结论是完全错的,方向都是反的。
这一篇记录三件事:错在哪、为什么会错、以及一套能核验的流程长什么样。
二、三个错误结论
2.1 「并发写比 Go 慢 1.3 倍」——拿两组不同规模的数字相除
原报告里有一组对比:
| Go Badger v4 | badger-cj | |
|---|---|---|
| 并发写入 | 252.8 ns/op | 330 ns/op |
相除得 1.3 倍。看起来是个挺体面的成绩——移植版只比原版慢 30%。
但这个比值根本不成立,因为两边的测量规模差了约 1300 倍:
- Go 侧那个 252.8 ns/op 取自默认的
-benchtime=1s,跑了约 640 万次迭代; - 仓颉侧那个 330 ns/op 取自
skiplist_perf的 5000 次操作。
而 Go 的 skiplist 基准有个特性:arena 随迭代次数线性增长(NewSkiplist(int64((b.N+1) * MaxNodeSize)))。这意味着同一个二进制、同一台机器,光改规模就能让读数变化 50%:
Go BenchmarkWrite 规模 |
ns/op |
|---|---|
-benchtime=5000x |
148 |
-benchtime=50000x |
154 |
-benchtime=500000x |
177 |
-benchtime=5000000x |
227 |
| 默认 1s(约 640 万次) | 232 |
把两边对齐到同一个 N=5000 之后,才是可比的:
| 场景 | Go Badger v4.9.1 | badger-cj v1.6.21 | 比值 |
|---|---|---|---|
| 纯并发写(N=5000, 16 线程) | 149.9 ns/op(±2%) | 387 ns/op(±40%) | 约 2.6x |
并发写入实际比 Go 慢约 2.6 倍——不是 1.3 倍。差距比原来写的更大,但这次它站得住:两侧同机、同 N、同线程数。
顺带说仓颉侧那个 ±40%:
skiplist_perf全部 4 项总共只要约 64 毫秒,
负载太小,点值本身不稳定(30 轮范围 352–506 ns/op)。
所以"2.6 倍"应该读作量级判断,而不是精确比值。
教训:比值是两个数字相除,但这两人必须同量纲。跨语言对照尤其危险——两边基准的默认规模、内存策略、预热方式往往完全不同,而默认值通常是"跑得快"而不是"可比较"。
2.2 「FFI memcmp 让 key 比较快 30%」——结论是反的
跳表的每次查找都要比较 key。当初的想法很自然:长 key 交给 C 的 memcmp(通常有 SIMD 优化),应该比仓颉逐字节循环快。于是加了按长度分流:
// src/skiplist/node.cj
if (minLen < 64) {
// 短 key:逐字节
compareBytes(buf, Int64(keyOffset),
other, minLen)
} else {
// 长 key:FFI + SIMD
fastMemcmp(buf, Int64(keyOffset),
other, 0, minLen)
}
用项目自带的 key_compare_perf(100 万次/档)实测,结果是相反的:
| key 大小 | 逐字节 | FFI memcmp |
|---|---|---|
| 16 B | 4 ns/op | 72 ns/op |
| 64 B | 4 ns/op | 72 ns/op |
| 256 B | 4 ns/op | 72 ns/op |
| 1024 B | 4 ns/op | 72 ns/op |
逐字节反而快约 18 倍,而且 FFI 那侧的耗时与 key 长度完全无关——这说明瓶颈根本不在比较本身,而在跨语言调用的固定开销(约 70 纳秒):acquireArrayRawData / releaseArrayRawData 还要把数组底层指针暴露给 C。
那逐字节为什么只要 4 纳秒?因为基准用的是随机 key,头几个字节就分出胜负,循环提前退出——根本没走到"长 key 逐字节扫完"的最坏情况。
所以正确的结论既不是"FFI 更快"也不是"FFI 更慢":
memcmp的调用开销约 70ns,只有当"逐字节需要扫很多字节"时才能赚回来。
对共享长前缀的 key(LSM-Tree 里很常见——同一前缀的连续 key),
逐字节要扫完整个公共前缀才分胜负,这时 FFI 才可能划算;
而随机 key 场景下逐字节提前退出,FFI 纯属倒贴。
minLen < 64 这个阈值就是基于该权衡设的。但必须承认:key_compare_perf 的随机 key 不足以验证长前缀场景,这个阈值目前仍是一个未经验证的启发式。
教训:基准的输入数据分布决定了它测的是什么。随机 key 测的是"平均情况",而 LSM-Tree 的真实负载恰恰是共享前缀——这属于"用错误的输入测对了代码"。
2.3 「io_uring 并不总是更快」——方向对,但结论说过头
原报告用小文件基准得出 io_uring 更慢(0.66x),并下了"并不总是更快"的判断。
重测发现:方向是对的,但结论说过了头。
小文件(4MB)批量写,同一进程内交替对照,5 轮:
| 文件数 | Sync | io_uring | 加速比 |
|---|---|---|---|
| 4 | 1 毫秒 | 6 毫秒 | 0.16x |
| 8 | 2 毫秒 | 11 毫秒 | 0.18x |
| 16 | 5 毫秒 | 23 毫秒 | 0.21x |
| 32 | 9 毫秒 | 46 毫秒 | 0.19x |
小文件场景 io_uring 慢 2–6 倍(原来的 0.66x 还偏乐观了)。
但换成大写入量 / 高并发,方向就反过来了:
| 场景 | MemTable | 写入量 | Speedup |
|---|---|---|---|
| 集成 | 2 MB | 100K | 1.6 |
| 集成 | 64 MB | 500K | 1.6 |
| 批量 | 1 MB / 16 线程 | 200K | 1.3 |
| 集成 | 1 MB | 50K | 0.71 |
修正后的完整表述:
io_uring 的收益取决于 workload——
小文件、低并发时慢 2–6 倍(提交/完成开销占主导);
大写入量、多 imm 表并发 flush 时可快 1.1–1.6 倍。
且失败时会自动降级到同步写。
⚠️ 上表所有倍数的离散度在 ±26%~±102% 之间,所以只能用来判断哪个方向更快,不适合引用具体数值。
教训:拿一个场景的结果去下普适结论,是基准报告里最常见的错误。结论必须带上它的适用边界——“在什么条件下成立"和"结论本身"一样重要。
结语
三个错误结论有个共同点:它们看起来都很具体、很可信。
- “1.3 倍"是两个数字相除,算术没错;
- “FFI 快 30%“来自一次真实的优化尝试;
- “io_uring 0.66x"是一次真实测量。
错的不是测量,是方法——规模没对齐、输入分布不匹配、结论超出了证据边界。而这些错误不会表现为数值偏差,会表现为方向性的错误结论。
下一篇接着往下挖:四个会给出错误结论的方法学缺陷、Go 侧基准自身的三个陷阱,
以及一套能核验的流程长什么样。
相关链接
- 项目地址:atomgit.com/ystyle/badger-cj
- 中心仓包:pkg.cangjie-lang.cn/packages/badgercj
- 参考实现:Go BadgerDB v4
- 本系列:
- 第一篇:没有裸指针怎么做无锁跳表
- 第二篇:十次优化与一次误判
- 第三篇:重跑基准,三个结论翻了
- 第四篇:怎样测才算数