Repository navigation
Optimized path: small leaf functions clobber callee-saved r4-r8 without a saving prologue (latent ABI bug) #490
Description
Activity
Correction: this is SYSTEMIC across the optimized path, not just small leaves
Verified the scope with a sentinel test. The optimized path's
alloc_i32_scratch(CANDIDATES[R4,R5,R6,R7,R8], r4 handed out first) gives callee-saved scratch to every function with a scratch temp, and the path never emits a callee-saved push — so the clobber is not limited to tiny leaves.control_step_decide— a silicon-validated frozen fixture (0x00210A55) — run under unicorn with r4–r8 sentinels clobbers r4, r6, r8. It only appears correct because it is top-level (its differential harness doesn't rely on r4–r8 surviving the call); the ABI violation is there regardless.Why there is no localized fix
The scratch pool is shared by every optimized-path function — there is no "prologue-less leaf" sub-path to special-case (they are all prologue-less w.r.t. callee-saved). Both candidate fixes touch every optimized-path function that uses r4–r8:
- (A) push-what-you-use: emit
push {used r4-r8, lr}/popin the optimized path (mirrors the direct selector). Correct, but byte-changescontrol_step/filter_step(frozen, silicon-validated) → re-freeze + re-validate. - (B) caller-saved scratch: also partly wrong — r12 is reserved encoder scratch, so the caller-saved pool is only r0–r3 minus params/return, too thin to swap in globally without heavy new spilling.
Either is systemic base-selector surgery with a silicon re-freeze ripple, not a narrow change. It needs a dedicated effort with the re-validate ritual (gale on G474RE), not a tail-of-session addendum.
Note this is masked in the
--relocatablepath gale ships (that path uses the direct selector, which preserves callee-saved correctly), so it is not a live miscompile in shipped firmware — it is a latent ABI violation in the self-contained optimized path, and the gate that blocks the non-leaf prologue lever (#428) from flipping.Recommend (A) as the principled fix when scheduled.
- (A) push-what-you-use: emit
[issue-hunt loop] Fixed in #495 (merged to main as `887133e`).
The optimized path now wraps any body that genuinely touches r4-r8 in `push {r4-r8,lr}` / `pop {r4-r8,pc}` (decided on the post-realloc body, sized down by `shrink_callee_saved_saves`). Validated by a new CI-gated unicorn sentinel oracle (
callee_saved_490_differential.py) — r4-r8 set to sentinels, asserts result == wasmtime AND sentinels restored, across 16-bit push and 32-bit PUSH.W forms; fails without the fix.Scope notes carried in the PR: callee-saved (r4-r8) only — caller-saved across import calls is the separate #197 gap; >4-param functions decline to the direct selector so the wrap can't perturb stack-arg offsets. Default-on; frozen byte gate (direct path) stays bit-identical.
A separate pre-existing optimized-path miscompile found while building the fixture (register-exhausting folds) is tracked in #496.
- added a commit that references this issue
on Jun 25, 2026 - added a commit that references this issue
on Jul 3, 2026 - added 5 commits that reference this issue
on Jul 17, 2026 - added 7 commits that reference this issue
on Sep 15, 2026
Summary
A small leaf function compiled via the optimized (non-
--relocatable) ARM path can write callee-saved registersr4–r8without a saving prologue, violating the AAPCS / synth's own callee-save convention. It is latent today because non-leaf callers over-save{r4-r8}, masking it.Minimal repro
helperwrites r4 and r5 (callee-saved) and returns viabx lrwith nopush— so it clobbers its caller's r4/r5.Root cause
alloc_i32_scratch(optimizer_bridge.rs ~2633) hands out the callee-saved pool[R4,R5,R6,R7,R8]as scratch;local_regs/alloc_i64_pairlikewise use r4–r7.push {r4-r8,lr}only for non-leaf functions (those with a call); a small leaf gets neither a push nor caller-saved temps.SYNTH_RANGE_REALLOC=0(identical bytes), so it is the base selector, not the range-reallocator.Larger leaves (e.g.
filter_stepinflight_seam) avoid it by spilling to a frame and using caller-saved temps — which is why the silicon-validated fixtures pass. The bug surfaces on small leaves whose 1–2 temps land directly in r4/r5.Why it's latent (and why it matters now)
Every non-leaf caller currently emits
push {r4-r8,lr}(the leaf-only prologue-shrink declines on calls), so it over-saves and restores the clobbered registers. Any optimization that stops a caller over-saving unmasks it. The non-leaf forwarder prologue lever (#428, branchwip/nonleaf-prologue-blocked-on-leaf-clobber) does exactly that and is blocked on this bug — its execution differential (scripts/repro/nonleaf_forwarder_differential.py) is the end-to-end reproduction (a shrunk forwarder loses the caller's r5 because its callee clobbers it).This is a
VCR-*"correctness from construction" item (epic #242): a base-selector ABI violation that outranks the perf lever sitting downstream of it.Fix shapes (for triage)
r0-r3,r12) instead ofr4-r8— no prologue needed, no re-freeze for functions already using caller-saved.push {used callee-saved, lr}/pop {…, pc}whenever the optimized path assigns any of r4–r8 — correct but byte-changing across many fixtures (re-freeze ripple).Either is byte-changing and gated; (1) is narrower. Filed from feature-loop work that uncovered it; the perf lever stays unmerged until this lands.