feat(vapor): sparse conditional constant propagation for the reactive graph - #267
Conversation
… graph The dependency masks the compiler bakes into ROM are a syntactic over-approximation: `flag.value ? a.value : b.value` subscribes to all three refs. This adds an SCCP pass that runs before setup analysis and asks which refs can ever hold more than one value at runtime. Every ref starts at its seed constant; write sites collected across the component body lower it, except writes behind a guard that is decidably false under the current environment. The pass is optimistic and iterates to a fixpoint, so mutually-gated refs converge to constants. A ref proven constant folds at every read: it registers no dependencies, decidable ternaries and ifs compile only the taken arm, and the dead arm's refs leave the effect mask - and the ROM - entirely. Folded refs keep their state slot, seed, and debug-block entry, so oracle and device grids stay comparable. Wiring in compile.ts: constNum folds SCCP-constant ref reads and prefix minus (fixing `ref(-1)` seeding state to 0 instead of -1); constBool folds !x and short-circuit &&/||; dead branches drop from if statements, ternaries, conditional rows, and view expressions; the graph report annotates folded refs. Conservative by design (v1): locals are not tracked, helper params are not-a-constant, loop bodies count as reachable, and only num/bool refs fold - str/list refs never do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Benchmark: what SCCP buys, measured end to endSince frame content is a pure function of tick index + input, the effect of this PR can be demonstrated exactly: same fixture, same 60-press button tape, per-frame grid hashes to prove the screens are identical, counters and timing to show what work disappeared. Fixture — a HUD in the shape real apps take: two refs fixed at build time, three live ones. const debugHud = ref(false); // build-time flag, never toggled in this build
const theme = ref(0); // reserved for v2, never written
const hp = ref(100);
const ammo = ref(30);
const score = ref(0);
onButton((b) => {
if (b === Button.A) ammo.value -= 1;
if (b === Button.B) hp.value -= 1;
if (b === Button.Select) score.value += 10;
if (debugHud.value) score.value += 1;
});
// rows: HP {debugHud.value ? score.value : hp.value} / theme ternary /
// AMMO {ammo.value} / SCORE {score.value} /
// {debugHud.value ? <row y={5}>DBG {score.value} {ammo.value} {hp.value}</row> : null}Reactive graph
Runtime, 60-press tape (generated C +
|
| main | this PR | |
|---|---|---|
| effect executions | 135 | 60 (−56%) |
| rows cleared+repainted | 135 | 60 |
VRAM rows recommitted (commit loop mirrored from vapor_gba.c) |
75 | 60 (−20%) |
| per-frame grid hash, all 60 frames | — | byte-identical |
The last row is the point: every eliminated execution was work that repainted pixels to the values they already had. On main, every ammo pickup re-runs the DBG-row effect (clear row, test debugHud, paint nothing), and every score change repaints an unchanged HP row.
Frame cost and ROM
Timing covers the full frame path — handler, flush, row commit — over 1.2M frames, uninstrumented C at -O2:
| main | this PR | |
|---|---|---|
| mean per-frame handler+flush+commit | 201.5 ns | 115.0 ns (−43%) |
hud.gba (arm-none-eabi-gcc, real build pipeline) |
6372 B | 6092 B (−280 B) |
todo.gba (control) |
9.1 KB | byte-identical |
Host x86 timing is a proxy; the eliminated work is full-row clears, per-cell glyph paints and integer formatting — memory-bound loops that ARM7TDMI cannot hide behind any microarchitecture, so the relative saving on device should be at least this. The counters above are architecture-independent.
Compiler cost
todo.tsx 2.00 → 1.83 ms/compile, hud.tsx 0.32 → 0.38 ms/compile (300 iterations after warmup): the pass — one AST walk to collect write sites plus a fixpoint over a ≤16-ref lattice — is under 0.1 ms and inside the noise.
Honest boundary
The benefit is proportional to how much build-time-fixed state an app carries (feature flags, platform switches, reserved fields, difficulty presets). The todo demo has none, and its ROM is byte-identical — the only textual change in its generated C is (-1) vs -1 from the prefix-minus fold. An app where every ref is genuinely mutated sees exactly zero change.
🤖 Generated with Claude Code
Validation update: the three-console parity suite passes on this branchFollow-up to the synthetic benchmark above — two more validation layers, both against real code. Every Pocket Vapor app in the repo, compiled by both compilers
None of the shipped examples carry a foldable ref — every ref is genuinely mutated — so the pass is a strict no-op on all of them. That is the regression story: apps without build-time-constant state compile to the same bytes. The full oracle↔ROM suite, on Linux, on this branchWith the toolchain-path overrides from #268 (libmgba 0.10 built from source, sdcc/rgbds/cc65 from Linuxbrew): That includes the three-console parity run: the todo ROMs produced by the SCCP compiler, driven through the 31-press tape under libmgba (GBA, GB) and jsnes (NES), compared cell-for-cell — characters and palettes, logical grid and decoded video memory — against real Vue 3.6 running the same tape, with tripwires asserted zero. Every folding decision this pass makes is downstream of that oracle. 🤖 Generated with Claude Code |
|
Verdict: merging. Reviewed on a local checkout; every load-bearing claim below was reproduced, not read. The bonus fix is a real parity bug, reproduced on base. The soundness prerequisites hold empirically. The pass is only sound if writes can't hide from Suite: 75/75, 7414 assertions on this machine — including the three-console oracle↔ROM parity your environment couldn't run (mgba headers present here). One footnote on the description: "Todo demo output is unchanged" is true for the graph, masks, and memory plan (verified byte-identical against base on gba/gb/nes), but the C text differs by 3 cosmetic lines — the new prefix-minus folding emits Thanks @lfkdsk — an optimistic-fixpoint pass whose soundness argument leans on subset rules the compiler already enforces (and says so) is exactly how vapor's compiler was meant to grow. The oracle-parity preservation (folded refs keep slot, seed, and debug entry) shows real care for the determinism contract. |
What
The dependency masks the compiler bakes into ROM are a syntactic over-approximation:
flag.value ? a.value : b.valuesubscribes to all three refs. This PR adds a sparse conditional constant propagation pass (vapor/compiler/sccp.ts) that runs before setup analysis and asks a prior question: which refs can ever hold more than one value at runtime?A ref whose every reachable write either sits behind a decidably-false guard or stores the value the ref already holds is a compile-time constant. Its reads fold, decidable ternaries and
ifs compile only the taken arm, and the dead arm's refs leave the effect mask — and the ROM — entirely.How
const(seed)and writes only lower it. Guard reachability is judged under the current environment, so mutually-gated refs converge (if (a.value) b.value = true; if (b.value) a.value = true;with both seededfalsekeeps both constant — a pessimistic pass could not conclude that).constNumfolds SCCP-constant ref reads, so the existing ternary/branch folding paths do the rest;constBoollearns!xand short-circuit&&/||; dead branches drop from if statements, ternaries, conditional rows, and view expressions; the graph report annotates folded refs (bit 0: flag (bool) = const 0 (sccp: reads folded, never dirty)).Bonus fix
constNumnow folds prefix minus. Seeding usedconstNum(seed) ?? 0, soref(-1)previously initialized device state to0while the oracle held-1. The parity suite never caught it because the todo demo has no negative seeds; a regression test is included.Tests
vapor/tests/sccp.test.ts— 8 new tests: mask splitting for never-written refs, guard-pruned write chains (conditional propagation), optimistic mutual-gate convergence, no-fold for genuinely mutated refs, same-value writes, dep-free computeds, debug-slot/seed preservation, negative seeds.bun run vapor:test: 69/70 pass; the one failure is the pre-existing libmgba harness build on machines without mgba headers (fails identically on a clean checkout).vapor/DESIGN.md§2 documents the refinement.🤖 Generated with Claude Code