从 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 侧基准自身的三个陷阱,
以及一套能核验的流程长什么样。


相关链接