cleanup(riscv): allocator prefers caller-saved (lowest-free, not round-robin) (#230, v0.11.26) - #231
Conversation
…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 Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Built #231 from source (v0.11.26) and ran it against the real wasm-cross-LTO leaf functions on qemu_riscv32 ( The win (real workload, not just fixtures) ✅All three leaves now use zero callee-saved registers (were s1–s6), code shrinks, and
( The regression ❌ — signed-div overflow guard clobbers the dividend
v0.11.26 disasm: The signed v0.11.25 got this right by keeping the numerator in a callee-saved reg clear of the guard temps: Root cause: the dividend's live range extends across the overflow-guard basic blocks to the Minimal repro: |
…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>
…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>
Audit-cycle cleanup — issue #230
The RV32
alloc_temprecycled the pool[t0..t6, s1..s6]with a monotonicnext_tempround-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_tempcan 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 deadnext_tempfield is removed.Before / after (measured)
controller_step.textOracle — behavior frozen, verified before opening
All five differential fixtures bit-identical:
div_const338/338,control_step0x00210a55,flight_seam0x07FDF307control_step0x00210a55,controller_step0x05ff0000165 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