Skip to content

Commit 5181d0c

Browse files
avrabeclaude
andauthored
feat(vcr-ra): spill-cost ranking — Chaitin cost/degree spill choice (wiring step 2) (#285)
* feat(vcr-ra): spill-cost ranking — Chaitin cost/degree spill choice (#209/#242, wiring step 2) VCR-RA-001 wiring step 2 (docs/design/vcr-ra-allocator-wiring.md). When no node can be simplified (every degree >= k), the optimistic spill candidate is now chosen by Chaitin's metric — minimise cost/degree, where cost is the node's def+use occurrence count (each spill inserts a store per def and a reload per use) and high degree is good to spill (more interference relieved). - color_graph_precolored_costed(g, k, precolored, costs): the costed variant. Integer cross-multiplied comparison (cost_a*deg_b vs cost_b*deg_a), ties to the smallest register — fully deterministic. - use_def_counts(instrs): per-register def+use occurrence counts via reg_effect — the cost numerator. - color_graph_precolored delegates with an empty cost map: every node defaults to cost 1, so minimising cost/degree degenerates to maximising degree — the historic degree-only candidate AND tie-break, exactly. - allocate_function now computes real counts and ranks spill candidates by them. Pure and unwired: allocate_function is reachable only from the flag-gated shadow pass (eprintln-only) and tests, so emitted bytes are unchanged by construction — and verified: all four fixture ELFs are cmp-bit-identical to main's, and the full differential passes (control_step 13/13 0x00210A55, flight_seam inlined+flat 0x07FDF307, div_const 338/338). Tests: costed_spill_picks_the_cheapest_node_not_the_smallest_reg (K4@k=3, degree-symmetric, costs force R3 — exact spill set {R3}), empty_costs_reproduce_the_degree_only_choice (same graph, no costs → {R0}), use_def_counts_sums_defs_and_uses. 326/326 lib tests green, clippy clean. Next (consequential, full differential gate): virtual-register selector output (flag default-off) -> spill-code insertion -> wire-in + per-function flip -> delete a hard-fail site. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(vcr-ra): settle the step-3 design — 3a post-pass range re-allocation, 3b exhaustion-only virtual temps (#242) 3a is bounded and wins the measured targets (spurious spills, const-CSE residency) but cannot remove the hard-fail; 3b (virtual temp ids >= 9 on the would-have-failed path only) is what meets the acceptance criterion. Both consume the same no-regret machinery (rename_def, range_interference, generic Chaitin core, apply_range_coloring). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(vcr): release-plan tags — SEL-002→v0.11.35, RA-001→v0.11.37, RA-002→v0.11.38 (#242) Release plan lives in rivet (release-vX.Y tags; queryable via rivet list --filter '(has-tag "release-v0.11.35")'). This rivet build has no first-class release: field — friction filed separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c963b7d commit 5181d0c

3 files changed

Lines changed: 191 additions & 12 deletions

File tree

artifacts/verified-codegen-roadmap.yaml

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,16 @@ artifacts:
9898
# cross-check / defense-in-depth). Verified to accept color_graph's own
9999
# output on real codegen. Still unwired. Wiring plan:
100100
# docs/design/vcr-ra-allocator-wiring.md.
101+
# - Spill-cost ranking (wiring step 2): color_graph_precolored_costed picks
102+
# the optimistic spill candidate by Chaitin's cost/degree (cost = def+use
103+
# occurrence count via use_def_counts) instead of degree-only; the uncosted
104+
# API delegates with all-costs-equal, reproducing the historic choice
105+
# exactly. allocate_function now ranks by real counts. Pure, unwired,
106+
# fixtures bit-identical (verified by cmp + full differential).
101107
# Remaining for VCR-RA-001 (CONSEQUENTIAL, changes emitted bytes, full
102-
# differential gate): spill-cost ranking -> virtual-reg selector output
103-
# (flag default-off) -> spill-code insertion -> wire-in + per-function flip
104-
# where the differential proves no-regression -> delete a hard-fail site.
108+
# differential gate): virtual-reg selector output (flag default-off) ->
109+
# spill-code insertion -> wire-in + per-function flip where the
110+
# differential proves no-regression -> delete a hard-fail site.
105111
- id: VCR-RA-001
106112
type: sw-req
107113
title: SSA-based register allocator with spilling (remove the hard-fail)
@@ -116,7 +122,7 @@ artifacts:
116122
architecture (R9 globals / R11 mem-base / R12 IP-scratch) and the
117123
bounds-mode R10 invariant.
118124
status: proposed
119-
tags: [codegen, register-allocation, ssa, regalloc2, track-a]
125+
tags: [codegen, register-allocation, ssa, regalloc2, track-a, release-v0.11.37]
120126
links:
121127
- type: derives-from
122128
target: VCR-001
@@ -303,7 +309,7 @@ artifacts:
303309
(fewer moves per clamp, reuse a resident bound) emitted by the selector.
304310
Independent of the allocator (different code region); composes with it.
305311
status: proposed
306-
tags: [codegen, selector, instruction-selection, clamp, track-b, perf]
312+
tags: [codegen, selector, instruction-selection, clamp, track-b, perf, release-v0.11.35]
307313
links:
308314
- type: derives-from
309315
target: VCR-001
@@ -328,7 +334,7 @@ artifacts:
328334
9-vs-12 gap to native). Make R10 allocatable iff bounds-checking is off.
329335
Composes with the allocator (which reads the pool size from config).
330336
status: proposed
331-
tags: [codegen, register-allocation, pool, reserved-registers, track-c, perf]
337+
tags: [codegen, register-allocation, pool, reserved-registers, track-c, perf, release-v0.11.38]
332338
links:
333339
- type: derives-from
334340
target: VCR-001

crates/synth-synthesis/src/liveness.rs

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,31 @@ pub fn color_graph_precolored(
937937
k: usize,
938938
precolored: &BTreeMap<Reg, usize>,
939939
) -> ColorResult {
940+
// No cost information: every node defaults to cost 1, so minimising
941+
// cost/degree degenerates to maximising degree — exactly the historic
942+
// degree-only heuristic (same candidate, same tie-break).
943+
color_graph_precolored_costed(g, k, precolored, &BTreeMap::new())
944+
}
945+
946+
/// `k`-colouring with precoloured pins **and spill-cost ranking** — VCR-RA-001
947+
/// wiring step 2 (`docs/design/vcr-ra-allocator-wiring.md`).
948+
///
949+
/// When no node can be simplified (every degree ≥ `k`), the optimistic spill
950+
/// candidate is chosen by **Chaitin's metric**: minimise `cost / degree`, where
951+
/// `cost` is the node's def+use occurrence count (each spill inserts a store
952+
/// per def and a reload per use, so cost scales with occurrences) and high
953+
/// degree is *good* to spill (removing the node relieves more interference).
954+
/// Compared cross-multiplied in integers (`cost_a·deg_b < cost_b·deg_a`) to
955+
/// avoid floats; ties break to the smallest register for determinism. A node
956+
/// missing from `costs` defaults to cost 1, so an empty map reproduces the
957+
/// degree-only heuristic exactly. Pure function: a decision, not a mutation.
958+
pub fn color_graph_precolored_costed(
959+
g: &InterferenceGraph,
960+
k: usize,
961+
precolored: &BTreeMap<Reg, usize>,
962+
costs: &BTreeMap<Reg, usize>,
963+
) -> ColorResult {
964+
let cost_of = |r: &Reg| costs.get(r).copied().unwrap_or(1);
940965
// Working adjacency over the FREE (non-precoloured) nodes only; precoloured
941966
// nodes are fixed, so they never enter the simplify worklist — but they
942967
// remain in every free node's neighbourhood (via `g.neighbors`) so they
@@ -949,17 +974,23 @@ pub fn color_graph_precolored(
949974
let mut stack: Vec<Reg> = Vec::with_capacity(adj.len());
950975

951976
// SIMPLIFY / SPILL: push every free node, preferring low-degree (< k) nodes;
952-
// when none qualify, optimistically push the highest-degree node.
977+
// when none qualify, optimistically push the cheapest-to-spill node.
953978
while !adj.is_empty() {
954979
let pick = adj
955980
.iter()
956981
.find(|(_, nbrs)| nbrs.len() < k)
957982
.map(|(r, _)| *r)
958983
.unwrap_or_else(|| {
959-
// No simplifiable node → optimistic spill candidate: highest
960-
// degree (ties broken by register order for determinism).
984+
// No simplifiable node → optimistic spill candidate: minimise
985+
// cost/degree (Chaitin), ties to the smallest register. The
986+
// comparator never returns Equal for distinct registers, so the
987+
// pick is deterministic regardless of iteration order.
961988
adj.iter()
962-
.max_by(|a, b| a.1.len().cmp(&b.1.len()).then(b.0.cmp(a.0)))
989+
.min_by(|a, b| {
990+
(cost_of(a.0) * b.1.len())
991+
.cmp(&(cost_of(b.0) * a.1.len()))
992+
.then(a.0.cmp(b.0))
993+
})
963994
.map(|(r, _)| *r)
964995
.unwrap()
965996
});
@@ -1564,8 +1595,26 @@ pub fn apply_const_cse(instrs: &[ArmInstruction]) -> (Vec<ArmInstruction>, usize
15641595
(out, count)
15651596
}
15661597

1598+
/// Per-register def+use occurrence counts over an instruction stream — the
1599+
/// numerator of Chaitin's spill cost (each spill inserts a store per def and a
1600+
/// reload per use, so spill cost scales with occurrences). Unmodeled ops are
1601+
/// skipped; callers needing strict modelling already gate on
1602+
/// [`interference_graph`] returning `Some`, which implies every op is modeled.
1603+
pub fn use_def_counts(instrs: &[ArmInstruction]) -> BTreeMap<Reg, usize> {
1604+
let mut counts: BTreeMap<Reg, usize> = BTreeMap::new();
1605+
for ins in instrs {
1606+
if let Some(e) = reg_effect(&ins.op) {
1607+
for r in e.defs.iter().chain(e.uses.iter()) {
1608+
*counts.entry(*r).or_insert(0) += 1;
1609+
}
1610+
}
1611+
}
1612+
counts
1613+
}
1614+
15671615
/// Run the register-allocator pipeline on a function: interference graph →
1568-
/// `k`-colouring with `precolored` reserved registers pinned → result. `k` is
1616+
/// `k`-colouring with `precolored` reserved registers pinned and spill
1617+
/// candidates ranked by Chaitin cost (def+use count / degree) → result. `k` is
15691618
/// the allocatable-pool size (e.g. 9 for synth's R0–R8). Pure: it computes a
15701619
/// decision and does not mutate the instruction stream — the call the codegen
15711620
/// wiring (VCR-RA-001) makes, runnable today as a shadow pass.
@@ -1578,7 +1627,8 @@ pub fn allocate_function(
15781627
return AllocationOutcome::Declined;
15791628
};
15801629
let remat_opportunities = analyze_function(instrs).redundant_consts.len();
1581-
match color_graph_precolored(&graph, k, precolored) {
1630+
let costs = use_def_counts(instrs);
1631+
match color_graph_precolored_costed(&graph, k, precolored, &costs) {
15821632
ColorResult::Colored(coloring) => AllocationOutcome::Allocated {
15831633
coloring,
15841634
remat_opportunities,
@@ -2493,6 +2543,70 @@ mod tests {
24932543
assert!(matches!(color_graph(&g, 0), ColorResult::Spilled(_)));
24942544
}
24952545

2546+
#[test]
2547+
fn costed_spill_picks_the_cheapest_node_not_the_smallest_reg() {
2548+
// K4 at k=3: every node has degree 3, so degree alone cannot
2549+
// discriminate — the degree-only heuristic falls back to the smallest
2550+
// register (R0). With costs, R3 is by far the cheapest to spill
2551+
// (fewest def+use occurrences), so Chaitin's cost/degree metric must
2552+
// pick it instead. Spilling R3 leaves K3, which 3-colours, so the
2553+
// spill set is exactly {R3}.
2554+
let g = clique(&[Reg::R0, Reg::R1, Reg::R2, Reg::R3]);
2555+
let costs: BTreeMap<Reg, usize> =
2556+
[(Reg::R0, 10), (Reg::R1, 10), (Reg::R2, 10), (Reg::R3, 2)].into();
2557+
match color_graph_precolored_costed(&g, 3, &BTreeMap::new(), &costs) {
2558+
ColorResult::Spilled(s) => {
2559+
assert_eq!(s, BTreeSet::from([Reg::R3]), "cheapest node spills");
2560+
}
2561+
ColorResult::Colored(_) => panic!("K4 cannot be 3-coloured"),
2562+
}
2563+
}
2564+
2565+
#[test]
2566+
fn empty_costs_reproduce_the_degree_only_choice() {
2567+
// Same K4 at k=3 with NO costs: all nodes default to cost 1, the
2568+
// ratios tie, and the tie-break picks the smallest register — the
2569+
// historic degree-only behavior, byte-for-byte.
2570+
let g = clique(&[Reg::R0, Reg::R1, Reg::R2, Reg::R3]);
2571+
match color_graph(&g, 3) {
2572+
ColorResult::Spilled(s) => {
2573+
assert_eq!(
2574+
s,
2575+
BTreeSet::from([Reg::R0]),
2576+
"degree-only tie → smallest reg"
2577+
);
2578+
}
2579+
ColorResult::Colored(_) => panic!("K4 cannot be 3-coloured"),
2580+
}
2581+
}
2582+
2583+
#[test]
2584+
fn use_def_counts_sums_defs_and_uses() {
2585+
// movw r0,#7 → r0: 1 def
2586+
// add r1, r0, r0 → r1: 1 def, r0: +2 uses
2587+
// add r2, r1, r0 → r2: 1 def, r1: +1 use, r0: +1 use
2588+
let seq = vec![
2589+
ins(ArmOp::Movw {
2590+
rd: Reg::R0,
2591+
imm16: 7,
2592+
}),
2593+
ins(ArmOp::Add {
2594+
rd: Reg::R1,
2595+
rn: Reg::R0,
2596+
op2: Operand2::Reg(Reg::R0),
2597+
}),
2598+
ins(ArmOp::Add {
2599+
rd: Reg::R2,
2600+
rn: Reg::R1,
2601+
op2: Operand2::Reg(Reg::R0),
2602+
}),
2603+
];
2604+
let c = use_def_counts(&seq);
2605+
assert_eq!(c.get(&Reg::R0), Some(&4));
2606+
assert_eq!(c.get(&Reg::R1), Some(&2));
2607+
assert_eq!(c.get(&Reg::R2), Some(&1));
2608+
}
2609+
24962610
#[test]
24972611
fn precolored_node_keeps_its_colour_and_constrains_neighbours() {
24982612
// Triangle R0—R1—R2; pin R0 to colour 2. With k=3, R1/R2 must avoid 2

docs/design/vcr-ra-allocator-wiring.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,62 @@ Coalescing, live-range splitting, and rematerialisation are regalloc2-class
9797
refinements deferred until the basic spill-capable allocator is wired and proven.
9898
The first win is simply: **stop hard-failing**, so the cost-gates become
9999
revert-able.
100+
101+
## Step 3 design decision (settled 2026-06-09, before any code)
102+
103+
Step 3 splits into two pieces with different power and different risk. They are
104+
sequenced — 3a first, because it is bounded and delivers the measured wins; 3b
105+
second, because only it can meet the acceptance criterion.
106+
107+
### 3a. Post-pass range re-allocation (bounded; lands first)
108+
109+
Re-allocate the **greedy physical stream** after selection: split it into value
110+
ranges (`straight_line_value_ranges` — already landed), build interference over
111+
*ranges* instead of physical registers, colour with the costed Chaitin pass
112+
(step 2), and rewrite each range to its assigned register.
113+
114+
- **What it wins (already measured):** removes the spurious spills (flat_flight
115+
peak value-pressure = 9 = pool → 17 spills eliminable) and restores constant
116+
residency, which is what lets `apply_const_cse` fire >0 times.
117+
- **What it cannot win:** the hard-fail sites. The selector fails *during
118+
emission*, before any post-pass runs — so 3a does not remove the exhaustion
119+
hard-fail and does not unlock the cost-gate reverts.
120+
- **Machinery needed (no-regret — 3b consumes the same pieces):**
121+
`rename_def` (the def-side sibling of `rename_use`), `range_interference`
122+
(ranges → adjacency over vreg ids; ranges interfere iff
123+
`a.def < b.last_use && b.def < a.last_use`, plus co-defined ranges — the
124+
dies-at-birth boundary case is deliberately non-interfering so a consumer can
125+
reuse its operand's register, the in-place-select pattern), a Chaitin core
126+
generic over node id (the step-2 colourer refactored from `Reg` to any
127+
`Ord + Copy`), and `apply_range_coloring` (replay ranges, rename uses from the
128+
open-range map then defs from the at-this-index map).
129+
- **Scope guard:** leaf, straight-line, fully-modeled segments only — the same
130+
scope every analysis in `liveness.rs` already declines outside of. Flag-gated
131+
default-off; bounds: bit-identical fixtures when off, measured spill/movw
132+
delta when on.
133+
134+
### 3b. Selector-side virtual temps (the hard-fail remover; lands second)
135+
136+
Teach `select_with_stack` to emit **virtual temp ids past the physical pool**
137+
instead of hard-failing in `alloc_temp_safe`: when all of R0–R8 are pinned by
138+
live stack values, hand out a virtual id (representation: keep `next_temp: u8`
139+
but let indices ≥ 9 denote virtuals — no `Reg` enum change; the virtual ids
140+
exist only between selection and allocation, and 3a's allocator+rewrite maps
141+
them onto physical registers, spilling per step 4 where k-colouring fails).
142+
Encoding is unreachable for virtual ids by construction: the allocate+rewrite
143+
pass runs before the encoder and either assigns physical registers or spills.
144+
145+
- This — and only this — removes the exhaustion hard-fail, which is the
146+
VCR-RA-001 acceptance criterion ("a previously load-bearing greedy fix
147+
becomes revertable").
148+
- Risk containment: virtual ids are emitted **only on the would-have-failed
149+
path** at first (the existing 9-register fast path stays byte-identical for
150+
every function that never exhausts), so all frozen fixtures are untouched by
151+
construction until the flag flips wider.
152+
153+
### Why not pure selector-side vregs from the start
154+
155+
Rewriting the selector to emit vregs for *every* temp invalidates the byte-level
156+
behavior of every function at once — the opposite of the per-function,
157+
differential-gated migration this plan requires. 3a + exhaustion-only-3b keeps
158+
the frozen world the default at every merge.

0 commit comments

Comments
 (0)