Graven

Envelope v1: cost, and closing the regression series

Historical document. Written on 2026-11-12, when the project was called zidl (later Graven). Snapshot names (t1-base, t4-head, …) refer to copies of the tree taken during the format plan.

Date: 2026-11-12 · Complements round 2 (same scenarios and same methodology). Machine: Intel Core i9-14900HX, Linux 7.3, rustc 1.94.0, --release. Noise observed between runs: ~±5%. Differences smaller than that count neither as a regression nor as a win. Machine load: 1-minute average between 0.5 and 4.5 during the runs (a browser open in the background). Number of runs per table:

Single-run numbers (envelope reading, CRC) carry more uncertainty than the medians; read them as orders of magnitude.

Commands:

The envelope

An envelope is [20 B header][message][4 B CRC32C]: +24 bytes per message. The header carries the magic, the version, the schema id and the size. The CRC32C covers the header and the message.

Cost of the envelope (ns, median)

write_owned is the fair comparison line: one owned Bytes per message (flush()), the same contract as flush_envelope(). In bank/10 both use a list with a known count.

Writing

scenario write_owned write_enveloped abs overhead rel overhead
telemetry 22.4 32.9 +10.5 +47%
chunk/64 27.2 40.9 +13.7 +50%
chunk/65536 1602.6 2630.2 +1027.7 +64%
bank/10 72.5 109.8 +37.3 +51%

The overhead is dominated by the CRC (crc32c of ~60 B costs ~6 ns, of 64 KiB ~1.65 µs, see below) plus writing the 20 B header. In chunk/65536 the extra cost (1.03 µs) is smaller than the bare CRC (1.65 µs): probably because the owned 64 KiB write already spends time in memcpy and the CRC overlaps part of it (not verified).

Reading (one field)

scenario verified() (payload) from_bytes() (envelope) abs overhead rel overhead
telemetry 3.6 13.0 +9.4 +260%
chunk/64 4.7 15.6 +10.8 +228%
chunk/65536 4.9 1720.1 +1715 64 KiB CRC
bank/10 98.5 134.2 +35.7 +36%

Reading (every field, bank/10)

read ns
read_all_bank_verified (verified() + trusted read) 120.0
from_bytes + trusted read 156.3 (+36.3, +30%)

Reading the result:

CRC32C throughput

zidl::crc32c (hardware acceleration, the SSE4.2 crc32 instruction) against crc32c_sw (slicing-by-8, used without std).

size hw (ns) hw GB/s sw (ns) sw GB/s
64 B 6.06 10.6 11.9 5.4
1 KiB 30.2 33.9 349 2.9
64 KiB 1645 39.8 23460 2.8

GB/s = 10⁹ bytes/s (criterion reports GiB/s: 37.1 GiB/s at 64 KiB). The hardware path is ~14× faster than the software one for large messages.

The deferred regression series (Tasks 4 and 5)

Context:

Since the machine's absolute speed may differ from round 2, there are controls:

  1. Absolute value against the round 2 table (column "Δ abs").
  2. Ratio zidl/fbs and zidl/capnp against the round 2 ratio (cancels differences in machine speed). A real regression is a ratio deviation > ~5–8% above the noise.
  3. Direct A/B. The plan's snapshots (snaps/t1-base = the pre-plan code, t4-*, t5-base) were built with the same rustc and measured interleaved with the current code, in the same session. This is more reliable than comparing with a table from another day.

In this round the absolute FlatBuffers and Cap'n Proto numbers stayed within ~±5% of round 2 for writing (fbs bank/1 58 vs 56.8; frame/500 17.7 µs vs 17.7 µs) and ~+5–10% for reading. That is, the machine is close to round 2's, with fbs reads a bit slower.

The tables in this section (up to the verdict) were measured before the fix described in "Fixing the regression"; the verified bank/1 row (56 ns) is the value with the regression. The final numbers are in the fix section.

Writing: zidl, zidl_known, zidl_batched, zidl_owned

