Skip to content

Commit 84e7aaf

Browse files
avrabeclaude
andauthored
feat(vcr-ra): i64 pair-spill on exhaustion — VCR-RA-001 acceptance criterion closes (#242) (#325)
The last hard-fail named by the v0.11.40 acceptance review: a high-pressure i64 module now compiles with no hard-fail. ROOT CAUSE (differs from the review's reading): the pair allocator (alloc_consecutive_pair) has ALWAYS spilled register-resident stack values — pair-aware, both halves into one 8-byte slot (#171). The remaining "no consecutive pair of free registers for i64" Err fires only when the operand stack holds nothing register-resident and the blockers are the PINNED PARAM HOME REGISTERS (#193 `reserved`, up to r0-r3) plus the popped operand pairs (`extra_avoid`, up to 4 regs) — 8 of the 9-register pool, no pair left, and no amount of stack spilling can free a param home register. FIX (same structural-bit-identity pattern as #320): a third retry mode, `set_param_backing_on_exhaustion`, forces the proven #204 `param_slots` frame-backing for call-free functions — params spill to frame slots at entry and reload on read, so `reserved` empties and a free consecutive pair always exists after stack spilling (extra_avoid is at most two adjacent pairs among nine registers; pigeonhole over the free segments). The backend's select_direct ladder: pass 1 default → pass 2 spill-only (#320, on the single-reg Err) → pass 3 spill+param-backing (on the pair Err). Functions that compile on an earlier pass are selected by exactly yesterday's code. Loop bound unchanged: each spill_deepest_reg iteration converts one StackVal::Reg to Spilled. Honest remaining bound: the i64 spill-slot pool ("spill-slot pool exhausted"), deliberately NOT retried. EVIDENCE - scripts/repro/high_pressure_i64.wat: 4 simultaneously-live i64 consts + 4 pinned i32 params. On main: skipped with the exact pair Err. On this branch: compiles; high_pressure_i64_differential.py (unicorn vs wasmtime, i64 result in r0:r1, large/negative vectors) 6/6 ORACLE PASS. - Bit-identity vs origin/main (cortex-m4, per differential headers): control_step 8248…32f6, div_const cada…45ede, flight_seam_flat 03ce…8579, high_pressure_i32 355c…e6c2 — all pairwise sha256-identical; all three frozen differentials ORACLE PASS on the branch. - Tests: 5 new (pair victim spill, single victims, pinned-blockers Err preserved verbatim, spilled-pair reload on pop, end-to-end 3-pass ladder); synth-synthesis 382 lib tests green; workspace 1468 passed / 0 failed. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 84e1a5d commit 84e7aaf

4 files changed

Lines changed: 473 additions & 55 deletions

File tree

crates/synth-backend/src/arm_backend.rs

Lines changed: 64 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -160,57 +160,72 @@ fn compile_wasm_to_arm(
160160
// unmodified default, so every function that compiles today is selected by
161161
// exactly the code that compiled it yesterday (bit-identity is structural,
162162
// not behavioural).
163-
let select_direct_attempt =
164-
|spill_on_exhaustion: bool| -> Result<Vec<ArmInstruction>, synth_core::Error> {
165-
let db = RuleDatabase::with_standard_rules();
166-
let mut selector =
167-
InstructionSelector::with_bounds_check(db.rules().to_vec(), bounds_config);
168-
selector.set_target(config.target.fpu, &config.target.triple);
169-
if config.num_imports > 0 {
170-
selector.set_num_imports(config.num_imports);
171-
}
172-
// #195: plumb the callee argument-count tables so the direct selector can
173-
// marshal call arguments into R0–R3 per AAPCS.
174-
selector.set_func_arg_counts(
175-
config.func_arg_counts.clone(),
176-
config.type_arg_counts.clone(),
177-
);
178-
// #197: in relocatable host-link mode, emit direct `func_N` BLs for
179-
// imports (rewritten to the wasm field name by build_relocatable_elf)
180-
// instead of `__meld_dispatch_import`.
181-
selector.set_relocatable(config.relocatable);
182-
// #237: native-pointer ABI — wasm statics become __synth_wasm_data-relative.
183-
selector.set_native_pointer_abi(config.native_pointer_abi, config.linear_memory_bytes);
184-
// #311: i64 call results are register PAIRS — tag them.
185-
selector.set_result_types(config.func_ret_i64.clone(), config.type_ret_i64.clone());
186-
// Stack-pointer promotion is meaningful only under the native-pointer ABI;
187-
// gating here keeps every non-native compile (all frozen fixtures) on the
188-
// legacy R9 globals-table path, bit-identical.
189-
if config.native_pointer_abi
190-
&& let Some((sp_idx, sp_init)) = config.stack_pointer_global
191-
{
192-
selector.set_native_pointer_stack(sp_idx, sp_init);
193-
}
194-
selector.set_spill_on_exhaustion(spill_on_exhaustion);
195-
selector.select_with_stack(wasm_ops, num_params)
196-
};
163+
let select_direct_attempt = |spill_on_exhaustion: bool,
164+
param_backing_on_exhaustion: bool|
165+
-> Result<Vec<ArmInstruction>, synth_core::Error> {
166+
let db = RuleDatabase::with_standard_rules();
167+
let mut selector =
168+
InstructionSelector::with_bounds_check(db.rules().to_vec(), bounds_config);
169+
selector.set_target(config.target.fpu, &config.target.triple);
170+
if config.num_imports > 0 {
171+
selector.set_num_imports(config.num_imports);
172+
}
173+
// #195: plumb the callee argument-count tables so the direct selector can
174+
// marshal call arguments into R0–R3 per AAPCS.
175+
selector.set_func_arg_counts(
176+
config.func_arg_counts.clone(),
177+
config.type_arg_counts.clone(),
178+
);
179+
// #197: in relocatable host-link mode, emit direct `func_N` BLs for
180+
// imports (rewritten to the wasm field name by build_relocatable_elf)
181+
// instead of `__meld_dispatch_import`.
182+
selector.set_relocatable(config.relocatable);
183+
// #237: native-pointer ABI — wasm statics become __synth_wasm_data-relative.
184+
selector.set_native_pointer_abi(config.native_pointer_abi, config.linear_memory_bytes);
185+
// #311: i64 call results are register PAIRS — tag them.
186+
selector.set_result_types(config.func_ret_i64.clone(), config.type_ret_i64.clone());
187+
// Stack-pointer promotion is meaningful only under the native-pointer ABI;
188+
// gating here keeps every non-native compile (all frozen fixtures) on the
189+
// legacy R9 globals-table path, bit-identical.
190+
if config.native_pointer_abi
191+
&& let Some((sp_idx, sp_init)) = config.stack_pointer_global
192+
{
193+
selector.set_native_pointer_stack(sp_idx, sp_init);
194+
}
195+
selector.set_spill_on_exhaustion(spill_on_exhaustion);
196+
selector.set_param_backing_on_exhaustion(param_backing_on_exhaustion);
197+
selector.select_with_stack(wasm_ops, num_params)
198+
};
197199
let select_direct = || -> Result<Vec<ArmInstruction>, String> {
198-
match select_direct_attempt(false) {
199-
Ok(instrs) => Ok(instrs),
200-
// VCR-RA-001 step 3b-lite (#242): the i32 register-exhaustion
201-
// hard-fail is recoverable — retry once with spill-on-exhaustion,
202-
// which reserves the spill area and spills the deepest stack value
203-
// when the pool is full. Only functions that FAILED the first pass
204-
// ever reach this, so existing output is untouched by construction.
205-
Err(e)
206-
if e.to_string()
207-
.contains("all allocatable registers are live on the stack") =>
208-
{
209-
select_direct_attempt(true)
210-
.map_err(|e| format!("instruction selection failed: {}", e))
211-
}
212-
Err(e) => Err(format!("instruction selection failed: {}", e)),
200+
// The two recoverable exhaustion classes. NOT retried: the i64
201+
// spill-slot-pool Err ("spill-slot pool exhausted") — the honest
202+
// remaining bound of the 3b-lite allocator.
203+
const SINGLE_EXHAUSTION: &str = "all allocatable registers are live on the stack";
204+
const PAIR_EXHAUSTION: &str = "no consecutive pair of free registers for i64";
205+
let mut attempt = select_direct_attempt(false, false);
206+
// VCR-RA-001 step 3b-lite (#242): the i32 register-exhaustion
207+
// hard-fail is recoverable — retry with spill-on-exhaustion, which
208+
// reserves the spill area and spills the deepest stack value when the
209+
// pool is full. Only functions that FAILED the first pass ever reach
210+
// this, so existing output is untouched by construction.
211+
if let Err(e) = &attempt
212+
&& e.to_string().contains(SINGLE_EXHAUSTION)
213+
{
214+
attempt = select_direct_attempt(true, false);
215+
}
216+
// VCR-RA-001 acceptance increment (#242): the i64 consecutive-PAIR
217+
// exhaustion is recoverable too — but not by stack spilling (the pair
218+
// allocator already spills stack values, #171): the blockers are the
219+
// pinned param home registers. The final retry frame-backs the params
220+
// (#204 machinery) so they stop pinning R0-R3, with spill-on-exhaustion
221+
// kept on for the single-register pressure the reloads add. Reached
222+
// only by functions that failed every earlier pass.
223+
if let Err(e) = &attempt
224+
&& e.to_string().contains(PAIR_EXHAUSTION)
225+
{
226+
attempt = select_direct_attempt(true, true);
213227
}
228+
attempt.map_err(|e| format!("instruction selection failed: {}", e))
214229
};
215230

216231
// Instruction selection: optimized or direct.

0 commit comments

Comments
 (0)