Skip to content

fix(selector): spill rung stores before clobber — never reload a never-stored slot (#581) - #582

Merged
avrabe merged 1 commit into
mainfrom
fix/581-direct-spill-rung
Jul 2, 2026
Merged

fix(selector): spill rung stores before clobber — never reload a never-stored slot (#581)#582
avrabe merged 1 commit into
mainfrom
fix/581-direct-spill-rung

Conversation

@avrabe

@avrabe avrabe commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

The bug (#581 — shipped-path silent miscompile, #518 severity class)

The direct selector's spill rung (the backend's exhaustion retry, VCR-RA-001 3b-lite) emitted a reload ldr r0, [sp,#8] of a frame slot that was never stored: scripts/repro/spill_on_exhaust_242.wat compiled --relocatable returns 0xffffd1ce for hp(1,2) where wasmtime says 0x2e37.

Named root cause

The #253 add/sub (and bitwise/const-addr) immediate folds delete the const materialization by source_line — which also deleted the spill store the const's own temp allocation emitted under the spill rung (alloc_temp_or_spillspill_deepest_reg tags the victim's STR rX,[sp,#slot] with the const's line), leaving the victim's vstack entry marked Spilled with no store. The victim's register was then legitimately reassigned (the mul.w-adjacent clobber the issue observed), and its later pop reloaded garbage from the never-written slot.

Three sites shared the defect:

Fix (store-before-clobber invariant restored)

Red → green

scripts/repro/spill_rung_581_differential.py (unicorn vs wasmtime, direct path, symbols from the ELF symtab) runs the new minimal fixture spill_rung_581.wat (7 live binop results + i32.const 77; i32.sub fold — the smallest fold-deletes-store shape) plus the original #580 discovery fixture, 6 arg pairs each:

  • main: ORACLE FAIL — minimal hp(1,2) = 0x4e vs 0x51; original hp(1,2) = 0xffffd1ce vs 0x2e37 (the recorded values)
  • this branch: ORACLE PASS, 12/12

Gates

  • unit fold_preserves_spill_store_581 (every spill-area reload has a matching store; the imm fold still drops the MOVW)
  • frozen fixtures 3/3 bit-identical (frozen_codegen_bytes: ARM + RV32 + stack-fwd escape hatch) — the default pass never tags a spill store with a const's line, so shipped bytes are unchanged by construction
  • r12_spill_496_differential.py PASS, AAPCS oracle (audit_aapcs_repro) 5/5
  • cargo test -p synth-synthesis -p synth-cli green (41 test binaries, 0 failures)
  • cargo fmt --check + clippy --all-targets -D warnings clean

Scope note

The fix covers all three by-source_line deletion sites in the direct selector. The defensive validator is linear-scan (store-before-reload in emission order, which is how the vstack machinery always orders them) and is defense-in-depth behind the filters — slot reuse can mask a lost second-generation store from the scan, but no such deletion path remains.

Closes #581

🤖 Generated with Claude Code

…r-stored slot (#581)

The direct selector's #253 add/sub (and bitwise/addr) immediate folds delete
the const materialization BY source_line. Under the spill-on-exhaustion retry
rung (VCR-RA-001 3b-lite), alloc_temp_or_spill for the const's temp first
emits the victim's spill store `STR rX,[sp,#slot]` tagged with the SAME
source_line — the fold deleted the store along with the MOVW, leaving the
victim's vstack entry marked Spilled with no store. Its later reload read a
never-written frame slot: silent wrong value on the shipped --relocatable
path (spill_on_exhaust_242.wat hp(1,2) = 0xffffd1ce vs wasmtime 0x2e37).

Fix: `is_const_materialization` (MOVW/MOVT/MVN) filters every by-source_line
drop — drop_prev_const_materialization, splice_out_addr_const_materialization,
and the #209 reciprocal-mult dead-divisor retain — so spill stores survive any
fold. Defensive `assert_spill_reloads_have_stores` at the end of
select_with_stack (internal-bug panic pattern): a reload from the reserved
spill area without a preceding store to that slot can no longer leave the
selector silently.

Gates: new scripts/repro/spill_rung_581_differential.py (minimal fold-shape
fixture + the original #580 discovery fixture, unicorn vs wasmtime, direct
path) red on main → 12/12 green; unit fold_preserves_spill_store_581;
frozen 3/3 bit-identical; r12_spill_496 + AAPCS oracles green;
cargo test -p synth-synthesis -p synth-cli green; clippy -D warnings clean.

Closes #581

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@avrabe
avrabe merged commit 594beff into main Jul 2, 2026
25 checks passed
@avrabe
avrabe deleted the fix/581-direct-spill-rung branch July 2, 2026 19:25
avrabe added a commit that referenced this pull request Jul 2, 2026
… Belady spilling default-on (#585)

Caps the four-lane arc: slot liveness (#579), exhaustion spill (#580),
spill-rung fix (#582), SYNTH_SPILL_REALLOC flip + refreeze (#583).
VCR-RA-001 -> verified; rivet release status v0.24.0: cuttable. Pin sweep +
lock + CHANGELOG.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
avrabe added a commit that referenced this pull request Jul 8, 2026
…nst-div guards (#242, the PR #659 verdict) (#672)

PR #659 held the SYNTH_SPILL_ON_EXHAUST flip on a measured i32-shape cycle
regression and named the missing capability: post-exhaustion code quality on
the optimized path. Root cause of the unreached allocation-time Belady slots:

- fresh-monotonic slots defeat the overwrite-only frame-slot DCE (#515);
- the eviction store's source is redefined immediately, defeating
  forward_stack_reloads;
- spill_rechoice's rename deadness proof is segment-local — R2/R3 are never
  touched again on bridge streams, so "possibly live-out" declined them, and
  exact SegmentTrace equality rejected any fresh-register rename;
- signed_div_const additionally paid for const-divisor trap guards the
  direct selector elides (#209 Opt 1a).

Extensions, ALL scoped to functions the #580 machinery actually shaped
(OptimizerBridge::spill_on_exhaust_fired — flag-on leaves every untouched
function byte-identical, locked by vcr_ver_001_gate_242 + frozen 10/10 run in
both flag states; flag-off is bit-for-bit the shipping pipeline):

- scratch_dead_at: function-level R2/R3 exit-deadness (conservative at any
  branch/call/unmodeled op) feeding rename_kill_def; trace equality modulo
  provably-dead exit entries; per-pair pressure commit; byte-shrinking
  count-neutral folds admitted;
- constant rematerialization of spilled single-instruction consts in
  spill_forward_segment (movt RMW kills the shape — the #582 discipline);
- bounded fixpoint of the cleanup triple + late elide_dead_frame;
- terminal-segment relaxed live-out pinning in range-realloc (only R0/R1
  observable past bx lr pre-prologue; VCR-RA-003 validator run with the same
  exemptions; reload-free segments only);
- const-divisor trap-guard elision in DivS/DivU/RemS/RemU (single-def Const
  scan, total-or-disabled def enumeration; c=0 keeps all, DivS c=-1 keeps
  the overflow guard).

Cycle proxy (scripts/repro/postex_cycle_proxy.py, wasmtime-matched):
spill_on_exhaust_242 +30.4%→+17.4%, spill_rung_581 +32.4%→+8.8%,
high_pressure_i32 +8.0%→−24.0%, signed_div_const +120%→−33.3%,
i64 pair/pool −16.4%/−2.9%. Two fixtures still miss ≤+5%: the residual is
alloc_i32_scratch's fixed R4-R8 dest pool vs the direct selector's nine
registers — the Track-A allocator replacement itself (see the gate doc's
"residual, named" section; a reload-pool widening was tried and reverted,
measured strictly worse). The flip stays HELD (#580).

Gates: cargo test --workspace green; frozen anchors 10/10 + gate lock in
BOTH flag states; r12_spill_496 / spill_on_exhaust_242 / i64_pair_exhaust_587
/ i64_spill_pool_587 / spill_rung_581 differentials PASS flag-on; fmt +
clippy -D warnings clean.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Direct selector spill rung miscompiles: ldr reads a never-stored frame slot after mul.w clobbers a live register (shipped path, wrong value)

1 participant