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)
- zidl's writing paid for the pool on every message.
- zidl created a
Builderper message:take+giveon theSimplePool, with twoMutexes, plusreserve. - FlatBuffers reused its builder with
reset()and Cap'n Proto reused its allocator. - Fix:
Builder::flush(), the equivalent ofreset(). The builder lives between messages and each message is cut from the same pool block.
- zidl created a
- The benchmark's checksum dominated reading.
- Each field did
rotate ^ vand 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.
- Each field did
- "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) andzidl_trusted(strings without UTF-8 validation,unsafe, the equivalent ofroot_unchecked).
- FlatBuffers
- "Verified" in zidl validated UTF-8 twice: once in
verify()and again when reading. Now reading afterverify()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:
- Required-field mask: a proptest compares the fast path with the slow one over 8192 cases per schema. Verified by mutation: a wrong mask is detected.
- Allocations: the test still shows 0 allocations per message in steady state and fewer than 0.05 per message with 4 in flight.
Results (ns, median; bold = best)
Writing (round 4)
Three ways of handing over the message, all measured:
zidl(borrowed,Builder::finished()): a&[u8]valid until the nextroot(). It is the same contract as FlatBuffers'finished_data()and Cap'n Proto's&Vec, which are the numbers in those two columns.zidl_batched(seal()+take_frames()): each message becomes a[u32 len][msg]frame in the same buffer. Every 64 frames or 32 KiB, the whole batch leaves as oneBytes, with one refcount per batch. It is the path of a sender writing to a socket.zidl_owned(flush()): one ownedBytesper message, with one atomic refcount per message.
| 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:
- Small messages: batching removes the cost of the refcount. chunk/64 goes from 26.0 to 10.8 ns and telemetry from 21.1 to 6.4 ns, both faster than borrowed FlatBuffers.
- Large messages (frame/500, 15.5 KB): borrowed takes 766 ns; owned or batched, 886–920
ns. The difference is not the refcount. It is cache locality: the borrowed message
always rewrites the same 15.5 KB (hot in L1), while a message that outlives the next write
needs new memory. We tested a 1 MiB block to rule out reallocation: no change. Cap'n Proto
(868) was measured borrowed; an owned version of it would require copying the
Vec.
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
- Large owned messages: ~15–20% above borrowed (cache locality, see above). An option for
whoever sends right away: write straight to the socket from
finished(), without an owned message. verify()on large lists: each element callsenter,leaveandcharge. The whole list could be charged at once when the element has no pointers.- Streaming list: the per-element
resizein the aux buffer plus the fix-up cost ~2.4 ns per element.