从 Go 移植 BadgerDB 到仓颉(四)

四个方法学缺陷、Go 侧三个陷阱,与一套可核验的流程

Cangjie · 基准测试 · 方法论

这是系列最后一篇。第三篇讲了三个被推翻的结论 ,
本篇追问:它们为什么会错,以及怎样才能不再错。


一、四个方法学缺陷

上面三个错误结论不是偶然。复盘下来,它们背后有四个系统性缺陷——不是"精度不够",而是会直接给出错误结论。

缺陷 1:数据目录落在 tmpfs 上,I/O 成绩虚高一个数量级

所有 src/examples/* 基准都把数据目录硬编码成了 /tmp/...:

$ grep -rn '"/tmp' src/examples/ | wc -l
9

而 /tmp 通常是 tmpfs——读写全在内存里,没有磁盘语义:

$ findmnt -no FSTYPE /tmp
tmpfs

实测后果:sstable_write_perf 在 tmpfs 上算出 sync 吞吐 4021 MB/s——这是内存速度,不是磁盘速度。报告里那些毫秒级的 SSTable 写入数字,在 tmpfs 上根本没有意义。

修法:不改被测源码(保持二进制与发布 revision 一致),而是用私有 mount namespace 重定向:

unshare -rm bash -c \
  "mount --bind <真实盘目录> /tmp \
   && exec <基准命令>"

⚠️ 这里有个连带坑:cjvs env 给出的 CANGJIE_HOME 位于 /tmp/cjvs_multishells/...
(只是指向 ~/.config/cjvs/store/<ver> 的软链)。一旦把 /tmp bind 掉,软链被遮住 →
进程报 libcangjie-runtime.so 找不到(exit 127)。
所以 bind 场景下必须同时把 LD_LIBRARY_PATH 指到 store 的真实路径。

缺陷 2:负载太小,单次测量没有统计意义

skiplist_perf 全部 4 项总耗时仅约 64 毫秒(5000 ops / 16 线程)。单次运行的数字在 15 轮复测里的波动:

指标 15 轮实测范围 相对离散度
并发写 341 – 468 ns/op ±32%
并发读 288 – 380 ns/op ±24%

也就是说,单跑一次得到的"330 ns/op"和"380 ns/op"没有区别,都是噪声。历史报告里那些精确到个位数的 ns/op,精度是虚假的。

修法:每项连跑 N 轮(默认 5),报中位数 + 离散度;离散度 > 15% 的项自动标 ⚠️波动大——这类项不足以支撑结论,只宜作定性描述。

缺陷 3:环境指纹缺失

没有 CPU、内核、编译器版本、commit 的记录,数字既不能复核也不能横向比较。现在报告头固定包含:

测试日期 / 被测版本(commit)
CPU 型号与核数 / 内存 / 内核版本
仓颉编译器版本与优化级别 / 堆大小
数据目录文件系统 / 轮次

缺陷 4:跨语言对照的规模不对齐

就是第三篇 §2.1 讲的那个问题。修法很直接:对照双方必须显式对齐规模、线程数与内存策略,并把不对齐的偏差记录下来(而不是假装没有)。



二、Go 侧还有三个陷阱

重测 Go 对照时,又发现 Go 自己的基准也有坑。这一节可能比结论本身更有用。

陷阱 1:frac_10 读的是一张永久空表

Go 的 skl/skl_test.go 里,NewSkiplist(...) 位于 b.Run 闭包内部——每个 frac_* 子基准各建一张新空表。

而 frac_10 的 readFrac = 1.0,只 Get、从不 Put → 表永远为空 → 命中率 0。同一次运行实测:

子基准 数据状态 ns/op
frac_9(90% 读 + 10% 写) 有真实数据 55.6
frac_10(100% 读) 表为空 10.5 – 17.7

所以原报告里"Go 100% 读 3.7 ns/op"不是一个读基准,而是"在一张空表上走一趟 head 比较"。它不能与仓颉"预写满 5000 条后再并发读"对照。

附带一个坑:单独跑 frac_10 且不加 -benchtime=<N>x 会 OOM 崩溃——
arena 按 (b.N+1) * MaxNodeSize 分配,纯读时 b.N 被外推到约 13.5 亿 → 约 130GB arena。

陷阱 2:key 长度不等价

Go 的 randomKey 先造 8 字节,再由 KeyWithTs 追加 8 字节时间戳 → 实际参与比较的是 16 字节。而仓颉 skiplist_perf 用的是纯 8 字节。key 长度翻倍会影响比较分支数与 cache 占用。这个偏差无法消除,只能显式记录。

陷阱 3:Go 侧语义对齐的基准并不存在

项目 Go v4.9.1 能否对照
纯并发写 BenchmarkWrite ✅ 可用
并发读 只有 frac_10,且是空表 ❌ 不可用
并发事务写 完全没有 ❌ 不给数字

关于第三行:Go 根包 22 个 *_test.go 里只有 BenchmarkDbGrowth(单线程);badger bank test 是正确性不变式测试,其唯一的统计输出被注释掉了(badger/cmd/bank.go:427-431);badger benchmark write 走 OpenManaged 单线程顺序 Set,语义不同。

所以跨语言对照只在"纯并发写"这一项上是干净的,其余要么明确标注不可比,要么不给数字。



三、现在的成绩(可复现)

把不靠谱的数字清掉之后,这里补上站得住的那部分。

方法与前提:

  • 被测版本 v1.6.21(中心仓已发布);
  • 跳表这类"小负载"基准连跑 30 轮取中位数,其余项 5 轮;
  • 数据目录显式落在真实盘(btrfs,非 tmpfs);
  • 同时给出离散度,>15% 的项标注"波动大"。

并发跳表(5000 ops / 16 线程,30 轮)

指标 中位数 离散度
并发写 387 ns/op(约 258 万 op/s) ⚠️ ±40%
并发读 333 ns/op(约 300 万 op/s) ⚠️ ±39%
顺序写 555 ns/op ⚠️ ±39%
顺序读 1,227 ns/op ⚠️ ±27%

⚠️ 负载只有约 64 毫秒,点值本身不稳定。正确的说法是"亚微秒级",
而不是"330 ns"。

并发事务写(50 并发事务,5 轮)

规模 吞吐量 离散度
50 × 100 = 5,000 keys 634,365 keys/s ±13%
50 × 1,000 = 50,000 keys 730,396 keys/s ±5%
50 × 2,000 = 100,000 keys 691,171 keys/s ±5%
50 × 3,000 = 150,000 keys 567,458 keys/s ±2%

长事务(500 × 3000 = 150 万 key):382,194 keys/s,事务成功率 100%。

批量写对每 key 成本的摊薄(同一写路径,40 万 key / 8 线程)

事务粒度 每 key 成本 相对单键
单键事务 6,778–6,857 ns 1x
8 key/事务 2,216–2,494 ns 快 2.7–3.1x
64 key/事务 1,715–1,969 ns 快 3.5–4.0x

这组数据背后有个反直觉的结论:写路径的同步成本没法靠"框架内部批量提交"摊薄(唤醒次数 = 请求数,每个 commit 必须被单独唤醒),真正的杠杆在用户侧的事务粒度。

DB 与内存路径微基准

基准 指标 中位数 离散度
examples.perf DB 写(20 事务 × 200 key) 1,431 ns/op ±11%
examples.perf DB 读(单事务内 4000 次读) 2,681 ns/op ±2.5%
cjpm bench benchDBGet(单次 get) 5.54 µs ⚠️ ±69%
cjpm bench benchPut(跳表单次 put) 1.04 µs ⚠️ ±46%
raw_write_perf MemTable 直写 50 万 key 554,084 keys/s ±4.4%

注意 examples.perf 与 cjpm bench 的口径不同(前者批内摊薄、后者单次调用),
数值不可互相比较——引用时必须带上是哪个基准。
另外 benchDBGet 离散度高达 ±69%,历史报告给它写的精确值
(4.834 / 3.447 µs)其实都落在本次实测范围(3.78–7.57 µs)内,
说明那不是"版本差异",就是噪声。

异步 flusher(同一二进制开关对照)

指标 中位数 离散度
运行期写吞吐 97,370 次/秒 ±4.8%
close 耗时 54 毫秒 ±9.3%


四、一套能核验的流程长什么样

复盘下来,现在项目里固定做这几件事:

1. 只产出两类数据

  • ① 当前 revision 的绝对值;
  • ② 同一二进制内的开关对照(如 sync vs io_uring)。

不做跨版本对比。 原因见本篇第五节。

2. 必须报离散度,不能只报最佳值

离散度 > 15% 的项自动标记,这类数字只用于判断方向,不用于引用。

3. 环境指纹进报告头

CPU / 内存 / 内核 / 编译器版本 / commit / 数据目录文件系统 / 轮次,一个不少。

4. 一条命令跑完全部

python3 scripts/bench/run-bench.py \
    --runs 5

这样"重跑一遍"的成本从半天降到几分钟——这才是数字能被核对的前提。



五、一个附带发现:那份版本演进表是错的

原报告的版本演进表长这样:

版本 Skiplist Put Skiplist Get …
v1.3.1(优化前) 1958 ns 3990 ns
v1.6.1(CAS + findGe) 1324 ns 2241 ns
v1.6.1+(优化后) 1115 ns 1120 ns
v1.6.12 1067 ns 2130 ns

看起来是条清晰的优化曲线。但核对 git 历史后发现两个问题:

  1. v1.3.1 这个 tag 在仓库里不存在(最早的是 v1.2.0);
  2. 表里那些优化提交集中在 2026-03-11 ~ 03-14,而最早的 tag 是 2026-03-21——也就是说,优化发生在第一个 tag 之前。

所以表头那些"vX.Y.Z 优化前 / 优化后"的对照关系,在版本历史上没有依据。它大概是当时按语义手工标注的,不是真实版本对照。

这也是"不做跨版本对比"这条原则的由来:版本对照要成立,前提是每个版本都能真的检出、构建、并在同一环境下跑一遍。做不到,就不该把它写成表格。



结语

复盘下来,这几个错误的共同点是:错的不是测量精度,而是方法。而方法层面的错误有个特点——它们不会表现为数值偏差,会表现为方向性的错误结论。

往上一层:你给出的每一个数字,别人是要拿它做判断的。

“约 330 纳秒"和"亚微秒级、离散度 ±40%“说出口的分量完全不同——前者像是可以依赖的事实,后者才是我真正测到的东西。

这次复核删掉了一批好看的数字,换来一套能重跑、能核验、并且明确说自己不可靠在哪的流程。这比任何一个 “+30%” 都值钱。


相关链接