ADR 0088 Phase 0b Napkin: cerl-ETF Wire Mechanism + Timing

Date: 2026-05-28 Issue: BT-2315 (part of epic BT-2313, Wave 2 — Phase 0a is BT-2314) Evaluator: end-to-end cerl-ETF round-trip on the existing OTP Port, plus encode/decode/compile timings on two fixtures Status: Recommendation — Proceed with ETF as the Phase 1 wire. ETF encode+decode is a small share (<5%) of per-compile cost on both fixtures; the ADR's pivot-to-Alternative-7 gate does not fire.

Executive Summary

Phase 0b is the wire-mechanism napkin. It answers two questions:

  1. Does the cerl-ETF Port contract actually work end-to-end? — Rust constructs a cerl term, encodes to ETF, ships it over the existing Port wire as {cerl, Etf}, BEAM decodes via binary_to_term/2 with [safe], compile:forms/2 produces a .beam, code:load_binary/3 installs it, and module_info(module) returns the expected name.
  2. Is ETF encode/decode a meaningful fraction of per-compile cost? — The ADR's commitment to ETF over Alternative 7 (Port + NIF-for-conversion) is contingent on this number being small. If ETF dominates, the right move is to pivot to a NIF before Phase 1.

Answers:

  1. Yes — both compile paths (beamtalk_compiler_server, no debug_info; beamtalk_build_worker, with debug_info) accept the {cerl, Etf} variant, decode via binary_to_term/2 with [safe], compile through compile:forms/2, and produce loadable BEAM modules. Byte-equivalence between the Rust ETF encoder and term_to_binary(cerl:c_module(...)) is asserted in both Rust and Erlang unit tests for the empty-module and minimum-module shapes.
  2. ETF is a small share. On a representative ~1500-node module the ETF encode+decode pair is ~3.4% of total per-compile time. On the smallest possible (module_info-only) module the share is ~2.4%. compile:forms/2 dominates both cases. The pivot-to-Alternative-7 gate does not fire.

Recommendation: Proceed with Phase 1 (ETF wire) as committed in ADR 0088. Phase 0a's qualified shrinkage signal (~10–12%) plus this phase's "ETF cost is negligible" signal together support Phase 1 work, if the wider epic gates pass. The independent typed-Document-leaves follow-up (Phase 0c, suggested by the Phase 0a memo) still merits the same three-function comparison before final commitment — Phase 0b's timing data does not refute it, only the alternative-NIF pivot.

What was built

Wire-mechanism napkin

ComponentLocation
Minimum cerl Rust mirrorcrates/beamtalk-core/src/codegen/core_erlang/cerl.rs (~430 LOC + 280 LOC tests)
{cerl, Etf} Port arm (server, no debug_info)runtime/apps/beamtalk_compiler/src/beamtalk_compiler_server.erl (compile_core_erlang/1)
{cerl, Etf} Port arm (build worker, with debug_info)runtime/apps/beamtalk_compiler/src/beamtalk_build_worker.erl (compile_core_erlang/1)
End-to-end EUnit suite (both paths)runtime/apps/beamtalk_compiler/test/beamtalk_cerl_wire_tests.erl (11 tests)
Byte-equivalence Rust↔Erlangcerl.rs::tests::{truly_empty,minimum}_module_matches_otp_term_to_binary + Erlang empty_module_etf_layout_test
Reference-bytes regeneratorscripts/cerl-napkin-dump.escript
Rust-side timing harnesscrates/beamtalk-core/examples/cerl_napkin_timing.rs
Erlang-side timing harnessscripts/cerl-napkin-timing.escript

