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
- Reused contexts. zidl uses
SimplePool. FlatBuffers usesFlatBufferBuilder::reset()and a reused offsets vector. Cap'n Proto uses a reusedScratchSpaceHeapAllocator. - Cap'n Proto writing. The output is
serialize::write_messageinto a reusedVec, which includes a copy of the segments. Without that copy (a writev of the segments) it would be a little faster. - Lists in Cap'n Proto. Cap'n Proto needs to know the list's size before writing it. The
zidlcolumn inbankuses a streaming list, the hard path. Thezidl_knowncolumn uses a known count, comparable to Cap'n Proto. - "Unverified" reading:
- FlatBuffers:
root_unchecked. - Cap'n Proto:
traversal_limit = None. It still checks bounds, because Cap'n Proto always does. - zidl:
read(), which validates required fields and checks bounds on every access.
- FlatBuffers:
- "Verified" reading:
- FlatBuffers:
root(), with the full verifier. - Cap'n Proto: the default
ReaderOptions. Its verification is lazy, so it changes almost nothing. - zidl: a full
verify()followed byread_all.
- FlatBuffers:
- Strings. zidl validates UTF-8 on every string read. FlatBuffers
root_uncheckedand Cap'n Protoas_bytes()do not. There is no measured_uncheckedvariant yet (see next steps). - Optionals. FlatBuffers omits scalars equal to the default, which favors it in size
when there are zeros. Cap'n Proto has no optional scalars; we use
union { none; value }.
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)
- Fixed write overhead (~30 ns).
- Likely cause:
SimplePooltakes aMutextwice per message (takeandgive). Addreserveandsplit().freeze(). - Next step: measure with a thread-local pool, or reuse the
Builderitself.
- Likely cause:
- Writing lists is slower than Cap'n Proto.
- Likely causes: every
setgoes throughfixed_buf()(a match), plus a bounds check andset_bit.pushusesresizewith 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,
setwithout a redundant bounds check, and specializeTableWfor "fixed part in the main buffer".
- Likely causes: every
read()costs ~4 ns more thanroot_unchecked.- Cause:
check_requiredwalks theReqlist. Spec §4.3.1 asks for anANDwith a precomputed mask; that was not implemented in this phase. - In lists,
from_refvalidates 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_lencovers the reader's layout.
- Cause:
- Strings in
bank.- Cause: zidl validates UTF-8 and the others do not.
- Next step: measure the
str_uncheckedvariant and/or validate UTF-8 only inverify().
- 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.