Generated code (zidlc) against the hand-written code
Historical document. Written on 2026-11-13, when the project was called zidl (later Graven, with the compiler
gravenc).
Date: 2026-11-13 · Plan 2 (the compiler), Task 8:
zidl-schemas drops the hand-written code and includes the code generated by zidlc via
build.rs.
Machine: Intel Core i9-14900HX, Linux 7.3, rustc 1.94.0, --release ([profile.bench] with
debug = true).
Machine load: 1-minute average between 1.7 and 4.6 during the runs (a browser open in the
background).
The plan's criterion: within ±5% of the pre-plan state, measured with interleaved A/B runs.
Methodology
- A (pre-plan): the
pre-plansnapshot, the tree before Task 1 of this plan (hand-written code, already with the#[inline]fix from the envelope report). The snapshot has noCargo.lock. I used the currentCargo.lock, so the third-party dependencies (criterion, bytes, flatbuffers, capnp) have the same versions on both sides. - B (generated): the current tree.
- C (t8-base, control): the
t8-basesnapshot, that is, the end of Task 7: hand-written code over the current runtime. It is the control that isolates Task 8 (C → B) from Tasks 1–7 (A → C). - The bench binaries (
cargo bench -p zidl-bench --bench codec --no-run) were built once per side, each with its ownCARGO_TARGET_DIR, and then run directly. Thezidl-benchcrate is identical on all three sides. - Interleaved: in each round, each group runs first on one side and right after on the other. The order of the sides alternates between rounds. Each value is the median of 3 rounds.
- Reduced times, equal on both sides:
--warm-up-time 1 --measurement-time 3 --noplot. The criterion default is 3 s and 5 s; I reduced them to fit in the time available. - Filters: only the zidl rows.
^write/zidl,^read_all/zidl,^read_one/zidl,^read_all_verified/zidland^envelope/(this whole group is zidl).
Commands (equivalent): cargo bench -p zidl-bench --bench codec -- <group> --warm-up-time 1 --measurement-time 3
for write, read_all, read_one, read_all_verified and envelope.
First measurement: two real code differences
In the first A/B round (the generated code as Task 7 left it), two groups of rows were consistently outside ±5%: the 3 measurements of B were above the 3 of A.
| row | pre-plan | generated | Δ |
|---|---|---|---|
| read_all_verified/zidl/bank/10 | 111.2 | 123.3 | +10.9% |
| read_all_verified/zidl/bank/100 | 876.7 | 978.2 | +11.6% |
| envelope/read_verified_one/bank/10 | 86.2 | 99.8 | +15.8% |
| envelope/read_verified_all/bank/10 | 110.6 | 124.9 | +13.0% |
| read_all_verified/zidl/chunk/64 | 4.4 | 5.2 | +17.0% |
A 3-sided A/B (A, C and B, interleaved, median of 3) showed that these differences came from
Task 8, that is, from generated against hand-written code over the same runtime. Examples:
envelope/read_verified_one/bank/10 measured C 87.3 and B 101.6 (+16%);
read_all_verified/zidl/chunk/64 measured C 4.7 and B 5.3 (+14%).
Bisection (binaries with a single generated file swapped into t8-base, interleaved, median of 3, ns):
| row | C (by hand) | only stream generated |
only bank generated |
bank generated with the hand-written order |
|---|---|---|---|---|
| envelope/read_verified_one/bank/10 | 94.8 | — | 104.2 | 94.8 |
| read_all_verified/zidl/bank/10 | 120.5 | — | 128.1 | 119.4 |
Causes and fixes, both in the codegen (crates/zidl-compiler/src/codegen/read.rs):
- The
verifyorder ofbank.Transaction. The generated code verified the fields in ID order (entries @5and only thennote @6). The hand-written code verifiednotefirst and then the list. Just swapping the order in a generatedbank.rsbrings back the hand-written time (table above). Fix: the generatedFromTable::verifynow visits the leaf data first (string,bytes, lists without tables) and then the fields that descend (a nested table, a union,[Table]), in ID order within each group. The helper function isdescends(). This changes no result: the precedence between errors is not normative (spec §7.5) and the budget's sum does not depend on the order (§7.3). The gates still pass: conformance vectors,budget_totals_unchangedand the mutation gate. - The shape of the charge for a required
string/bytes(stream.Chunk.data). The generated code emittedif let Some(b) = self.0.bytes(..)? { ctx.charge(4 + b.len())?; }. The hand-written code emittedctx.charge(4 + data.len())?unconditionally. LLVM orders the blocks differently in the two cases. Fix: for a required field, the generated code now emitsctx.charge(4 + self.0.bytes(..)?.unwrap_or(&[]).len())?(and the equivalent withstr). Theunwrap_orarm is unreachable, becausefrom_refalready rejected the absent pointer withMissing. With that, the machine code ofread_all_chunk_verifiedis identical to the hand-written one.
The snapshots in crates/zidl-compiler/tests/snapshots were re-recorded. The diff contains
only these two changes, plus the sentence in the verify doc.
Proof that the code is the same
I disassembled (objdump -d) the 116 functions with zidl in their name from the bench
binary (the zidl_bench::zidl_impl::* ones with everything inlined into them, plus the
runtime functions that were not inlined). I compared the final binary (B) with the
hand-written t8-base (C), normalizing addresses and the numbers of anonymous symbols.
- 111 of 116 functions are identical instruction for instruction, among them every bank, frame and chunk path: write, read_all, read_one, verified, trusted, envelope.
- The 5 that differ are the telemetry ones (
read_all_telemetry,read_all_telemetry_verified,read_one_telemetry,verified_one_telemetry,from_bytes_one_telemetry), with +2 instructions each. The cause is the order ofTELEMETRY_REQ: the generated code lists the required fields in ID order, the same order as the conformance interpreter, per a Task 3 decision. The hand-written code put theReq::Slotofhumidityfirst. It only changes where theTruncatedcheck of the partial slot sits. I did not change it. The measured effect is ~0.1–0.2 ns, with a sign that varies between rounds (final table:read_one/telemetry−2.4%,read_all_verified/telemetry+14%,write/telemetry−11%).
The rest is code layout
With identical machine code, the remaining differences come from layout (the address and alignment of the functions and hot loops in the binary). Evidence:
- Same code, different binaries. In the bisection, the "only
bankgenerated" binary (K) has aread_all_chunk_verifiedbyte-for-byte identical to C's, and it still measured 5.34 ns against 4.82 (+11%). Only the function's address changed (0x139730vs0x1396c0). - Same code, same address, different sessions.
read_all_bank_verifiedis identical (and at the same address) in the binary after fix 1 and in the final binary, and it is also identical to the hand-written one. Against the pre-plan measured in the same session, it measured −2.8% (bank/10) in one round and +12.4% in the other. Between the two binaries only thestreamcode changed. - Rows nothing changed. Between the round after fix 1 and the previous one,
read_all/zidl/frame/500went from +18% to +42% against the pre-plan. Thegamecode did not change in that swap. - Aligned loops. With
RUSTFLAGS="-C llvm-args=-align-loops=64"on both sides (pre-plan and generated, before fix 2),read_all/zidl/frame/500dropped from +38–42% to +7%,read_all_verified/zidl/frame/500to +5%,read_one/zidl/frame/*stayed at −16% andread_all_verified/zidl/bank/*at −4 to −6%.
So the remaining differences against the pre-plan mix two things: (a) the runtime changes of Tasks 1–7 (A → C, different machine code, outside the scope of this measurement) and (b) layout. In Task 8 (C → B) the machine code is the same, except for telemetry.
Final table (pre-plan × generated, after both fixes)
Final binary, interleaved A/B, median of 3 rounds, ns. ⚠ = outside ±5%. See the previous sections: in the bank, frame and chunk rows, the generated machine code is identical to the hand-written one (t8-base).
| group | row | pre-plan (by hand) | generated | Δ | n |
|---|---|---|---|---|---|
| write | zidl/bank/1 | 33.1 | 33.0 | -0.2% | 3/3 |
| write | zidl_known/bank/1 | 24.9 | 24.7 | -1.0% | 3/3 |
| write | zidl/bank/10 | 98.9 | 94.4 | -4.5% | 3/3 |
| write | zidl_known/bank/10 | 59.6 | 60.4 | +1.4% | 3/3 |
| write | zidl/bank/100 | 743.5 | 708.2 | -4.7% | 3/3 |
| write | zidl_known/bank/100 | 406.4 | 409.1 | +0.7% | 3/3 |
| write | zidl/frame/10 | 28.1 | 28.0 | -0.5% | 3/3 |
| write | zidl/frame/500 | 843.6 | 841.4 | -0.3% | 3/3 |
| write | zidl_owned/chunk/64 | 28.9 | 28.6 | -1.2% | 3/3 |
| write | zidl_batched/chunk/64 | 12.2 | 12.0 | -0.9% | 3/3 |
| write | zidl_owned/telemetry | 24.6 | 23.7 | -3.7% | 3/3 |
| write | zidl_batched/telemetry | 8.4 | 7.8 | -7.2% ⚠ | 3/3 |
| write | zidl_batched/frame/500 | 1024.1 | 1031.1 | +0.7% | 3/3 |
| write | zidl_owned/frame/500 | 989.8 | 980.6 | -0.9% | 3/3 |
| write | zidl/telemetry | 7.3 | 6.5 | -11.0% ⚠ | 3/3 |
| write | zidl/chunk/64 | 10.6 | 10.6 | +0.8% | 3/3 |
| write | zidl/chunk/65536 | 1042.9 | 1088.0 | +4.3% | 3/3 |
| read_all | zidl/bank/1 | 34.6 | 36.3 | +4.9% | 3/3 |
| read_all | zidl_trusted/bank/1 | 7.8 | 7.9 | +1.0% | 3/3 |
| read_all | zidl/bank/10 | 89.4 | 92.4 | +3.4% | 3/3 |
| read_all | zidl_trusted/bank/10 | 25.5 | 25.7 | +0.8% | 3/3 |
| read_all | zidl/bank/100 | 732.2 | 742.4 | +1.4% | 3/3 |
| read_all | zidl_trusted/bank/100 | 210.7 | 207.6 | -1.5% | 3/3 |
| read_all | zidl/frame/10 | 64.0 | 70.4 | +10.0% ⚠ | 3/3 |
| read_all | zidl/frame/500 | 2400.1 | 3318.8 | +38.3% ⚠ | 3/3 |
| read_all | zidl/telemetry | 1.6 | 1.7 | +10.2% ⚠ | 3/3 |
| read_all | zidl/chunk/64 | 2.6 | 2.9 | +11.2% ⚠ | 3/3 |
| read_all | zidl/chunk/65536 | 2.5 | 2.8 | +11.1% ⚠ | 3/3 |
| read_one | zidl/bank/1 | 1.5 | 1.8 | +23.2% ⚠ | 3/3 |
| read_one | zidl/bank/10 | 1.5 | 1.8 | +23.0% ⚠ | 3/3 |
| read_one | zidl/bank/100 | 1.5 | 1.8 | +23.2% ⚠ | 3/3 |
| read_one | zidl/frame/10 | 3.5 | 3.4 | -3.5% | 3/3 |
| read_one | zidl/frame/500 | 3.5 | 3.3 | -5.4% ⚠ | 3/3 |
| read_one | zidl/telemetry | 1.5 | 1.5 | -2.4% | 3/3 |
| read_one | zidl/chunk/64 | 1.5 | 1.7 | +10.7% ⚠ | 3/3 |
| read_one | zidl/chunk/65536 | 1.5 | 1.7 | +12.3% ⚠ | 3/3 |
| read_all_verified | zidl/bank/1 | 44.6 | 47.5 | +6.6% ⚠ | 3/3 |
| read_all_verified | zidl/bank/10 | 118.3 | 133.0 | +12.4% ⚠ | 3/3 |
| read_all_verified | zidl/bank/100 | 931.8 | 1037.3 | +11.3% ⚠ | 3/3 |
| read_all_verified | zidl/frame/10 | 72.9 | 81.3 | +11.6% ⚠ | 3/3 |
| read_all_verified | zidl/frame/500 | 2778.3 | 3759.3 | +35.3% ⚠ | 3/3 |
| read_all_verified | zidl/telemetry | 3.7 | 4.2 | +14.1% ⚠ | 3/3 |
| read_all_verified | zidl/chunk/64 | 4.8 | 5.9 | +21.5% ⚠ | 3/3 |
| read_all_verified | zidl/chunk/65536 | 4.9 | 5.8 | +17.8% ⚠ | 3/3 |
| envelope | write_owned/telemetry | 23.9 | 23.9 | +0.2% | 3/3 |
| envelope | write_enveloped/telemetry | 35.2 | 35.5 | +1.0% | 3/3 |
| envelope | read_verified_one/telemetry | 2.1 | 2.4 | +16.5% ⚠ | 3/3 |
| envelope | read_from_bytes_one/telemetry | 13.3 | 12.0 | -10.4% ⚠ | 3/3 |
| envelope | write_owned/chunk/64 | 29.1 | 28.7 | -1.3% | 3/3 |
| envelope | write_enveloped/chunk/64 | 43.5 | 43.7 | +0.4% | 3/3 |
| envelope | read_verified_one/chunk/64 | 2.9 | 3.1 | +5.1% ⚠ | 3/3 |
| envelope | read_from_bytes_one/chunk/64 | 13.6 | 12.3 | -9.3% ⚠ | 3/3 |
| envelope | write_owned/chunk/65536 | 1650.1 | 1639.1 | -0.7% | 3/3 |
| envelope | write_enveloped/chunk/65536 | 2775.0 | 2766.6 | -0.3% | 3/3 |
| envelope | read_verified_one/chunk/65536 | 3.0 | 3.1 | +4.7% | 3/3 |
| envelope | read_from_bytes_one/chunk/65536 | 1794.6 | 1802.6 | +0.4% | 3/3 |
| envelope | write_owned/bank/10 | 78.2 | 79.6 | +1.9% | 3/3 |
| envelope | write_enveloped/bank/10 | 118.6 | 117.6 | -0.9% | 3/3 |
| envelope | read_verified_one/bank/10 | 92.5 | 106.1 | +14.7% ⚠ | 3/3 |
| envelope | read_from_bytes_one/bank/10 | 119.8 | 126.4 | +5.5% ⚠ | 3/3 |
| envelope | read_verified_all/bank/10 | 120.3 | 133.7 | +11.1% ⚠ | 3/3 |
| envelope | read_from_bytes_all/bank/10 | 148.3 | 154.4 | +4.1% | 3/3 |
Verdict
| item | verdict |
|---|---|
Writing (write, envelope/write_*) |
✅ within ±5% in every row, except write/zidl(_batched)/telemetry, which got faster (−7% to −11%, ~0.7 ns) |
Checked reading of bank (read_all, zidl_trusted) |
✅ within ±5% |
| Real code differences introduced by the generated code | ✅ fixed in the codegen (the verify order; the charge for a required string/bytes). After that, 111/116 bench functions have machine code identical to the hand-written one |
Remaining ⚠ rows (frame, chunk, verified bank, read_one/bank) |
layout/alignment noise, accepted. The machine code is identical to the hand-written one. The same identical function swings from −3% to +12% between binaries. With aligned loops, frame/500 drops from +38% to +5–7% |
| telemetry (5 functions with +2 instructions) | accepted: the REQ order is ID order (a Task 3 decision, same as the interpreter). An effect of ~0.1–0.2 ns with a varying sign. Not changed |
The next plan that wants stable numbers against snapshots should pin the layout. Options:
build both sides with -C llvm-args=-align-loops=64 (or -align-all-functions), or compare
instruction counts (perf stat -e instructions) instead of time. This kind of measurement
can be off by ±10–40% because of layout in this binary.
Note: the final 5-sided round (A, C, B and A/B with aligned loops, every group) was cut short by the machine-time limit. The final table comes from the last complete 2-sided round. The control numbers (C, K, aligned loops) come from the focused rounds described above.