Graven

Baseline: zidl vs FlatBuffers vs Cap'n Proto

Historical document. Written on 2026-11-12, when the project was called zidl (later Graven). It is the first comparison against FlatBuffers and Cap'n Proto.

Obsolete for timings: the write methodology was biased against zidl (see round 2). Sizes and allocations remain valid.

Date: 2026-11-12 · Phases 1–2 of the plan runtime and benchmarks

Machine: Intel Core i9-14900HX (32 threads, up to 5.8 GHz), Linux 7.3 CachyOS, rustc 1.94.0, --release. Versions: flatbuffers 25.12.19 (flatc 25.12.19), capnp 0.27.3 (capnp 1.5.0), criterion 0.8.2. How to reproduce (at the time):

cargo bench -p zidl-bench --bench codec
cargo run --release -p zidl-bench --bin sizes
cargo run --release -p zidl-bench --bin allocs

All formats pass the same equivalence test (crates/zidl-bench/tests/equivalence.rs): the same checksum for 20 seeds × 4 scenarios.

Fairness notes

Size (bytes per message)

scenario zidl flatbuffers capnp capnp packed
bank/1 121 176 152 109
bank/10 417 552 464 344
bank/100 3561 4464 4016 2947
frame/10 331 488 360 322
frame/500 15521 20104 16040 15827
telemetry (with humidity) 24 48 40 28
telemetry (without humidity) 24 48 40 23
chunk/64 87 112 104 90
chunk/65536 65559 65584 65576 65626

Allocations per written message (after warm-up)

Condition: the pool packs consecutive messages into the same 64 KiB chunk (split()), and only asks for a new chunk when less than ¼ is left. Messages dropped before the next one ⇒ 0 allocations. With 4 messages in flight (a send queue) ⇒ < 0.05 allocations/message (test in_flight_messages_amortize_allocations). The memory held is ≈ the size of the messages in flight + 1 chunk.

scenario zidl (streaming) zidl (known) flatbuffers capnp
bank/100 0 0 0 1
frame/500 0 0 0 1
telemetry 0 0 0 1
chunk/65536 0 0 0 1

Time (median, ns)

Writing

scenario zidl (streaming¹) zidl (known) fbs capnp
bank/1 112.6 87.0 60.7 61.4
bank/10 237.4 161.9 350.2 87.5
bank/100 1529.0 885.5 3229.1 480.4
frame/10 116.8 — 360.7 62.4
frame/500 2640.2 — 17703.0 843.1
telemetry 50.6 — 20.1 31.8
chunk/64 56.4 — 19.2 40.8
chunk/65536 1035.7 — 1658.5 2714.3

¹ Only bank has a streaming variant; in the other rows the first column is zidl's only measurement (known count).

Reading every field (unverified)

scenario zidl fbs capnp
bank/1 39.6 24.9 66.6
bank/10 113.0 85.2 113.7
bank/100 860.3 690.0 635.9
frame/10 108.0 145.1 125.5
frame/500 4542.4 7695.7 4047.4
telemetry 5.9 11.2 35.3
chunk/64 6.6 7.6 45.3
chunk/65536 6.4 7.5 44.9

Reading one field

scenario zidl fbs capnp
bank (1/10/100) 6.3 2.5 31.2
frame (10/500) 12.1 4.8 41.0
telemetry 4.6 2.5 31.4
chunk 5.1 2.4 31.6

Full read with verification

scenario zidl fbs capnp
bank/1 76.8 120.6 65.5
bank/10 209.4 475.6 112.3
bank/100 1664.3 3964.1 624.4
frame/10 167.8 565.2 125.1
frame/500 6883.2 28411.0 4023.9
telemetry 12.9 52.6 35.8
chunk/64 14.2 39.5 44.6
chunk/65536 14.1 40.1 44.7

Verdict per criterion (spec §8)

criterion result
Win on size ✅ against FlatBuffers and unpacked Cap'n Proto in every scenario: 20–50% smaller than FlatBuffers and 3–40% smaller than Cap'n Proto for small and medium messages. For 64 KiB blobs the difference is negligible (~0.03%). ❌ Packed Cap'n Proto is smaller in bank/*, frame/10 and telemetry without humidity, paying CPU to pack and unpack.
Zero allocations per message ✅ 0 in every scenario, including with a streaming list. With messages in flight, < 0.05 per message (see the condition above). FlatBuffers also has 0; Cap'n Proto has 1.
Win on writing ❌ Does not win. Against FlatBuffers: loses for messages of 1 element or smaller (bank/1 streaming 1.9× slower, telemetry 2.5×, chunk/64 2.9×); wins for larger lists (bank/10: 1.5× streaming and 2.2× known; bank/100: 2.1× and 3.6×; frame: 3.1–6.7×) and for 64 KiB blobs (1.6×). Against Cap'n Proto: loses on lists (1.8–3.2×) and on tiny messages; wins only on 64 KiB blobs (2.6×).
Tie or win on reading ⚠️ Mixed. Wins on telemetry, chunk and frame/10. Loses on bank to FlatBuffers and Cap'n Proto (UTF-8 validation) and on frame/500 to Cap'n Proto (≈ 12%). Reading one field it loses to FlatBuffers (≈ 2.5×) and beats Cap'n Proto (≈ 5×). With verification it beats FlatBuffers in every scenario, by 1.6× (bank/1) to 4.1× (frame/500).

Hypotheses for the losses (to validate in the next round)

  1. Fixed write overhead (~30 ns).
    • Likely cause: SimplePool takes a Mutex twice per message (take and give). Add reserve and split().freeze().
    • Next step: measure with a thread-local pool, or reuse the Builder itself.
  2. Writing lists is slower than Cap'n Proto.
    • Likely causes: every set goes through fixed_buf() (a match), plus a bounds check and set_bit. push uses resize with a zero-fill per element. With streaming, add the final memcpy and the pointer fix-up.
    • Cap'n Proto does one memset of the whole list and then only stores.
    • Next steps: zero the list in one block, set without a redundant bounds check, and specialize TableW for "fixed part in the main buffer".
  3. read() costs ~4 ns more than root_unchecked.
    • Cause: check_required walks the Req list. Spec §4.3.1 asks for an AND with a precomputed mask; that was not implemented in this phase.
    • In lists, from_ref validates the required fields of every element (frame/500 = 500 validations).
    • Next steps: a mask per byte of bits, and validating elements once per list when elem_fixed_len covers the reader's layout.
  4. Strings in bank.
    • Cause: zidl validates UTF-8 and the others do not.
    • Next step: measure the str_unchecked variant and/or validate UTF-8 only in verify().
  5. The "no alignment" hypothesis did not turn out to be a problem.
    • In the scalar scenarios (telemetry, chunk, frame/10) zidl reads faster than both.
    • The read losses come from validation (items 3 and 4), not from unaligned access.