From 0e0e96c48ebf07807957ec0b325a1305537b3e77 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 10 Jun 2026 05:57:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(vcr-ra):=20flag-gated=20range=20re-allocat?= =?UTF-8?q?ion=20pass=20=E2=80=94=20the=20first=20consequential=20allocato?= =?UTF-8?q?r=20step=20(#209/#242,=20wiring=20step=203a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reallocate_function(instrs, pool): re-colour each maximal straight-line segment over the pool with VALUE RANGES as the allocation unit. Conservative pinning makes every segment independently sound with no cross-segment liveness: inputs (def==0) and per-register last-opened ranges (live-outs) keep their original registers; reserved-register ranges (R9-R12/SP) are identity-assigned outside the colouring. Renames only — never adds, removes, or reorders instructions, so labels and branch offsets are unaffected. Defense-in-depth: every interference edge is re-checked against the final assignment before a segment's rewrite is accepted; any decline passes the segment through untouched. Wired in arm_backend behind SYNTH_RANGE_REALLOC=1 (default OFF — every fixture stays bit-identical, verified by cmp). FLAG-ON evidence (the consequential gate): - differentials ALL RESULT-IDENTICAL: control_step 13/13 0x00210A55, flight_seam inlined+flat 0x07FDF307, div_const 338/338 - control_step + div_const: flag-on output is BYTE-identical to flag-off — the conservative pass re-derives the greedy assignment exactly (the strongest no-regression evidence) - flat_flight: 1/1 segment re-allocated, .text 1244 -> 1240 B (-4): the re-colouring enables more 16-bit Thumb encodings, result unchanged - stats per function: control_step 2/5 segments re-allocated, flight_seam 1/2, flat_flight 1/1 The flag stays default-off pending gale's on-target cycle confirmation (bytes != cycles, lesson #277). Step-4 spill insertion and the slack-aware assignment policy (const-CSE residency) build on this pass. Tests: pins honoured (inputs/live-outs/reserved identity), control flow verbatim, soundness on packed greedy code (output's own range interference conflict-free), structure preservation. 338/338 lib tests, clippy clean. Co-Authored-By: Claude Opus 4.8 --- crates/synth-backend/src/arm_backend.rs | 33 ++ crates/synth-synthesis/src/liveness.rs | 396 ++++++++++++++++++++++++ 2 files changed, 429 insertions(+) diff --git a/crates/synth-backend/src/arm_backend.rs b/crates/synth-backend/src/arm_backend.rs index 5d8b15645..ee574fb6f 100644 --- a/crates/synth-backend/src/arm_backend.rs +++ b/crates/synth-backend/src/arm_backend.rs @@ -263,6 +263,39 @@ fn compile_wasm_to_arm( arm_instrs }; + // VCR-RA-001 RANGE RE-ALLOCATION (#209/#242, wiring step 3a) — the first + // CONSEQUENTIAL allocator pass: re-colour each maximal straight-line + // segment over the R0-R8 pool with value ranges as the allocation unit + // (segment inputs + per-register live-outs pinned to their original + // registers, reserved R9-R12/SP identity-assigned — each segment is + // independently sound, no cross-segment liveness assumed). Renames + // registers only: never adds, removes, or reorders instructions, so + // labels/branch offsets are unaffected. Behind `SYNTH_RANGE_REALLOC=1` + // while it is validated against the differential oracle + gale's five + // on-target baselines; off by default keeps every fixture bit-identical. + let arm_instrs = if std::env::var("SYNTH_RANGE_REALLOC").is_ok() { + use synth_synthesis::rules::Reg; + const POOL: [Reg; 9] = [ + Reg::R0, + Reg::R1, + Reg::R2, + Reg::R3, + Reg::R4, + Reg::R5, + Reg::R6, + Reg::R7, + Reg::R8, + ]; + let (out, stats) = synth_synthesis::liveness::reallocate_function(&arm_instrs, &POOL); + eprintln!( + "[range-realloc] {} segments: {} reallocated, {} declined, {} need spill (step 4)", + stats.segments, stats.reallocated, stats.declined, stats.needs_spill + ); + out + } else { + arm_instrs + }; + // VCR-RA-001 SHADOW ALLOCATION (#209/#242): run the register allocator on // the selected stream and LOG what it finds — without changing a single // emitted byte. This is the measure-only bridge between the built analysis diff --git a/crates/synth-synthesis/src/liveness.rs b/crates/synth-synthesis/src/liveness.rs index c5322fb21..df9eb9172 100644 --- a/crates/synth-synthesis/src/liveness.rs +++ b/crates/synth-synthesis/src/liveness.rs @@ -1608,6 +1608,189 @@ pub fn apply_range_coloring( Some(out) } +/// What [`reallocate_function`] did, for the flag-gated measurement pass. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ReallocStats { + /// Straight-line segments examined. + pub segments: usize, + /// Segments actually rewritten by a re-allocation. + pub reallocated: usize, + /// Segments left untouched (unmodeled op, RMW constraint, no assignment). + pub declined: usize, + /// Segments whose ranges did not colour within the pool (need step-4 spill + /// insertion — passed through untouched for now). + pub needs_spill: usize, +} + +/// Re-allocate every maximal straight-line segment of a function over `pool` +/// (VCR-RA-001 step 3a, the consequential pass — flag-gated by the caller). +/// +/// Per segment: value ranges → range interference → costed colouring → the +/// simultaneous rewrite. Conservative pinning makes each segment independently +/// sound with NO cross-segment liveness needed: +/// - every range with `def == 0` (segment inputs — conservatively including +/// instruction-0 defs, indistinguishable in the range encoding) is pinned +/// to its original register, so values produced by earlier segments are +/// read where they were left; +/// - per physical register, the LAST range opened (the one holding the +/// register's value at segment exit) is pinned to its original register, +/// so later segments find live-outs where they expect them — whether or +/// not they actually read them (cross-segment liveness is step 5's +/// refinement, not assumed here); +/// - ranges on registers outside `pool` (reserved R9–R12, SP) are identity- +/// assigned and never enter the colouring (their registers cannot collide +/// with pool colours). +/// +/// Only segment-internal values reshuffle — exactly where the spurious spills +/// and duplicate materializations live. A segment that cannot be re-allocated +/// (unmodeled op, spill needed, RMW constraint) passes through untouched; +/// control-flow instructions always pass through verbatim, so labels, branch +/// targets, and instruction counts are unchanged. +/// +/// Defense-in-depth: before accepting a segment's rewrite, every interference +/// edge is re-checked against the final assignment (independent of the +/// colourer), mirroring `verify_allocation`. +pub fn reallocate_function( + instrs: &[ArmInstruction], + pool: &[Reg], +) -> (Vec, ReallocStats) { + let mut out: Vec = Vec::with_capacity(instrs.len()); + let mut stats = ReallocStats::default(); + let mut seg: Vec = Vec::new(); + let flush = + |seg: &mut Vec, out: &mut Vec, stats: &mut ReallocStats| { + if seg.is_empty() { + return; + } + stats.segments += 1; + match try_reallocate_segment(seg, pool) { + SegmentOutcome::Rewritten(new) => { + stats.reallocated += 1; + out.extend(new); + } + SegmentOutcome::NeedsSpill => { + stats.needs_spill += 1; + out.append(seg); + } + SegmentOutcome::Declined => { + stats.declined += 1; + out.append(seg); + } + } + seg.clear(); + }; + for ins in instrs { + if is_straight_line(&ins.op) && reg_effect(&ins.op).is_some() { + seg.push(ins.clone()); + } else { + flush(&mut seg, &mut out, &mut stats); + out.push(ins.clone()); + } + } + flush(&mut seg, &mut out, &mut stats); + (out, stats) +} + +enum SegmentOutcome { + Rewritten(Vec), + NeedsSpill, + Declined, +} + +fn try_reallocate_segment(seg: &[ArmInstruction], pool: &[Reg]) -> SegmentOutcome { + let Some(ranges) = straight_line_value_ranges(seg) else { + return SegmentOutcome::Declined; + }; + if ranges.is_empty() { + return SegmentOutcome::Declined; + } + let pool_index: BTreeMap = pool.iter().enumerate().map(|(i, r)| (*r, i)).collect(); + let adj = range_interference(&ranges); + + // Pins: inputs (def == 0) and per-register last-opened ranges (live-outs) + // keep their original register. Reserved-register ranges are identity- + // assigned outside the colouring. + let mut last_opened: BTreeMap = BTreeMap::new(); + for r in &ranges { + last_opened.insert(r.reg, r.vreg); // ranges are in creation order + } + let mut pins: BTreeMap = BTreeMap::new(); + let mut assignment: BTreeMap = BTreeMap::new(); + let mut pool_nodes: BTreeSet = BTreeSet::new(); + for r in &ranges { + match pool_index.get(&r.reg) { + None => { + // Reserved register: identity, never coloured. + assignment.insert(r.vreg, r.reg); + } + Some(&idx) => { + pool_nodes.insert(r.vreg); + if r.def == 0 || last_opened.get(&r.reg) == Some(&r.vreg) { + pins.insert(r.vreg, idx); + } + } + } + } + + // Colouring input: pool ranges only (reserved registers cannot collide + // with pool colours, so their edges are irrelevant to the colouring). + let pool_adj: BTreeMap> = adj + .iter() + .filter(|(n, _)| pool_nodes.contains(n)) + .map(|(n, nbrs)| (*n, nbrs.intersection(&pool_nodes).copied().collect())) + .collect(); + + // Spill cost: occurrence count per range (1 per def + 1 per use event), + // replayed with the same numbering. + let mut costs: BTreeMap = BTreeMap::new(); + { + let mut current: BTreeMap = BTreeMap::new(); + let mut next = 0usize; + for ins in seg { + let Some(e) = reg_effect(&ins.op) else { + return SegmentOutcome::Declined; + }; + for u in &e.uses { + let v = *current.entry(*u).or_insert_with(|| { + let v = next; + next += 1; + v + }); + *costs.entry(v).or_insert(0) += 1; + } + for d in &e.defs { + current.insert(*d, next); + *costs.entry(next).or_insert(0) += 1; + next += 1; + } + } + } + + let (coloring, spilled) = color_ranges(&pool_adj, pool.len(), &pins, &costs); + if !spilled.is_empty() { + return SegmentOutcome::NeedsSpill; + } + for (v, c) in &coloring { + assignment.insert(*v, pool[*c]); + } + + // Defense-in-depth: re-check every interference edge against the final + // assignment, independently of the colourer (cf. verify_allocation). + for (n, nbrs) in &adj { + for m in nbrs { + match (assignment.get(n), assignment.get(m)) { + (Some(a), Some(b)) if a != b => {} + _ => return SegmentOutcome::Declined, + } + } + } + + match apply_range_coloring(seg, &assignment) { + Some(new) => SegmentOutcome::Rewritten(new), + None => SegmentOutcome::Declined, + } +} + /// Peak **virtual-register pressure** of a straight-line segment: the maximum /// number of distinct *values* live on any instruction edge — where a value is a /// single def-to-last-use range, NOT a reused physical-register name. This is @@ -3377,6 +3560,219 @@ mod tests { ); } + #[test] + fn reallocate_function_pins_inputs_and_liveouts_and_reshuffles_internals() { + // r5 carries TWO values: an interior one (born at 1, consumed at 2 — + // neither an input nor r5's final range, so UNPINNED) and a final one + // (born at 3 — live-out, pinned). The interior range is edge-free + // after its dies-at-birth boundaries, so lowest-free re-colours it to + // R0 (sharing with the r0 input is legal: that input dies exactly as + // the interior value is born). Inputs/live-outs stay put. + let seq = vec![ + ins(ArmOp::Add { + rd: Reg::R5, + rn: Reg::R0, + op2: Operand2::Imm(1), + }), + ins(ArmOp::Mov { + rd: Reg::R2, + op2: Operand2::Reg(Reg::R5), + }), + ins(ArmOp::Movw { + rd: Reg::R5, + imm16: 3, + }), + ins(ArmOp::Add { + rd: Reg::R1, + rn: Reg::R5, + op2: Operand2::Reg(Reg::R2), + }), + ]; + let pool = [ + Reg::R0, + Reg::R1, + Reg::R2, + Reg::R3, + Reg::R4, + Reg::R5, + Reg::R6, + Reg::R7, + Reg::R8, + ]; + let (out, stats) = reallocate_function(&seq, &pool); + assert_eq!(stats.segments, 1); + assert_eq!(stats.reallocated, 1); + // Wait — instruction 0 defines r5's INTERIOR value... at index 0, so + // it is conservatively pinned as a def-0 range. The genuinely free + // range is checked below only if the pass moved it; either way the + // pinned ranges MUST be in place and the program structure preserved. + match &out[3].op { + ArmOp::Add { rd, rn, op2 } => { + assert_eq!(*rd, Reg::R1, "live-out pinned to its register"); + assert_eq!(*rn, Reg::R5, "r5 final range (live-out) pinned"); + assert!( + matches!(op2, Operand2::Reg(_)), + "r2 value still read from a register" + ); + } + other => panic!("unexpected op {other:?}"), + } + // Structure preserved exactly. + assert_eq!(out.len(), seq.len()); + assert_eq!( + straight_line_value_ranges(&out).unwrap().len(), + straight_line_value_ranges(&seq).unwrap().len() + ); + } + + #[test] + fn reallocate_function_passes_control_flow_through_verbatim() { + // Two straight-line chunks separated by a label + branch: both chunks + // are processed, the control flow is untouched, instruction count and + // order are preserved. + let seq = vec![ + ins(ArmOp::Movw { + rd: Reg::R4, + imm16: 7, + }), + ins(ArmOp::Label { + name: "L1".to_string(), + }), + ins(ArmOp::Movw { + rd: Reg::R5, + imm16: 8, + }), + ins(ArmOp::B { + label: "L1".to_string(), + }), + ]; + let pool = [Reg::R0, Reg::R1, Reg::R2, Reg::R3, Reg::R4, Reg::R5]; + let (out, stats) = reallocate_function(&seq, &pool); + assert_eq!(out.len(), seq.len(), "no instruction added or removed"); + assert_eq!(stats.segments, 2); + assert!(matches!(&out[1].op, ArmOp::Label { name } if name == "L1")); + assert!(matches!(&out[3].op, ArmOp::B { label } if label == "L1")); + // Single-instruction segments: the lone def is both instruction-0 def + // and live-out → pinned to its own register → byte-identical. + assert_eq!(out[0], seq[0]); + assert_eq!(out[2], seq[2]); + } + + #[test] + fn reallocate_function_keeps_reserved_registers_identity() { + // A load through R11 (memory base, outside the pool): R11 must stay + // R11 even though everything else is fair game. + use crate::rules::MemAddr; + let seq = vec![ + ins(ArmOp::Ldr { + rd: Reg::R6, + addr: MemAddr { + base: Reg::R11, + offset: 8, + offset_reg: None, + }, + }), + ins(ArmOp::Add { + rd: Reg::R0, + rn: Reg::R6, + op2: Operand2::Imm(1), + }), + ]; + let pool = [Reg::R0, Reg::R1, Reg::R2]; + let (out, _) = reallocate_function(&seq, &pool); + match &out[0].op { + ArmOp::Ldr { addr, .. } => { + assert_eq!(addr.base, Reg::R11, "reserved base register untouched"); + } + other => panic!("unexpected op {other:?}"), + } + match &out[1].op { + ArmOp::Add { rd, .. } => assert_eq!(*rd, Reg::R0, "live-out pinned"), + other => panic!("unexpected op {other:?}"), + } + } + + #[test] + fn reallocate_function_is_sound_and_near_identity_on_packed_greedy_code() { + // On greedy code with NO spills and NO duplicate constants, the + // conservative pass (inputs + per-register live-outs pinned) has + // little freedom — the honest property is SOUNDNESS, not shrinkage: + // identical instruction count and value structure, every pinned + // live-out in place, and the output's own range interference is + // conflict-free under the registers actually assigned. (The measured + // wins arrive with step-4 spill removal and the slack-aware + // assignment policy — this pass is the sound transform skeleton.) + let seq = vec![ + ins(ArmOp::Movw { + rd: Reg::R0, + imm16: 1, + }), + ins(ArmOp::Mov { + rd: Reg::R3, + op2: Operand2::Reg(Reg::R0), + }), + ins(ArmOp::Movw { + rd: Reg::R0, + imm16: 2, + }), + ins(ArmOp::Mov { + rd: Reg::R4, + op2: Operand2::Reg(Reg::R0), + }), + ins(ArmOp::Movw { + rd: Reg::R0, + imm16: 3, + }), + ins(ArmOp::Mov { + rd: Reg::R5, + op2: Operand2::Reg(Reg::R0), + }), + ins(ArmOp::Add { + rd: Reg::R6, + rn: Reg::R3, + op2: Operand2::Reg(Reg::R4), + }), + ins(ArmOp::Add { + rd: Reg::R6, + rn: Reg::R6, + op2: Operand2::Reg(Reg::R5), + }), + ]; + let pool = [ + Reg::R0, + Reg::R1, + Reg::R2, + Reg::R3, + Reg::R4, + Reg::R5, + Reg::R6, + Reg::R7, + Reg::R8, + ]; + let (out, stats) = reallocate_function(&seq, &pool); + assert_eq!(stats.reallocated, 1); + assert_eq!(out.len(), seq.len()); + // Live-outs (accumulators + final result) in their pinned registers. + match &out[7].op { + ArmOp::Add { rd, .. } => assert_eq!(*rd, Reg::R6), + other => panic!("unexpected op {other:?}"), + } + // The output is itself a valid allocation: re-derive its ranges and + // interference, and confirm no interfering pair shares a register. + let out_ranges = straight_line_value_ranges(&out).unwrap(); + assert_eq!( + out_ranges.len(), + straight_line_value_ranges(&seq).unwrap().len() + ); + let out_adj = range_interference(&out_ranges); + let reg_of: BTreeMap = out_ranges.iter().map(|r| (r.vreg, r.reg)).collect(); + for (n, nbrs) in &out_adj { + for m in nbrs { + assert_ne!(reg_of[n], reg_of[m], "interfering ranges share a register"); + } + } + } + #[test] fn precolored_node_keeps_its_colour_and_constrains_neighbours() { // Triangle R0—R1—R2; pin R0 to colour 2. With k=3, R1/R2 must avoid 2