Graven

Round 2: optimization + revised methodology

Historical document. Written on 2026-11-12, when the project was called zidl (later Graven).

Date: 2026-11-12 · Replaces the timings of the baseline (sizes and allocations did not change). Machine: Intel Core i9-14900HX, Linux 7.3, rustc 1.94.0, --release. Noise observed between runs: ~±5%. Differences smaller than that do not count as wins.

What was wrong with the methodology (and was fixed)

  1. zidl's writing paid for the pool on every message.
    • zidl created a Builder per message: take + give on the SimplePool, with two Mutexes, plus reserve.
    • FlatBuffers reused its builder with reset() and Cap'n Proto reused its allocator.
    • Fix: Builder::flush(), the equivalent of reset(). The builder lives between messages and each message is cut from the same pool block.
  2. The benchmark's checksum dominated reading.
    • Each field did rotate ^ v and then * K, a serial chain of ~5 cycles per field, the same for every format.
    • That hid the real difference between them: in frame/500 it was ~8 ns per entity just for the checksum.
    • Fix: now it is only rotate ^ v, at ~1 cycle.
  3. "Unverified reading" was not the same thing in the three formats.
    • FlatBuffers root_unchecked: validates nothing, not even UTF-8.
    • Cap'n Proto: checks bounds, but does not validate UTF-8.
    • zidl read(): validates required fields, bounds and UTF-8.
    • Now zidl appears in two modes: zidl (safe, the default) and zidl_trusted (strings without UTF-8 validation, unsafe, the equivalent of root_unchecked).
  4. "Verified" in zidl validated UTF-8 twice: once in verify() and again when reading. Now reading after verify() uses the trusted mode.

Cap'n Proto still pays a copy into the output Vec when writing (write_message). This disfavors it slightly and is noted here.

Optimizations in zidl

change main effect
Builder::flush() + block reservation only in root() telemetry write 50.6 → 19.9 ns; bank/1 112.6 → 44.9 ns
a stash of aux buffers in the Builder (streaming lists without the pool) bank/100 streaming 1529 → 629 ns
FixedW<N> for scalar-only tables (no bounds check) frame/500 write 2640 → 940 ns
TableRef with a pre-sliced fixed part (1 bounds check per field) a field read ≈ 1 load
a required-field mask (spec §4.3.1): 1 AND per byte of bits read_one 6.3 → 1.2 ns

The optimizations are covered by tests:

Results (ns, median; bold = best)

Writing (round 4)

Three ways of handing over the message, all measured:

scenario zidl (streaming¹) zidl (known) zidl_batched zidl_owned fbs capnp
bank/1 31.3 22.3 — — 56.8 55.2
bank/10 88.2 54.0 — — 325.4 79.9
bank/100 663.9 369.7 — — 3036.4 395.7
frame/10 25.0 — — — 328.6 58.4
frame/500 765.6 — 920.4 886.0 17727 867.7
telemetry 5.5 — 6.4 21.1 22.5 33.3
chunk/64 9.3 — 10.8 26.0 20.0 42.6
chunk/65536 970.4 — — — 1688.4 2764.7

¹ Only bank has a streaming variant; in the other rows it is zidl's only measurement.

Reading the modes:

Reading every field

scenario zidl (safe) zidl_trusted fbs (unchecked) capnp
bank/1 29 6.3 21.8 64.7
bank/10 78.2 30.4 77.5 111.4
bank/100 632.4 215.5 661.5 631.1
frame/10 52.7 — 120.6 96.2
frame/500 2194.3 — 6279.9 2213
telemetry 1.2 — 10.2 33.9
chunk/64 2 — 6.8 45.1
chunk/65536 2 — 6.7 45.2

Reading one field

scenario zidl fbs capnp
bank/* 1.2 2.2 32
frame/* 3.2 4.1 41.5
telemetry 1.3 2.2 31.8
chunk/* 1.4 2.3 32

Reading with full verification

scenario zidl fbs capnp²
bank/1 40.5 119 64.5
bank/10 115.5 470.2 111.1
bank/100 849.9 4023.7 627.1
frame/10 65.1 541.9 96.4
frame/500 2558.3 26950 2217.6
telemetry 4.2 52 33.9
chunk/64 6.5 39 46.1
chunk/65536 6.7 38.7 45.8

² Cap'n Proto's "verification" is lazy and does not validate UTF-8. zidl and FlatBuffers walk and validate the whole message.

Sizes and allocations: the same as the baseline (zidl smaller than FlatBuffers and than unpacked Cap'n Proto in every scenario; 0 allocations per message).

Verdict per criterion (spec §8)

criterion result
Size ✅ same as the baseline: beats FlatBuffers and unpacked Cap'n Proto. Packed Cap'n Proto is smaller in bank, frame/10 and telemetry without humidity.
Zero allocations ✅
Writing ✅ borrowed against borrowed (the same contract for all three): zidl wins in every scenario, 1.07–6× over Cap'n Proto (smallest margin: bank/100, ~7%) and 1.7–23× over FlatBuffers. ✅ batched (seal/take_frames, an owned message for sending): small messages stay faster than borrowed FlatBuffers and Cap'n Proto. ⚠️ large owned messages (frame/500) cost ~15–20% more than borrowed ones because of cache locality. They land at ~890–920 ns, against 868 for borrowed Cap'n Proto. The streaming list, which Cap'n Proto does not support, costs 1.4–1.8× the known-count one.
Reading ✅ one field: beats everyone (1.3–1.8× over FlatBuffers). ✅ every field: wins or ties in every scenario; in trusted mode, bank is 2.5–3.5× faster than both. The exception is bank/1 in safe mode, which loses to unvalidated FlatBuffers (29 vs 21.8 ns): the cost is UTF-8 validation. ⚠️ verified: beats FlatBuffers (3–12×); loses to Cap'n Proto in bank/10, bank/100 and frame/500, whose verification is not equivalent.

What is still missing