scenario column doc now Δ abs zidl/fbs doc → now zidl/capnp doc → now
bank/1 zidl 31.3 32.0 +2% 0.55 → 0.55 (-0%) 0.57 → 0.57 (+0%)
bank/1 zidl_known 22.3 23.0 +3% 0.39 → 0.40 (+1%) 0.40 → 0.41 (+1%)
bank/10 zidl 88.2 84.4 -4% 0.27 → 0.26 (-5%) 1.10 → 1.05 (-5%)
bank/10 zidl_known 54.0 55.6 +3% 0.17 → 0.17 (+2%) 0.68 → 0.69 (+2%)
bank/100 zidl 663.9 650.3 -2% 0.22 → 0.21 (-4%) 1.68 → 1.59 (-5%)
bank/100 zidl_known 369.7 384.6 +4% 0.12 → 0.12 (+2%) 0.93 → 0.94 (+1%)
frame/10 zidl 25.0 25.5 +2% 0.08 → 0.07 (-3%) 0.43 → 0.42 (-2%)
frame/500 zidl 765.6 780.1 +2% 0.04 → 0.04 (+0%) 0.88 → 0.87 (-1%)
frame/500 zidl_batched 920.4 957.0 +4% 0.05 → 0.05 (+2%) 1.06 → 1.07 (+1%)
frame/500 zidl_owned 886.0 921.8 +4% 0.05 → 0.05 (+2%) 1.02 → 1.03 (+1%)
telemetry zidl 5.5 6.1 +12% 0.24 → 0.29 (+18%) 0.17 → 0.18 (+8%)
telemetry zidl_batched 6.4 7.2 +12% 0.28 → 0.34 (+18%) 0.19 → 0.21 (+8%)
telemetry zidl_owned 21.1 22.4 +6% 0.94 → 1.06 (+13%) 0.63 → 0.65 (+3%)
chunk/64 zidl 9.3 9.7 +4% 0.47 → 0.47 (+1%) 0.22 → 0.22 (+3%)
chunk/64 zidl_batched 10.8 11.5 +6% 0.54 → 0.56 (+3%) 0.25 → 0.27 (+5%)
chunk/64 zidl_owned 26.0 26.7 +3% 1.30 → 1.30 (-0%) 0.61 → 0.62 (+1%)
chunk/65536 zidl 970.4 1022.3 +5% 0.57 → 0.59 (+2%) 0.35 → 0.36 (+3%)

Reading every field: zidl and zidl_trusted

scenario column doc now Δ abs zidl/fbs doc → now zidl/capnp doc → now
bank/1 zidl 29 29.2 +1% 1.33 → 1.22 (-8%) 0.45 → 0.43 (-5%)
bank/1 zidl_trusted 6.3 7.6 +21% 0.29 → 0.32 (+11%) 0.10 → 0.11 (+15%)
bank/10 zidl 78.2 83.2 +6% 1.01 → 1.03 (+2%) 0.70 → 0.72 (+3%)
bank/10 zidl_trusted 30.4 24.1 -21% 0.39 → 0.30 (-24%) 0.27 → 0.21 (-24%)
bank/100 zidl 632.4 701.8 +11% 0.96 → 1.04 (+9%) 1.00 → 1.09 (+9%)
bank/100 zidl_trusted 215.5 196.6 -9% 0.33 → 0.29 (-11%) 0.34 → 0.31 (-10%)
frame/10 zidl 52.7 56.6 +7% 0.44 → 0.42 (-4%) 0.55 → 0.57 (+5%)
frame/500 zidl 2194.3 2201.4 +0% 0.35 → 0.32 (-9%) 0.99 → 0.95 (-4%)
telemetry zidl 1.2 1.4 +13% 0.12 → 0.13 (+12%) 0.04 → 0.04 (+10%)
chunk/64 zidl 2 2.2 +9% 0.29 → 0.30 (+2%) 0.04 → 0.05 (+5%)
chunk/65536 zidl 2 2.2 +10% 0.30 → 0.31 (+3%) 0.04 → 0.05 (+6%)

Verified reading (read_all_verified): zidl

scenario column doc now Δ abs zidl/fbs doc → now zidl/capnp doc → now
bank/1 zidl 40.5 56.0 +38% 0.34 → 0.45 (+31%) 0.63 → 0.82 (+31%)
bank/10 zidl 115.5 120.0 +4% 0.25 → 0.24 (-0%) 1.04 → 1.05 (+1%)
bank/100 zidl 849.9 843.0 -1% 0.21 → 0.21 (-2%) 1.36 → 1.30 (-4%)
frame/10 zidl 65.1 70.3 +8% 0.12 → 0.12 (+2%) 0.68 → 0.73 (+7%)
frame/500 zidl 2558.3 2557.0 -0% 0.09 → 0.09 (-6%) 1.15 → 1.11 (-4%)
telemetry zidl 4.2 4.4 +6% 0.08 → 0.08 (+4%) 0.12 → 0.13 (+2%)
chunk/64 zidl 6.5 6.9 +5% 0.17 → 0.17 (+2%) 0.14 → 0.14 (+2%)
chunk/65536 zidl 6.7 6.8 +2% 0.17 → 0.17 (-1%) 0.15 → 0.15 (+0%)

