Skip to content

fix(vcr-ra): const-CSE size-regression guard — CSE-last + per-segment size guard (#242) - #519

Merged
avrabe merged 1 commit into
mainfrom
vcr-ra-const-cse-no-regression-242
Jun 26, 2026
Merged

fix(vcr-ra): const-CSE size-regression guard — CSE-last + per-segment size guard (#242)#519
avrabe merged 1 commit into
mainfrom
vcr-ra-const-cse-no-regression-242

Conversation

@avrabe

@avrabe avrabe commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

What

Fixes the const-CSE size regression gale found in the v0.17.0 burndown (#242):
SYNTH_CONST_CSE=1 grew a tiny --relocatable function (gust_mix 90→92 B).

Root cause. On --relocatable, the optimized path's inline const cache never
runs (select_direct()), so the post-hoc liveness::apply_const_cse acts alone.
A remove-movw + rename-use post-pass on already-register-assigned instructions
cannot itself spill — it grows code only by changing what a later pass does. Here
it retargeted a use, kept a constant resident longer, and defeated a downstream
immediate-fold
that would otherwise have absorbed the constant.

Fix

  1. CSE-last — move the apply_const_cse call to run after every immediate-fold
    (fold_immediate_shifts / fold_uxth), before branch resolution. Foldable consts
    are already folded-and-gone, so CSE can no longer defeat a fold. Structurally
    eliminates gale's mechanism.
  2. Per-segment size guard in apply_const_cse — stage each segment's removals/
    retargets, estimate the rewritten segment via estimate_arm_byte_size (the test(vcr-oracle): estimator↔encoder agreement oracle for the optimized path (#498, #242) #511
    encoder mirror), commit only if it does not grow. A retarget that flips a 16-bit
    ldr to its 32-bit form (low→high base register) is declined.

Verification

  • Non-vacuous guard tests — two contrasting liveness unit tests: identical
    segments differing only in the resident register's class (high R8 → encoding flips
    → declines; low R2 → no flip → commits).
  • Differential (const_cse_differential.py) — flag-on values bit-identical to
    wasmtime across the corpus; new per-function no-regression gates on both the
    optimized and --relocatable paths.
  • Flag-off byte-identical — frozen gate 3/3, const_cse golden 2/2.
  • cargo test --workspace green (85 suites); fmt + clippy clean.

Honesty / scope

const-CSE stays flag-off (SYNTH_CONST_CSE). The pressure/size prerequisite for
the eventual default-on flip is now closed; alias-eviction remains the sole open
prerequisite. gale's exact gust_mix case is not yet reproduced in-tree — the
post-hoc pass is currently inert on the arithmetic --relocatable corpus, so that
gate is a tripwire that lights up when a triggering fixture (gust_mix.wat, requested
on #242) lands. The fix is structurally sound; this PR does not claim an empirical
fix-on-gale.

🤖 Generated with Claude Code

… size guard (#242)

gale's v0.17.0 burndown found SYNTH_CONST_CSE=1 GREW a tiny --relocatable
function (gust_mix 90→92 B). On --relocatable the optimized path's inline const
cache never runs (select_direct), so the post-hoc liveness::apply_const_cse acts
alone: it retargeted a use, kept a constant resident longer, and defeated a
downstream immediate-fold that would otherwise have absorbed the constant.

A remove-movw + rename-use post-pass on already-register-assigned instructions
cannot itself spill — it grows code only by changing what a later pass does. Two
fixes:

1. CSE-LAST: move the apply_const_cse call to run after every immediate-fold
   (fold_immediate_shifts / fold_uxth), before branch resolution. Foldable consts
   are already folded-and-gone, so CSE can no longer defeat a fold. This
   structurally eliminates gale's mechanism.

2. Per-segment SIZE GUARD in apply_const_cse: stage each segment's removals/
   retargets, estimate the rewritten segment via estimate_arm_byte_size (the #511
   encoder mirror), and commit only if it does not grow — so a retarget that flips
   a 16-bit ldr to its 32-bit form (low→high base register) is declined.

Verification:
- Two contrasting liveness unit tests prove the guard non-vacuous: identical
  segments differing only in the resident register's class (high R8 → encoding
  flips → declines; low R2 → no flip → commits).
- const_cse_differential.py: flag-on values bit-identical to wasmtime across the
  corpus; new per-function no-regression gates on BOTH the optimized and
  --relocatable paths (the latter is the path gale's bug lives on — currently
  inert on the arithmetic corpus, a tripwire for when gust_mix.wat lands).
- Flag-off byte-identical (frozen gate 3/3, const_cse golden 2/2).
- cargo test --workspace green (85 suites); fmt + clippy clean.

const-CSE stays flag-off (SYNTH_CONST_CSE). The pressure/size prerequisite for
the eventual default-on flip is now closed; alias-eviction remains the sole open
prerequisite. gale's exact gust_mix case is not yet reproduced in-tree — fixture
requested on #242 to pin the trigger.

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

codecov Bot commented Jun 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.10714% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/synth-backend/src/arm_backend.rs 85.71% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@avrabe
avrabe merged commit 2024817 into main Jun 26, 2026
24 checks passed
@avrabe
avrabe deleted the vcr-ra-const-cse-no-regression-242 branch June 26, 2026 21:43
avrabe added a commit that referenced this pull request Jun 27, 2026
…t fires + stays correct on gale's path (#242) (#522)

The const-CSE no-regression fix (#519) ships with an honest gap: its
`--relocatable` gate was INERT. gale's gust_mix 90→92 B regression was on
`--relocatable`, which routes through `select_direct()` where only the post-hoc
`apply_const_cse` runs — but the `const_cse.wat` arithmetic corpus never makes
the direct selector emit the redundant same-value-in-two-registers shape that
pass dedups, so the gate gave zero positive evidence on the exact path the bug
lived on.

This adds `const_cse_direct.wat`: single-param, pure-register, reloc-free shapes
(a >8-bit const reused across several independent sub-expressions summed at the
end) that DO make the direct selector emit the redundant `movw`, so post-hoc
`apply_const_cse` fires on `--relocatable` (r1 44→40, r2 38→34). The differential
now runs a NON-VACUOUS direct-path gate that asserts:
  (a) CSE actually fires on >=1 function — fails if the gate goes blind;
  (b) no function grows (the no-regression property on gale's path);
  (c) every result is bit-identical to wasmtime under unicorn (correctness of
      post-hoc CSE on the direct selector's output).

This is the positive evidence on gale's exact path that #519 could not provide.
Behavior-frozen: new fixture + harness only, no codegen change — frozen anchors
(control_step 0x00210A55, flight_algo 0x07FDF307) and the const_cse flag-off
golden are untouched (frozen gate 3/3, golden 2/2). The full differential passes
(exit 0); flag-off byte-identical.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
avrabe added a commit that referenced this pull request Jul 3, 2026
…on (#242) (#604)

* feat(vcr-ra)!: retire inline const aliasing; SYNTH_CONST_CSE default-on (#242)

Two coupled steps, oracle-gated in order:

1. RETIRE the bridge-level inline const aliasing (the flip blocker verified
   in PR #592): the reg_holds_const alias arm in optimizer_bridge::ir_to_arm
   made two live vregs share one physical register, breaking the spill
   model's vreg<->reg bijection (alias-eviction stale-read hazard). Deleted
   outright — const materialization always falls through to normal
   allocate-and-emit; the flag now gates ONLY the post-hoc, liveness-proven
   liveness::apply_const_cse passes (PR1 #519 + PR2 #562). The recorded
   reg_effect DEF-COMPLETENESS prerequisite retires with it (the post-hoc
   passes treat unmodeled ops as segment boundaries and decline).

2. FLIP SYNTH_CONST_CSE DEFAULT-ON (opt-out =0), full #583/#592 refreeze
   ritual: differentials re-run green on the new default bytes BEFORE any
   golden was pinned (const_cse, frame_slot_dce 8/8, flight_seam 0x07FDF307,
   spill_rung_581 6/6, volatile_segment_543 incl. a new default-on
   composition check, control_step 13/13). Corpus sweep 152 fixture-x-path
   combos: 0 functions grow, 40 shrink (const_cse::spill12 236->148 B),
   total -536 B. Frozen ARM anchors re-pinned (control_step 304->300,
   flight_seam 730->726; flat + signed_div_const byte-identical); RV32
   untouched. SYNTH_CONST_CSE=0 restores every pre-flip byte (CI-gated:
   const_cse_escape_hatch_restores_old_bytes_242 +
   frozen_fixtures_const_cse_escape_hatch_restores_old_bytes); the older
   stack-fwd/spill-realloc escape hatches gain the =0 composition.

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

* fix(const-cse): decline across branch boundaries — nested(1,) store-of-99 miscompile

The optimized path resolves BOffset/BCondOffset displacements to byte-accurate
halfword offsets inside optimizer_bridge::ir_to_arm — BEFORE apply_const_cse
runs — and nothing re-resolves them afterwards. Both const-CSE passes (PR1
cross-reg fold + PR2 extending hoist) violated two invariants of that stream
on spill_frame_499.wat::nested (the CI spill-frame teardown oracle, nested(1,)
wrote 0 where wasmtime writes 99 at offset 32):

  1. JOIN INVISIBLE: a resolved branch target carries no Label op, so a
     "straight-line segment" spanned the if/else join — the hoist retargeted
     the join tail's `add r12,r12,r4` onto r0, whose base value is only
     materialized on the fall-through arm (on the taken arm r0 = the sel
     param), so the taken path stored 55 over the 99.
  2. DISPLACEMENT STALE: deleting the arm's two redundant movw+movt pairs
     (16 bytes) between the `b` and its target made the pre-resolved
     `b +0x42` overshoot the join by exactly those 16 bytes.

Soundness rule (liveness.rs, resolved_branch_geometry): reconstruct every
numeric branch's target index by mirroring the bridge's own offset table
(estimate_arm_byte_size, the #511-pinned estimator), then in BOTH passes
(1) treat each target as a segment BARRIER — held/hoist state never crosses a
join — and (2) FREEZE the total byte size of any segment lying between a
branch and its target (commit requires new_bytes == orig_bytes there, not
merely no-grow). Unmappable targets or mixed Label/numeric streams decline
the whole function. Label-based (--relocatable/direct) streams are unaffected:
Label was already a barrier and their branches resolve AFTER this pass.

Verification (fix, not fixture — the oracle is untouched):
  - spill_frame_499_differential.py: PASS (was FAIL nested(1,), off=32 99 vs 0)
  - full scripts/repro sweep: 54 scripts, 52 PASS both default AND
    SYNTH_CONST_CSE=0; sret_decide = pre-existing, flag-independent (#359-era
    characterization, bytes identical on/off); wake_path skipped (needs gale's
    external gist fixture /tmp/merged.wat)
  - corpus re-measured: 152 fixture×path combos, 0 functions grow, 38 shrink,
    total -488 B (was -536 B — the returned 48 B are exactly the branched
    shapes' unsound wins: nested -24 -> 0, init_branch -16 -> -8);
    spill12 keeps its full -88 B; all four const_cse_reduction_242 goldens
    (default + escape-hatch) pass UNCHANGED
  - 4 new regression tests: target-as-barrier, span freeze (fold + hoist),
    and fold-outside-span still commits
  - cargo test -p synth-synthesis -p synth-cli (45 suites ok), fmt, clippy
    -D warnings: clean

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

---------

Co-authored-by: Claude Opus 4.8 <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.

1 participant