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:
- Envelope write overhead: median of 4 runs (3 from the
writegroup and 1 from theenvelopegroup). - Envelope read overhead: 1 run (the
envelopegroup), under load 0.5–4.5. - CRC32C: 1 run.
- Comparison with round 2 (
write,read_all,read_all_verified): median of 3 runs. - A/B against snapshots: median of 3 interleaved runs in the final table of the section "Fixing the regression"; at least 2 interleaved runs in the A/B tables before the fix; at least 3 in the telemetry bisection.
Single-run numbers (envelope reading, CRC) carry more uncertainty than the medians; read them as orders of magnitude.
Commands:
cargo bench -p zidl-bench --bench codec -- envelopecargo bench -p zidl-bench --bench codec -- crc32ccargo bench -p zidl-bench --bench codec -- writeand-- read_all(includesread_all_verified), 3 times each
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.
- Writing:
Builder::new_envelopedreserves 20 bytes before each message.flush_envelope(schema_id)fills the header in place, computes the CRC and returns aBytes. The message is not copied. - Reading:
X::from_bytes(buf)decodes the envelope (checks the magic, version, size and CRC), checks the schema id and runs the full verification. It is equivalent toX::verified(payload)plus the cost of the envelope.
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:
- The extra cost of reading is the CRC: zidl's full verification does not touch the data
bytes (
chunk/65536verifies in 4.9 ns), but the CRC walks the whole message. That is why the overhead grows with size: ~10 ns for messages of tens of bytes, 1.7 µs for 64 KiB. - For small messages the envelope costs 10 to 14 ns per side (writing and reading). In exchange you get integrity (CRC32C), a version and a schema check.
- For large payloads the cost is proportional to size, at ~40 GB/s (below): 1 µs per ~40 KB.
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:
- Task 4 changed
Builder::begin_msg(a prefix of 4 or 20 bytes). The ±5% write comparison was inconclusive because of machine load. - Task 5 changed the trusted/verified read path of
bank.
Since the machine's absolute speed may differ from round 2, there are controls:
- Absolute value against the round 2 table (column "Δ abs").
- 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.
- Direct A/B. The plan's snapshots (
snaps/t1-base= the pre-plan code,t4-*,t5-base) were built with the samerustcand 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:
- Cause of the verified bank/1 regression: see the next section. The initial attribution to Task 5 was wrong (the A/B only had the t5-base snapshot and the pre-plan, and t5-base already carried the step); the bisection by snapshots shows the step came from Task 1.
- Small inconclusive variations (≤ ~4 ns absolute, A/B without consistency between runs):
read_all/zidl/frame/10+8%,read_all_verified/zidl/chunk/64+10% (6.3 to 6.9 ns). fbs frame/10 got faster in the same comparison, which suggests a code layout effect rather than an algorithmic change. - Absolute differences against the doc in
read_all/zidl/bank/100(+11%): the A/B shows that the pre-plan code measures the same value today (690 vs 682 now), so it is the round 2 table that was ~9% faster. Not a regression of the plan.
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)
cargo test --workspace: 174 tests pass, 0 fail (also after the#[inline]fix).cargo build -p zidl --no-default-features: ok, 0 warnings.cargo clippy --workspace --all-targets: 5 warnings, all pre-existing (files identical to the pre-plan snapshot):zidl-bench/src/data.rslines 73, 85 and 97 (is_multiple_of),zidl/src/pool.rs:57(collapsibleif) andzidl/tests/fuzz_props.rs:78(unneededif let). One new warning inzidl/tests/envelope.rs(amatchwith a single pattern, in a test of this feature) was fixed toif let. Theenvelopetest still passes (23/23).