Skip to content

Phase B: Record Layer

Part of the hcloud-rebench-20261014 campaign.

Eight pkg/recordlayer benchmarks in three trees, counterbalanced. This separates two changes: the read-path parity work (record loads switched to Java’s whole-record range reads) and the allocation work that followed it.

Arms

ArmSourceWhat it contains
legacy9a9a448670a748e4ea721772ba96d70ccc4210a0The tree behind the published 2026-10-12 numbers
before3824f14654edae8431b0085de8ebf83f5a460a08legacy plus the read-path parity change (#888); nothing else in between changes code
final267a41434a81723b07e7150869d702636a1b91a9before plus the allocation work and everything merged since

All three trees ran byte-identical benchmark source (pkg/recordlayer/benchmark_test.go, harness d94b4c61377d173b7c6884fde426a227dab0f0bb; see campaign-harness.md), so differences come from the production code. final/before is the allocation work plus later changes; final/legacy is both changes together.

Environment and design

  • The host and CPU groups are those of the phase A client run: Hetzner ccx43, fdbserver on CPUs 2–7, the benchmark process on 8–15 in a network namespace reaching the server over a veth pair. Here the single fdbserver 7.3.77 process used the memory engine, single redundancy.
  • Per case: 6 balanced blocks over the three arms (seed 641101, every order once). One fresh process per arm and block, running the benchmark three times (-test.count=3): read benchmarks for 3 s each, write benchmarks for exactly 1,000 operations (SaveRecord, SaveRecordWithIndex, SaveSplitRecord insert IDs 0–999 into a freshly cleared subspace). 18 samples per arm and case, 432 in total. The cluster’s user keys were cleared before every process.
  • Estimand: the geometric mean over blocks of each block’s arm ratio, using each process’s median of its three repeats, with a nominal 95% percentile bootstrap over blocks (20,000 resamples, seed 391706). Absolute values are the median over blocks of those process medians.
  • ns/op is testing.B’s mean time per operation, not a percentile; B/op and allocs/op are Go heap allocations per operation.
  • Workloads: SaveRecord and SaveRecordWithIndex save one small Order per transaction (with a VALUE index for the latter); SaveSplitRecord saves one ~250 kB record (three chunks); LoadRecord loads one record; ScanRecords/ScanRecordsSplitVersioned scan 100 records (the latter in the SQL default layout: split records with inline versions); ScanIndexRecords/serial and /pipelined scan 1,000 records through a value index, loading each record serially or with the production pipeline.

Results

Figure B.1: Record Layer, final / beforeBelow 1.0× favors the final tree. Single in-memory server.

Allocations per operation dropped on all 8 benchmarks, down to 0.627×. Time improved by 2–3% on 3 benchmarks and is inconclusive on the rest.

0.6×0.6×0.7×0.7×0.8×0.8×0.9×0.9×1.0×1.0×1.1×1.1×← Final lowerBefore lower →Mean time (ns/op): final / beforeSaveRecordSaveRecord: 0.981× [0.954, 1.015]0.981× [0.954, 1.015]SaveRecordWithIndexSaveRecordWithIndex: 0.998× [0.965, 1.040]0.998× [0.965, 1.040]SaveSplitRecordSaveSplitRecord: 0.966× [0.907, 1.031]0.966× [0.907, 1.031]LoadRecordLoadRecord: 0.978× [0.958, 0.994]0.978× [0.958, 0.994]ScanRecordsScanRecords: 0.978× [0.948, 1.005]0.978× [0.948, 1.005]ScanRecordsSplitVersionedScanRecordsSplitVersioned: 0.976× [0.957, 0.996]0.976× [0.957, 0.996]ScanIndexRecords/serialScanIndexRecords/serial: 1.015× [0.987, 1.052]1.015× [0.987, 1.052]ScanIndexRecords/pipelinedScanIndexRecords/pipelined: 0.972× [0.952, 0.995]0.972× [0.952, 0.995]Go bytes (B/op): final / beforeSaveRecordSaveRecord: 0.902× [0.900, 0.904]0.902× [0.900, 0.904]SaveRecordWithIndexSaveRecordWithIndex: 0.910× [0.908, 0.912]0.910× [0.908, 0.912]SaveSplitRecordSaveSplitRecord: 0.998× [0.993, 1.004]0.998× [0.993, 1.004]LoadRecordLoadRecord: 0.855× [0.854, 0.856]0.855× [0.854, 0.856]ScanRecordsScanRecords: 0.859× [0.859, 0.860]0.859× [0.859, 0.860]ScanRecordsSplitVersionedScanRecordsSplitVersioned: 0.812× [0.812, 0.812]0.812× [0.812, 0.812]ScanIndexRecords/serialScanIndexRecords/serial: 0.757× [0.757, 0.758]0.757× [0.757, 0.758]ScanIndexRecords/pipelinedScanIndexRecords/pipelined: 0.749× [0.749, 0.749]0.749× [0.749, 0.749]Go allocations (allocs/op): final / beforeSaveRecordSaveRecord: 0.859× [0.857, 0.861]0.859× [0.857, 0.861]SaveRecordWithIndexSaveRecordWithIndex: 0.866× [0.866, 0.867]0.866× [0.866, 0.867]SaveSplitRecordSaveSplitRecord: 0.888× [0.887, 0.889]0.888× [0.887, 0.889]LoadRecordLoadRecord: 0.793× [0.793, 0.793]0.793× [0.793, 0.793]ScanRecordsScanRecords: 0.828× [0.828, 0.828]0.828× [0.828, 0.828]ScanRecordsSplitVersionedScanRecordsSplitVersioned: 0.816× [0.816, 0.816]0.816× [0.816, 0.816]ScanIndexRecords/serialScanIndexRecords/serial: 0.651× [0.651, 0.651]0.651× [0.651, 0.651]ScanIndexRecords/pipelinedScanIndexRecords/pipelined: 0.627× [0.626, 0.627]0.627× [0.626, 0.627]
95% CI below 1.0× 95% CI includes 1.0× (inconclusive) 95% CI above 1.0×

Final vs before: the allocation work

Ratio final/before, nominal 95% interval.

Casens/opB/opallocs/op
SaveRecord0.981 [0.954, 1.015]0.902 [0.900, 0.904]0.859 [0.857, 0.861]
SaveRecordWithIndex0.998 [0.965, 1.040]0.910 [0.908, 0.912]0.866 [0.866, 0.867]
SaveSplitRecord0.966 [0.907, 1.031]0.998 [0.993, 1.004]0.888 [0.887, 0.889]
LoadRecord0.978 [0.958, 0.994]0.855 [0.854, 0.856]0.793 [0.793, 0.793]
ScanRecords0.978 [0.948, 1.005]0.859 [0.859, 0.860]0.828 [0.828, 0.828]
ScanRecordsSplitVersioned0.976 [0.957, 0.996]0.812 [0.812, 0.812]0.816 [0.816, 0.816]
ScanIndexRecords/serial1.015 [0.987, 1.052]0.757 [0.757, 0.758]0.651 [0.651, 0.651]
ScanIndexRecords/pipelined0.972 [0.952, 0.995]0.749 [0.749, 0.749]0.627 [0.626, 0.627]

Final vs legacy: both changes together

Ratio final/legacy, nominal 95% interval. The legacy tree predates the read-path parity change.

Casens/opB/opallocs/op
SaveRecord1.057 [0.996, 1.174]0.970 [0.967, 0.972]0.928 [0.928, 0.930]
SaveRecordWithIndex1.002 [0.982, 1.030]0.973 [0.972, 0.974]0.927 [0.926, 0.929]
SaveSplitRecord1.023 [0.996, 1.049]1.009 [1.004, 1.013]0.873 [0.872, 0.874]
LoadRecord1.138 [1.119, 1.157]0.995 [0.993, 0.996]0.917 [0.917, 0.917]
ScanRecords1.000 [0.975, 1.026]0.859 [0.859, 0.860]0.828 [0.828, 0.828]
ScanRecordsSplitVersioned0.990 [0.972, 1.015]0.812 [0.811, 0.812]0.816 [0.816, 0.816]
ScanIndexRecords/serial1.477 [1.433, 1.541]1.309 [1.308, 1.310]1.186 [1.186, 1.186]
ScanIndexRecords/pipelined0.647 [0.633, 0.665]1.219 [1.219, 1.219]1.074 [1.074, 1.074]

Absolute values

Median over blocks of each process’s median of its three repeats.

CaseArmns/opB/opallocs/op
SaveRecordlegacy2.44 ms18,753249
SaveRecordbefore2.55 ms20,148269
SaveRecordfinal2.46 ms18,162231
SaveRecordWithIndexlegacy2.60 ms22,331299
SaveRecordWithIndexbefore2.58 ms23,894320
SaveRecordWithIndexfinal2.60 ms21,728277
SaveSplitRecordlegacy4.30 ms1,564,351334
SaveSplitRecordbefore4.47 ms1,580,698329
SaveSplitRecordfinal4.30 ms1,578,096292
LoadRecordlegacy660.5 µs12,949180
LoadRecordbefore766.7 µs15,060208
LoadRecordfinal749.9 µs12,882165
ScanRecordslegacy1.33 ms107,7461,435
ScanRecordsbefore1.36 ms107,7671,435
ScanRecordsfinal1.33 ms92,6001,188
ScanRecordsSplitVersionedlegacy1.80 ms175,3641,977
ScanRecordsSplitVersionedbefore1.81 ms175,3491,977
ScanRecordsSplitVersionedfinal1.77 ms142,3341,613
ScanIndexRecords/seriallegacy171.29 ms3,365,49041,250
ScanIndexRecords/serialbefore249.38 ms5,816,51875,140
ScanIndexRecords/serialfinal246.65 ms4,403,42148,917
ScanIndexRecords/pipelinedlegacy66.30 ms3,947,45247,400
ScanIndexRecords/pipelinedbefore44.02 ms6,425,94281,278
ScanIndexRecords/pipelinedfinal42.74 ms4,811,54450,922

Interpretation

Measured:

  • The allocation work cut allocations in every case. allocs/op fell 11–37% from before to final, most in the index scans (ScanIndexRecords/pipelined 0.627, /serial 0.651) and LoadRecord (0.793). B/op fell 9–25% except for SaveSplitRecord, whose ~1.6 MB per operation is dominated by the record itself (0.998).
  • Time changed little. Only LoadRecord (0.978), ScanRecordsSplitVersioned (0.976) and ScanIndexRecords/pipelined (0.972) have intervals entirely below 1, about 2–3% faster. The other five include 1.
  • The parity change dominates the index scans’ time. Compared with legacy, final is 48% slower on the serial index scan (1.477) and 14% slower on LoadRecord (1.138), but 35% faster on the pipelined index scan (0.647). The absolute medians put the step between legacy and before, i.e. on the parity change: serial 171 → 249 ms, LoadRecord 661 → 767 µs, pipelined 66 → 44 ms. Those legacy-to-before figures are ratios of medians, not paired estimates with intervals.
  • Allocation work recovered most of the parity change’s allocations but not its time. On the serial index scan, parity raised allocs/op from 41,250 to 75,140 and the allocation work brought it to 48,917, while time stayed at about 247 ms.

Not explained by this data:

  • The pipelined index scan got 34% faster with the parity change here, while the 2026-10-12 parity report measured +3% for the same change on a workstation (FoundationDB in Docker, no network namespace). A plausible difference is that whole-record range reads overlap better under this environment’s round-trip time, but that was not measured or profiled.
  • Why LoadRecord is still 14% slower than legacy after the allocation work. The parity report attributes the extra work to reading and validating the whole record subspace instead of a point read; this run did not profile it.

Limits

  • Same single host and veth path as phase A, one fdbserver process, single redundancy, memory engine: no replication, no network hop between storage servers, no disk. Each benchmark runs one operation at a time.
  • testing.B reports means; there are no latency percentiles here.
  • Six blocks give limited resolution; intervals are nominal, per case and metric, with no multiplicity correction and no aggregate across cases. SaveRecord’s final/legacy interval is wide because one block’s ratio was 1.36; its leave-one-block-out estimates range 1.005–1.073.
  • Case labels in the aggregate CSV were reconstructed. A loop variable in the campaign runner shadowed the case key, so the runner wrote a CPU field name as every row’s case in metrics.csv (fixed in tools/benchcampaign/singlenode/bench_runner.py, with a regression test). The per-run directories kept the correct case, and the analyzer reparses the raw benchmark output. The 432 labels were restored from the pinned plan in a separate working copy; no measurement was repeated or changed.