Direct A/B against the pre-plan code (bank, at least 2 interleaved runs)

benchmark pre-plan post-Task 4 / pre-Task 5 (t5-base) now now / pre-plan
write/zidl/bank/1 31.8 30.4 30.5 0.96x
write/zidl_known/bank/1 22.6 22.9 22.5 0.99x
write/zidl/bank/10 89.7 88.3 82.0 0.91x
write/zidl_known/bank/10 54.6 56.6 56.7 1.04x
write/zidl/bank/100 701.2 660.4 615.9 0.88x
write/zidl_known/bank/100 379.7 379.1 371.2 0.98x
read_all/zidl/bank/1 32.6 32.4 28.4 0.87x
read_all/zidl_trusted/bank/1 6.4 7.2 7.5 1.17x
read_all/zidl/bank/10 88.5 93.3 82.9 0.94x
read_all/zidl_trusted/bank/10 26.2 29.0 23.4 0.89x
read_all/zidl/bank/100 690.3 784.7 681.5 0.99x
read_all/zidl_trusted/bank/100 219.7 200.8 191.1 0.87x
read_all_verified/zidl/bank/1 (before the fix) 43.2 55.7 54.7 1.27x
read_all_verified/zidl/bank/10 122.1 128.7 118.8 0.97x
read_all_verified/zidl/bank/100 911.7 875.1 822.7 0.90x

Small scenarios (at least 2 interleaved runs, pre-plan against now):

benchmark pre-plan now now / pre-plan
write/zidl/telemetry 5.3 6.1 1.14x
write/zidl_batched/telemetry 6.4 6.9 1.07x
write/zidl_owned/telemetry 21.3 22.3 1.04x
write/zidl/chunk/64 9.7 9.5 0.98x
write/zidl_batched/chunk/64 11.1 11.0 1.00x
write/zidl_owned/chunk/64 26.2 26.7 1.02x
write/zidl/frame/10 25.9 25.4 0.98x
read_all/zidl/frame/10 51.6 55.7 1.08x
read_all_verified/zidl/frame/10 65.6 68.3 1.04x
read_all_verified/zidl/chunk/64 6.3 6.9 1.10x

The telemetry step: see the section "Fixing the regression" (bisection by snapshots). The step already existed before Task 4.

Regression verdict

item verdict
(a) Task 4, begin_msg: writing ✅ no regression from Task 4: bank, frame, chunk and the owned/batched variants stay within ±5% in absolute value and in ratio, and the pre-plan A/B gives 0.88–1.04x. Task 4 did not introduce the only deviation, write/zidl/telemetry (the cheapest message, ~5.4 ns): the step of ~+0.55 ns already appears in the Task 1 snapshot and Task 4 adds only ~+0.15 ns (within the noise). See the telemetry paragraph in "Fixing the regression". A known and accepted deviation from the ±5% criterion (~1 ns absolute): write/zidl/telemetry, +0.7 ns (5.4 → 6.1 ns, +12–14%). zidl_batched/telemetry +7–12% and zidl_owned/telemetry +4–6% (within the noise).
(b) trusted/verified reading of bank ✅ fixed (see "Fixing the regression"). Verified bank/1: 55–58 ns before the fix, 41.0 ns after (pre-plan 43.5, 0.94x). Safe read_all, zidl_trusted and verified bank/10 and bank/100: 0.85–1.03x of the pre-plan. What remains is zidl_trusted bank/1: 6.5 → 7.3 ns (+0.8 ns, +12%); in the t5-base snapshot (post-Task 4 / pre-Task 5) it already measured 7.2 ns. A known and accepted deviation from the ±5% criterion (+0.8 ns, ~1 ns absolute).

Notes:

Fixing the regression (verified bank/1)

Bisection by snapshots (read_all_verified/zidl/bank/1, 3 interleaved runs, ns): t1-base 45.9 · t1-head 54.2–56.4 · t2-base 55 · t3-head 55 · t4-base 54–55 · t4-head 57 · t5-base 56–58 · t5-head 57 · current 58–60. The step of ~+10 ns came in with Task 1, which only touched Error (new variants carrying data: Incomplete { needed: usize }, SchemaMismatch { expected: u64, found: u64 }, UnsupportedVersion(u8)) and the writer's list_size. Tasks 4 and 5 contribute ~+1–2 ns (noise).

