Skip to content

Optimized path: small leaf functions clobber callee-saved r4-r8 without a saving prologue (latent ABI bug) #490

Description

@avrabe

Summary

A small leaf function compiled via the optimized (non---relocatable) ARM path can write callee-saved registers r4–r8 without 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

(module
  (memory 1)
  (func (export "helper") (param i32) (result i32)
    (i32.add (local.get 0) (i32.const 100))))
$ synth compile helper.wat -o ho.o -b arm --target cortex-m4 --all-exports
$ # .text for `helper` (8 bytes):
  movs r4, #100      ; 6424
  adds r5, r0, r4    ; 0519
  mov  r0, r5        ; 2846
  bx   lr            ; 7047

helper writes r4 and r5 (callee-saved) and returns via bx lr with no push — so it clobbers its caller's r4/r5.

Root cause

  • The optimized path's alloc_i32_scratch (optimizer_bridge.rs ~2633) hands out the callee-saved pool [R4,R5,R6,R7,R8] as scratch; local_regs / alloc_i64_pair likewise use r4–r7.
  • The path emits a callee-saved push {r4-r8,lr} only for non-leaf functions (those with a call); a small leaf gets neither a push nor caller-saved temps.
  • Realloc-independent: reproduces with SYNTH_RANGE_REALLOC=0 (identical bytes), so it is the base selector, not the range-reallocator.

Larger leaves (e.g. filter_step in flight_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, branch wip/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)

  1. Localized: for a prologue-less leaf, allocate scratch from the caller-saved set (r0-r3, r12) instead of r4-r8 — no prologue needed, no re-freeze for functions already using caller-saved.
  2. General: emit a 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.

Activity

  1. avrabe commented on Jun 25, 2026

    @avrabe
    ContributorAuthor

    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} / pop in the optimized path (mirrors the direct selector). Correct, but byte-changes control_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 --relocatable path 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.

  2. added 2 commits that reference this issue on Jun 25, 2026
  3. avrabe commented on Jun 25, 2026

    @avrabe
    ContributorAuthor

    [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.

  4. added 5 commits that reference this issue on Jul 17, 2026
  5. added 2 commits that reference this issue on Aug 27, 2026
  6. added 7 commits that reference this issue on Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions