|
| 1 | +# 0026 — The dev loop assembles next to the engine |
| 2 | + |
| 3 | +**Status:** proposed · 2026-07-30 · adversarially reviewed the same day. |
| 4 | +The review demonstrated the decision's load-bearing assumption rather |
| 5 | +than refuting it — cross-assembler, cross-module rec-group identity |
| 6 | +(§3b) — and changed four things, marked *(review)*: the cold-start and |
| 7 | +size-scaling costs stated next to the headline median, the committed |
| 8 | +differential script with the interim "done" rule, the skew watcher named |
| 9 | +as that same rule, and the preamble-growth falsifier with its |
| 10 | +import-shaped exit. |
| 11 | + |
| 12 | +## The question |
| 13 | + |
| 14 | +`0009` made the dev-mode output format a decision that cannot be |
| 15 | +retrofitted, and its own precondition was a cost measurement before S3 |
| 16 | +commits to one. `0022` A ran the first half — `wasm-tools parse` spawns |
| 17 | +at ~23 ms median per form, so a 300-form namespace `require` through a |
| 18 | +per-form dev loop is ~7 s of assembler spawns — and named four |
| 19 | +candidates, none examined: assembling near the engine (binaryen.js / |
| 20 | +wabt.js), a persistent assembler process, batching forms per flush, and |
| 21 | +TeaVM's WasmGC binary writer. This note examines them and decides. |
| 22 | + |
| 23 | +## The decision |
| 24 | + |
| 25 | +1. **The compiler emits WAT text in both modes — one emitter core.** |
| 26 | + `0022` A's stated preference ("the two should share an emitter |
| 27 | + core"), now affordable because: |
| 28 | +2. **The dev loop assembles in-process, next to the engine, with |
| 29 | + binaryen.js.** Measured 2026-07-30 (binaryen.js 131.0.0 from npm, |
| 30 | + node v26.3.0, Apple M4 Pro; probe in this survey's session): |
| 31 | + parse+emit of a real emitted module (`fn-defn-fib.dev`, 538 B binary) |
| 32 | + is **1.28 ms median** (min 1.19, max 2.52 over 50 in-process reps) |
| 33 | + against the measured 23 ms spawn — **18×** — and V8 |
| 34 | + compile+instantiate of the result adds 0.005 ms. The 7-second |
| 35 | + 300-form `require` becomes ~0.4 s. Two honest costs the median hides |
| 36 | + *(review)*: a session pays **~170 ms once** (importing binaryen.js |
| 37 | + ~134 ms plus a ~33 ms first assemble), and the per-form cost is |
| 38 | + **linear in module text** (4.0 KB → 0.92 ms, 8.7 KB → 1.94 ms) — |
| 39 | + consequences under "what would falsify this". V8 cannot instantiate |
| 40 | + text, so *something* must assemble on the engine side regardless |
| 41 | + (`0022` A); this makes that something the whole answer. |
| 42 | +3. **The evidence is the whole corpus, not one module.** All 113 emitted |
| 43 | + corpus modules (both modes) assembled through binaryen.js agree with |
| 44 | + `wasm-tools`' binaries on every outcome — every value, every trap. |
| 45 | + The probe is committed as `corpus/devloop_differential.mjs`, and |
| 46 | + **re-running it is part of "done" for any change to the emitted |
| 47 | + grammar** until the dev lane itself is in the gate *(review — the |
| 48 | + interim rule; weak enforcement, honestly labeled, and the gate lane |
| 49 | + is the recorded stronger fix the first time drift actually happens)*. |
| 50 | + That differential found two silent-wrong-binary failures on the way, |
| 51 | + which are now constraints: |
| 52 | +3b. **Cross-assembler, cross-module type identity holds — demonstrated, |
| 53 | + not assumed** *(review — it is the fact `0009`'s dev-mode heap |
| 54 | + sharing rests on)*: a module exporting `make : [] → (ref $Fn)` links |
| 55 | + into a separately assembled importer through `call_ref`, in both |
| 56 | + pairings (binaryen→binaryen and wasm-tools→binaryen), and |
| 57 | + `wasm-tools print` shows binaryen preserves the rec group verbatim. |
| 58 | + One caveat came with the proof: binaryen prunes unreachable types, |
| 59 | + so an emitted group must stay reachable or stay absent — never |
| 60 | + half-referenced. |
| 61 | +4. **Feature flags are explicit — never `Features.All`, never none.** |
| 62 | + With no `setFeatures` call, binaryen's writer silently degrades |
| 63 | + `(ref null eq)` to `anyref` and the binary fails validation on the |
| 64 | + engine; with `Features.All`, the writer emits *exact* heap types |
| 65 | + (custom-descriptors, off by default in stable V8) and fails the same |
| 66 | + way. The dev lane sets exactly the features the emitter uses — the |
| 67 | + same discipline the `wat` skill already records for `wasm-opt`, now |
| 68 | + with its "All is as wrong as none" half. |
| 69 | +5. **The emitter stays in the folded, non-stacky subset of WAT.** |
| 70 | + Binaryen's parser is an AST, not a stack machine: bare stacky |
| 71 | + `local.set`s (the first `recur` emission) materialize as `anyref` |
| 72 | + scratch locals, lose the eq refinement, and fail validation. |
| 73 | + `emit-recur` now rebinds through explicit temporaries. The |
| 74 | + constraint's mechanical pin — a binaryen.js lane in the harness — |
| 75 | + lands with the nREPL unit that actually wires the dev loop; until |
| 76 | + then this survey's 113/113, dated today, is the evidence. |
| 77 | +6. **`wasm-tools parse` stays the prod/batch assembler and the oracle's |
| 78 | + reference lane.** Two independent assemblers agreeing on the corpus |
| 79 | + is coverage, not redundancy — the same argument the gate already |
| 80 | + makes for two clj-kondos. |
| 81 | + |
| 82 | +## Why |
| 83 | + |
| 84 | +- **wabt.js is out, checked by running it** (1.0.39, 2026-07-30): its |
| 85 | + parser rejects `(rec …)` at the first token — the corpus's shared |
| 86 | + fn substrate cannot be expressed. Second instrument, per the survey |
| 87 | + rule on negative claims: the Supported Proposals table in wabt's own |
| 88 | + README omits GC entirely |
| 89 | + (<https://github.com/WebAssembly/wabt>, checked 2026-07-30). |
| 90 | +- **binaryen.js is the same project as the pinned `wasm-opt`** — one |
| 91 | + toolchain relationship, not a new one; the skew (npm 131 vs flake's |
| 92 | + 129) is real and recorded below. |
| 93 | +- The measured per-form cost sits well below any editor-latency |
| 94 | + convention, with the margin available for the compiler's own |
| 95 | + analyze/emit time. |
| 96 | + |
| 97 | +## Alternatives rejected |
| 98 | + |
| 99 | +- **wabt.js** — cannot parse the output (measured; above). |
| 100 | +- **A persistent assembler process.** It amortizes the spawn cost that |
| 101 | + in-process assembly removes entirely, and pays for it with process |
| 102 | + lifecycle management in every editor session. Nothing is left for it |
| 103 | + to be better at. |
| 104 | +- **TeaVM's WasmGC binary writer.** A second, JVM-side emitter core — |
| 105 | + exactly what `0022` A's shared-core preference exists to avoid — and |
| 106 | + on the wrong side of the wire: `0009`'s dev loop instantiates in the |
| 107 | + engine's world, so JVM-side binaries still cross to the engine, while |
| 108 | + WAT text crossing the wire keeps the payload readable in every |
| 109 | + debugging session. Unneeded at 1.3 ms. |
| 110 | +- **An own binary writer** — already rejected with evidence in `0022` A |
| 111 | + (clj.wasm's recorded stall); nothing here reopens it. |
| 112 | +- **Batching as the primary mechanism.** Still trivially available on |
| 113 | + top (one flush, one module), but at 1.3 ms/form nothing forces it, |
| 114 | + and per-form modules are what `0009`'s open world wants. |
| 115 | + |
| 116 | +## What would falsify this |
| 117 | + |
| 118 | +- **A text shape the emitter later needs that binaryen's parser lacks** |
| 119 | + — it already dictated non-stacky emission once. Surfaces mechanically |
| 120 | + once the dev lane is in the gate; until then, |
| 121 | + `corpus/devloop_differential.mjs` is the check, and running it is |
| 122 | + part of "done" for emitted-grammar changes. |
| 123 | +- **The version skew biting**: npm binaryen.js and the flake's binaryen |
| 124 | + are different builds of one project (131 vs 129 today). `tools.json` |
| 125 | + gains the dev assembler's version when the nREPL unit lands; until |
| 126 | + then the committed differential is also the skew watcher — the same |
| 127 | + interim rule, named once *(review)*. |
| 128 | +- **The margin is a function of the runtime preamble** *(review)*: |
| 129 | + every per-form module re-parses the whole preamble, and assembly cost |
| 130 | + is linear in it — a 100 KB core-library preamble puts the per-form |
| 131 | + cost near 20 ms and the win is gone. The exit is already the shape |
| 132 | + `0009`'s shared heap wants: dev-mode forms *import* the shared |
| 133 | + runtime instead of re-declaring it. If that lands, this note's |
| 134 | + numbers should be re-taken for the import-shaped module. |
| 135 | +- **A browser measurement disagreeing with node's.** This survey |
| 136 | + measured node; one run characterises one path. The browser tab is the |
| 137 | + nREPL/browser unit's first measurement, and 1.28 ms has enough margin |
| 138 | + that only an order-of-magnitude surprise reopens the decision. |
0 commit comments