The cerl Rust mirror covers c_module, c_literal (atom-only payload), c_var (atom name + {Name, Arity} fun-reference name), c_fun, and c_call — the minimum needed for an empty self-identifying module (one that exposes module_info/0,1, the acceptance criterion's probe).

The acceptance criterion's phrasing — "minimum cerl node set needed for an empty module: c_module, c_var, c_literal, annotation wrapper" — nominally excludes c_fun and c_call. We extend slightly here because compile:forms/2 with from_core does not auto-inject module_info (unlike from_erl), so a truly empty cerl module compiles and loads but fails the AC's module_info(module) =:= bt_napkin check. The two extra node kinds (and a broadened c_var) cost ~120 LOC; the wire-format verification still operates on a tree that is dramatically smaller than the Phase 1 mirror's expected ~800–1500 LOC. The byte-equivalence test for the truly empty (no-defs) shape is retained so the smallest possible record layout is still pinned.

[safe] decode invariant

Both compile_core_erlang/1 clauses call binary_to_term(Etf, [safe]) — the atom-table-safety guarantee from ADR 0022. The cerl record tags (c_module, c_literal, c_var, c_fun, c_call) are a fixed finite set already known to the VM (the OTP cerl module exports them), and Beamtalk-generated names (module names, function-ref {Name, Arity} atoms, 'X' and the like) are pre-allocated by the existing text path before any Phase 0b traffic could exist in production. The {error, {cerl_decode_error, ...}} failure mode on malformed ETF is verified by cerl_variant_rejects_malformed_etf_test and build_worker_cerl_variant_rejects_malformed_etf_test.

The timing escript (which runs in isolation, without prior Beamtalk compile traffic) has to pre-touch the atoms via core_scan:string/1 on the equivalent text fixture before it can decode the cerl ETF — this is called out in the script and is not a constraint on production traffic.

Timing data

Methodology:

Empty module (425 bytes ETF / 242 bytes text)

PhaseBest (ns)
Rust ETF encode1,990
cerl: BEAM decode (binary_to_term)4,000
cerl: compile:forms/2243,257
cerl: total (decode + compile)247,542
text: core_scan:string/112,951
text: core_parse:parse/13,283
text: compile:forms/2250,618
text: total (scan + parse + compile)268,089

.beam output size is identical between the two paths (448 bytes).

Many-function module (19,092 bytes ETF / 10,159 bytes text)

PhaseBest (ns)
Rust ETF encode121,042
cerl: BEAM decode (binary_to_term)165,754
cerl: compile:forms/28,118,780
cerl: total (decode + compile)8,294,428
text: core_scan:string/1703,129
text: core_parse:parse/146,054
text: compile:forms/28,437,450
text: total (scan + parse + compile)9,223,468

.beam output size is identical between the two paths (10,956 bytes).

Cost-share analysis

For the cerl wire, the question is: what fraction of total per-compile time is the ETF encode + decode pair? (The compile:forms/2 cost is unavoidable on both wires.)

FixtureRust encodeBEAM decodeETF totalcerl totalETF share of cerl total
Empty1,990 ns4,000 ns5,990 ns249,532 ns2.4%
Many-fn (~1500 nodes)121,042 ns165,754 ns286,796 ns8,415,470 ns3.4%

(Rust encode is included as part of "total" by summing it into the cerl-side budget. The "cerl total" column above is Rust encode + BEAM decode + compile; the percentages reflect ETF cost vs total pipeline including the Rust encode.)

For the text wire, the analogous overhead is core_scan + core_parse. Same fixtures:

Fixturescan + parsetext totalscan+parse share of text total
Empty16,234 ns266,851 ns6.1%
Many-fn749,183 ns9,186,633 ns8.2%

So:

Recommendation against the ADR's pivot gate

ADR 0088 commits to ETF over Alternative 7 (Port + NIF-for-conversion) explicitly contingent on this napkin's timing data:

If ETF cost dominates for representative-sized modules, the right move is to pivot to a NIF-based conversion before Phase 1, not to ship ETF anyway.

ETF cost does not dominate. On the largest fixture measured here (~1500 nodes, ~19 KB ETF — already larger than the ADR's "representative real Beamtalk module"), the ETF encode+decode pair is 3.4% of total per-compile time. The compile step (compile:forms/2) is the overwhelming cost driver at 96.4%.

The NIF alternative's claimed win is "heap-resident term construction with literal sharing" — a measurable win when ETF encode/decode is on the critical path. With ETF at ~3.4% of total, even a hypothetical NIF that eliminated the encode/decode entirely would buy back at most ~3.4% of per-compile latency, at the cost of:

The trade is not justified by the data. Alternative 7 should remain deferred. Reopening it as a separate ADR is appropriate only if a later, larger-fixture measurement shows ETF cost dominating qualitatively — not on the data we have here.

What this does and does not say

Says:

Does not say:

Files added/modified

No regressions

References