Cause. With the new variants, Error went from 1 byte (an enum without data) to 24 bytes. No Result<_, Error> fit in registers anymore. The verify functions of the schema types (Transaction::verify, FromTable::verify of Transaction and Entry, ListR::verify) were called out of line (without #[inline]), so each message paid for the call and for returning the Result<(), Error> through memory. Evidence from an isolated experiment (a copy of the code, no repository file touched): with Error back at 1 byte the time drops from 57 to 45 ns (equal to the pre-plan 44); with a 12-byte Error (u32 fields) it does not change (57). Only #[inline] on the verify functions, keeping the 24-byte Error, brings the time to 39.5 ns.

Change. #[inline] on the 11 verify functions in crates/zidl-schemas/src/{bank,game,iot,stream,lib}.rs (FromTable::verify of each table, the public verify of each root, ListR::verify). No change to unsafe code, to Error or to the structure of the views (buildable only via verify). Coverage: the existing tests (verified.rs, equivalence, conformance and the required-mask proptest) exercise these same paths; cargo test --workspace still has 174 passing, 0 failing. There was no behavior change, so no new test was needed.

Result (interleaved A/B, median of 3 runs, ns):

benchmark pre-plan (t1) post-Task 4 / pre-Task 5 (t5-base) current current / pre-plan current / t5-base
write/zidl/bank/1 30.5 31.3 31.6 1.03x 1.01x
write/zidl_known/bank/1 22.3 23.2 22.9 1.03x 0.99x
write/zidl/bank/10 91.2 86.5 89.8 0.98x 1.04x
write/zidl_known/bank/10 56.1 57.7 54.8 0.98x 0.95x
write/zidl/bank/100 686.1 652.1 679.3 0.99x 1.04x
write/zidl_known/bank/100 373.7 394.0 385.0 1.03x 0.98x
read_all/zidl/bank/1 32.8 33.2 32.5 0.99x 0.98x
read_all/zidl_trusted/bank/1 6.5 7.2 7.3 1.12x 1.01x
read_all/zidl/bank/10 89.1 93.0 84.5 0.95x 0.91x
read_all/zidl_trusted/bank/10 26.2 29.1 23.5 0.90x 0.81x
read_all/zidl/bank/100 682.9 799.3 677.4 0.99x 0.85x
read_all/zidl_trusted/bank/100 224.2 203.1 197.6 0.88x 0.97x
read_all_verified/zidl/bank/1 43.5 58.1 41.0 0.94x 0.71x
read_all_verified/zidl/bank/10 120.3 130.7 110.8 0.92x 0.85x
read_all_verified/zidl/bank/100 911.2 898.3 882.0 0.97x 0.98x

(The "t5-base" column is the post-Task 4 / pre-Task 5 snapshot, which already carried the Task 1 step.) No regression above 5% of the pre-plan, except zidl_trusted bank/1: +0.8 ns (6.5 → 7.3 ns, +12%), a known and accepted deviation from the ±5% criterion (~1 ns absolute); in t5-base it already measured 7.2 ns. The gains of 0.88–0.92x (for example read_all/zidl_trusted/bank/10 and bank/100) are not wins of the plan: they suggest code layout noise, since no change optimized those paths.

Telemetry writing: a known and accepted deviation from the ±5% criterion (~1 ns absolute). write/zidl/telemetry, at least 3 interleaved runs, ns: t1-base 5.44 · t1-head 5.99 · t2-base 5.96 · t3-head 5.95 · t4-base 5.99 · t4-head 6.15 · current 6.47. The main step (+0.55 ns) came in with Task 1, not Task 4 (+0.15 ns, within the noise). The likely cause (not tested) is the same as for verified bank/1: the larger Error in the Result of finished(). Reference value: +0.7 ns (5.4 → 6.1 ns), measured in two sessions with the code before the #[inline] fix (5.3 → 6.1 in the small-scenario A/B; 5.4 → 6.1 in the t4 series). The sessions differ because the measurements were taken at different times and loads; the last session, with the code after the #[inline] fix, measured 6.47 ns (+1.0 ns relative to 5.44), at the 1 ns limit. The ~0.3 ns difference between 6.1 and 6.47 may be code layout (the #[inline] fix in zidl-schemas changes code generation) or load; it was not isolated. It was not optimized.

Wrap-up (Step 4)