Skip to content

cleanup(riscv): allocator prefers caller-saved (lowest-free, not round-robin) (#230, v0.11.26) - #231

Merged
avrabe merged 1 commit into
mainfrom
audit/rv32-prefer-caller-saved-alloc
Jun 3, 2026
Merged

cleanup(riscv): allocator prefers caller-saved (lowest-free, not round-robin) (#230, v0.11.26)#231
avrabe merged 1 commit into
mainfrom
audit/rv32-prefer-caller-saved-alloc

Conversation

@avrabe

@avrabe avrabe commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Audit-cycle cleanup — issue #230

The RV32 alloc_temp recycled the pool [t0..t6, s1..s6] with a monotonic next_temp round-robin. Even after #226 made it skip live registers, it kept marching forward into the callee-saved s-registers instead of reusing a just-freed low register — so short-lived expressions needlessly used s-registers and paid the #220 save/restore prologue tax.

Why it's now removable

#226 added vstack liveness tracking. With that in place, alloc_temp can simply return the lowest-indexed free temp; the pool lists caller-saved t-registers first, so a function stays in them whenever ≤7 values are simultaneously live. The s-registers become a genuine overflow reserve rather than something round-robin consumed by accident. The dead next_temp field is removed.

Before / after (measured)

before after
controller_step .text 440 B 368 B (−72 B, −18 instrs)
callee-saved spills 6 0
frame 48 B 16 B

Oracle — behavior frozen, verified before opening

All five differential fixtures bit-identical:

  • ARM: div_const 338/338, control_step 0x00210a55, flight_seam 0x07FDF307
  • RV32: control_step 0x00210a55, controller_step 0x05ff0000

165 riscv unit tests pass. The #220 callee-saved-preservation test was reworked to force s-register usage via 8 simultaneously-live values (a shorter expression now stays in t-registers), so the spill/restore invariant remains genuinely exercised.

Scope

This is the round-robin → lowest-free revert only. The broader local register allocation + constant-CSE (Opt-3 perf lever, gale #209 / task #78) stays separate.

Closes #230.

🤖 Generated with Claude Code

…ound-robin (#230, v0.11.26)

The RV32 alloc_temp recycled the pool [t0..t6, s1..s6] with a monotonic next_temp
round-robin. Even after #226 made it skip live registers, it kept marching forward
into the callee-saved s-registers instead of reusing a just-freed low register, so
short-lived expressions needlessly used s-registers and paid the #220 save/restore
prologue tax.

Now that vstack liveness is tracked (#226), alloc_temp returns the lowest-indexed
free temp. The pool lists caller-saved t-registers first, so a function stays in
them whenever <=7 values are simultaneously live; the s-registers become a genuine
overflow reserve. Drops the now-dead next_temp field.

Behavior frozen, oracle-confirmed bit-identical: ARM div_const 338/338, control_step
0x00210a55, flight_seam 0x07FDF307; RV32 control_step 0x00210a55, controller_step
0x05ff0000. Measured: controller_step .text 440->368B (-72B/-18 instrs), callee-saved
spills 6->0, frame 48->16B. 165 riscv tests pass (#220 preservation test reworked to
force s-reg usage via 8 simultaneously-live values).

Closes #230.

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

codecov Bot commented Jun 3, 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 715c0cb into main Jun 3, 2026
14 checks passed
@avrabe
avrabe deleted the audit/rv32-prefer-caller-saved-alloc branch June 3, 2026 11:34
@avrabe

avrabe commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

Built #231 from source (v0.11.26) and ran it against the real wasm-cross-LTO leaf functions on qemu_riscv32 (-icount shift=0, min-over-200 with overhead subtraction). The caller-saved-preference win reproduces beautifully — but it exposes a signed-division miscompile on one function. Please hold the merge.

The win (real workload, not just fixtures) ✅

All three leaves now use zero callee-saved registers (were s1–s6), code shrinks, and control_step/controller_step stay bit-identical AND get faster:

fn code v0.11.25 code v0.11.26 callee-saved (v26) icount v25→v26 correct?
filter_axis 172 B 88 B (−49%) none 37→25 wrong (returns 0)
control_step 576 B 504 B none 141→129 ✅ 2165333
controller_step 492 B 408 B none 114→100 ✅ 97419164

(control_step icount includes my ~6-cyc s11 table trampoline.) So your prediction held: the leaf prologue tax is gone and ratios drop toward native. controller_step 2.33×→2.04×, control_step 2.27×→2.08×.

The regression ❌ — signed-div overflow guard clobbers the dividend

filter_axis (the only one of these with a signed divide by a constant(gyro*980 + accel*20)/1000) returns 0 instead of 1088 on v0.11.26. Reproducible 3/3; the loom.wasm itself is correct (wasmtime --invoke filter_axis_decide … 1000 100 500 → 1088), so it's RV32 codegen.

v0.11.26 disasm:

20: add  t0,t0,t1      # t0 = numerator (gyro_term*980 + accel*20)   <-- live, needed by div
24: li   t1,1000        # divisor
28: bnez t1,30          # divisor != 0  -> ok
30: lui  t0,0x80000     # INT_MIN constant materialized into t0  <-- CLOBBERS numerator
34: bne  t0,t0,44       # t0==t0 always false (meant to test numerator==INT_MIN)
38: li   t0,-1          # -1 constant -> t0 clobbered again
3c: bne  t1,t0,44       # 1000 != -1 -> taken
44: div  t0,t0,t1       # t0 = (-1)/1000 = 0   <-- dividend is gone

The signed i32.div_s overflow guard (the numerator==INT_MIN && divisor==-1 check) materializes its INT_MIN and −1 comparison constants into t0 — the register holding the live numerator — so by the div the dividend is −1.

v0.11.25 got this right by keeping the numerator in a callee-saved reg clear of the guard temps:

3c: add  s2,t4,s1       # numerator in s2
4c: lui  s5,0x80000      # INT_MIN in s5
50: bne  s2,s5,60        # correctly tests numerator vs INT_MIN
54: li   s6,-1           # -1 in s6
60: div  s4,s2,s3        # numerator s2 intact  -> 1088

Root cause: the dividend's live range extends across the overflow-guard basic blocks to the div, but the new lowest-free allocator treats t0 as free after the add and hands it to the guard constants. Same class as #226 (liveness across a branch region), resurfaced because #231 changed allocation order to lowest-free. Your 5 differential fixtures evidently don't include a signed-div-by-constant leaf, so they stayed green.

Minimal repro: int32_t f(int32_t prev,int32_t gyro,int32_t accel){ return ((prev+gyro)*980 + accel*20)/1000; } → wasm → loom inline → synth compile -b riscv -t rv32imacf(1000,100,500) returns 0, expect 1088. Happy to test a fix the instant it's pushed — I have all four leaves wired to re-measure.

avrabe added a commit that referenced this pull request Jun 3, 2026
…232, v0.11.27) (#233)

Regression from v0.11.26 (#231 lowest-free allocator). The i32.div_s INT_MIN/-1
overflow guard pops dividend/divisor off the vstack, then allocs scratch regs for
its INT_MIN and -1 comparison constants. Since the popped operands are no longer
on the vstack, live_regs didn't protect them and the lowest-free allocator reused
the dividend's register for the INT_MIN constant, clobbering it before the guard's
bne read it. filter_axis_decide(1000,100,500) returned 0 instead of 1088 on
qemu_riscv32 (round-robin masked the latent bug in v0.11.25).

Fix: alloc_temp_avoiding(&[Reg]) — the guard materializes its constants into a
register that avoids the popped-but-live dividend and divisor. Applied to both the
i32 guard and the i64 INT64_MIN/-1 guard (i64 path not yet reachable but same
latent defect). Same liveness-across-a-branch-region class as #226.

Regression proof: scripts/repro/signed_div_const.{wat,wasm} +
signed_div_const_riscv_differential.py — 5/5 match wasmtime (incl. 1088 repro,
INT_MIN edge, negatives). Unit test signed_div_guard_does_not_clobber_operands_232.
All five prior fixtures stay bit-identical; the #231 caller-saved win is preserved.

Closes #232.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
avrabe added a commit that referenced this pull request Jun 11, 2026
…n RV32 (#312) (#316)

* fix(riscv): i64 locals — two-word frame slots + a0:a1 call-result pairs (#312)

The RV32 selector refused any function touching an i64 local ("stack type
mismatch: expected i32, found i64" on local.tee/set) — i64 locals were the
documented Phase-1 gap (one 4-byte slot per local). gale's u64-unpack repro
(the RISC-V analogue of ARM #311) hit it on every loom-dissolved module that
keeps a packed u64 in a local.

Mirrors the ARM #311 structure:

- synth-synthesis: `infer_i64_locals` is now `pub` (re-exported from the
  crate root). The width inference is ISA-neutral — it walks the wasm op
  stream with a virtual width stack and consumes the decoder's
  `func_ret_i64`/`type_ret_i64` tables so call-fed locals are covered.

- selector.rs `compute_local_layout`: i64 locals get 8-byte, 8-byte-aligned
  frame slots (lo word at `off`, hi word at `off+4`); i32 locals stay 4-byte.
  Slot table is now `idx -> (offset, is_i64)`.

- `lower_local_get/set/tee`: i64 arms emit two lw/sw against the slot and
  push/pop the crate's existing `(lo, hi)` vstack pair convention.

- `lower_call`: when the callee's signature says i64 (func_ret_i64), the
  result is tagged as the a0 (lo) : a1 (hi) pair per the RV32 psABI, so a
  following local.set stores both words instead of tripping the type check.
  Empty tables keep the legacy i32 tagging (select_with_options unchanged).

- New entry point `select_with_result_types(..., &[bool], &[bool])`; the
  backend threads `CompileConfig::func_ret_i64/type_ret_i64` (#311 plumbing,
  already populated by the CLI) through both `compile_function_with_opts`
  and `compile_to_riscv_ops`.

Deliberately NOT modeled (honest skip-and-continue, never half a value):
- i64 *params* — an i64 param occupies an aligned a-register pair per the
  RV32 psABI; param handling stays i32-only and inferred-i64 params return
  `Unsupported`.
- call_indirect — still `Unsupported` in this selector; `type_ret_i64` is
  consulted only by the width inference.

Verification:
- cargo test -p synth-backend-riscv: 173 passed (7 new #312 tests: set/get
  roundtrip asserts two sw/lw at off and off+4, tee keeps the pair live,
  i64-producer inference, 8-byte alignment after an i32 local, call-fed
  a0:a1 pair, empty-table i32 default, i64-param Unsupported).
- scripts/repro/u64_unpack_inlined.wat now COMPILES for rv32imac
  (--all-exports --relocatable; the #312 failure was a refusal).
- scripts/repro/u64_unpack.wat: 3/5 functions compile (check + both i64
  leaf callees); check_call/check_hot now pass the selector but skip at ELF
  emit ("external call without relocation table") — the pre-existing RV32
  cross-function-call relocation gap, unchanged by this fix.
- ARM fixture control_step.wasm unaffected; workspace build + clippy clean.

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

* fix(riscv): per-op pin scope in alloc_temp — i64 pairs collapsed to one register (#312)

The #312 i64-locals oracle (u64_unpack_inlined.wat) failed with check(3,4)
= 0x0 on every vector. Root cause is not the new locals lowering but a
latent hole the #231 lowest-free allocator opened under it: alloc_temp only
dodges vstack-LIVE registers, so

  1. two back-to-back alloc_temp calls within one op return the SAME
     register — every i64 (lo, hi) pair collapsed to one reg (i64.const,
     extend_i32_u, local.get, select, every i64 helper), e.g. the repro's
     `mv t0, zero` zeroing the hi of a pair right on top of its own lo;
  2. operands POPPED off the vstack are invisible to liveness, so an op's
     own scratch lands on a popped operand it still reads (#232 was a
     point-fix of exactly this class in the signed-div guard).

Never seen end-to-end before because no RV32 oracle exercised i64 data
flow; the shape-asserting unit tests don't check register distinctness.

Fix (systemic, not another point patch): a per-wasm-op pin scope.
pop_* pins what it pops, alloc_temp_avoiding skips + pins what it returns,
lower_one clears the set at each op boundary. High-pressure lowerings
(i64 div/shift/rotate, clz/ctz/popcnt) unpin scratch as it dies so their
fully-pinned peak stays inside the 13-register pool (worst case, signed
i64 div with trap guards: peak 11 of 13).

Oracle: u64_unpack rv32 differential PASS (4/4 vectors, unicorn vs
wasmtime); the four existing RV32 differentials (control_step,
controller_step #226, filter_axis #220, signed_div_const #232) all still
PASS; 175 riscv tests green (+2 new #312 regression tests); ARM fixture
byte-identical (control_step cortex-m4 cmp OK).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(riscv): commit the u64-unpack RV32 unicorn-vs-wasmtime lane (#312)

The behavioral oracle that caught the per-op aliasing — compile-only gating
would have shipped pairs collapsed to one register.

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.

cleanup(riscv): allocator prefers caller-saved (lowest-free, not round-robin) — drops 6 needless s-reg spills on controller_step

1 participant