VCR-DEC-001 increment 3: graph-colouring allocator colours ACROSS CALLS — 57 of 68 declines taken, measured (#242) - #896
Merged
Conversation
… AND validator (#242) Increment 2's second-largest decline bucket was `call` / `call-indirect` (68 of the measured corpus): a `bl` had no modeled effect, so the join CFG builder refused the whole function. This increment models the AAPCS call boundary. ONE definition, TWO consumers — deliberately. `liveness::call_effect` states the contract once: defs = {R0,R1,R2,R3,R12,LR} (call-clobbered) uses = {R0,R1,R2,R3} + a `blx`'s target (arguments, conservatively all four — the callee signature is invisible here, and guessing an arity would silently corrupt a call whose argument we then recoloured) and it is consumed by BOTH `graph_alloc::joins` (liveness / interference / identity pins) AND `validate_cfg_rewrite`'s backward transfer. Modeling it in the pass alone would have been the #872 defect verbatim — the lane brief's own hazard: "a validator treating `bl` as effect-free would accept a non-identity equation across it", i.e. it would certify its own pass's "live value parked in call-clobbered scratch" miscompile. Before this commit `validate_cfg_rewrite` did exactly that (non-straight-line ops were required identical and then given NO effect). Deliberately NOT widening `reg_effect`: its `None`-on-call is load-bearing for the shipping pipeline (`body_uses_callee_saved`'s fail-safe prologue, `shrink_callee_saved_saves`' decline, VCR-RA-003 invariant 1), so widening it would move shipped bytes. `call_effect` is a separate, additive function. Pass side: `bl`/`blx` classify as FALL-THROUGH (interior, not a terminator); their webs are identity-pinned like a `Push`/`Pop` register list (#888) and the op is emitted VERBATIM with a re-check that both rename maps are the identity (`call-operand-recoloured` declines otherwise). Single-block functions containing a call are taken here too — increment 1 structurally cannot, since a call is not `is_straight_line`. Still declined by name: the HIGH-LEVEL `Call`/`CallIndirect` pseudo-ops (expanded downstream into a bounds guard + table load + result move, so the footprint here is not the one that ships). MEASURED (scripts/repro/vcr_dec_001_join_alloc_measure.py, ARM repro corpus): relocatable inc2 -46 B / -25 cyc, 275 applied -> inc3 -100 B / -33 cyc, 307 applied; 24 functions shrank (was 8), 0 cycle regressions self-contained inc2 -70 B / -9 cyc, 81 applied -> inc3 -120 B / -17 cyc, 108 applied; 27 shrank (was 10), 0 cycle regressions Decline histogram: `call` 57 -> 0 (taken); `call-indirect` 11 -> 11 (renamed `call-indirect-pseudo`, still out of scope). Flag stays OFF by default: frozen anchors 10/10 byte-identical, the flag-off path never enters this module. RED-FIRST, with the mutation that attributes the rejection to the call model: `cfg_validator_rejects_a_live_value_recoloured_across_a_call` shows R4->R2 across a `bl` is REJECTED (DefClobbersEquation) while the IDENTICAL rename over the IDENTICAL instructions with the `bl` DELETED is ACCEPTED — so the rejection comes from the call contract and nothing else. Plus: R4->R5 (callee-saved, both saved) accepted; a renamed call ARGUMENT rejected; the pass proven to colour a post-call temporary into scratch and to keep a cross-call value callee-saved. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
… floor (#242) The AAPCS call contract is SHARED by the pass and `validate_cfg_rewrite` on purpose (two hand-maintained copies would be the VCR-ORACLE mirror-pinning failure mode) — which means neither validator can catch an error IN the contract itself. Only execution can. So the join execution differential grows a CALL population covering both halves of the contract: CLOBBER half — a value live ACROSS a `bl` must not be homed in caller-saved scratch: local_promote_cross_call::cross_call and intra_module_callee_saved::a (both written precisely so a caller-saved home is observably wrong), plus stack_canary_687::recurse (self-recursion: every activation re-enters the same allocation). ARGUMENT half — the registers a call READS must still hold what the callee expects: call_5args::caller and call_6_7args::call6/call7, whose callees pack each argument into its own nibble, so ANY dropped/shifted/mis-assigned argument changes the result. Harness changes: * AAPCS argument passing: 0-3 in R0-R3 (was R0-R2), 4+ on an 8-byte-aligned stack with the fifth at [sp,#0]. Without this the 5/6/7-arg fixtures would look like miscompiles. * `contains_call()` verifies a case's `is_call_shape` DECLARATION against the emitted Thumb bytes (bl imm / blx reg). A fixture whose call got inlined away can no longer inflate the increment-3 population while testing nothing. * Its own non-vacuity FLOOR: <4 divergent call-containing functions FAILS, and the machine-readable summary line carries CALLSHAPES= so the CI awk pins the same floor (#890 — exit 0 alone is never trusted). Result: 56/56 checks, 19 engaged functions of which 6 are call shapes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
…ANGELOG (#242) MUTATION MATRIX over the shared AAPCS contract (`liveness::call_effect`), run to find out what each half is actually buying: uses = [] KILLED by cfg_validator_rejects_a_renamed_call_argument (unit) and by the execution differential (5 wrong results: call6(1,2,3,4,5,6) -> 0x00651321 vs 0x00654321) once the churn bias is replaced by a churn-MAXIMISING one. Control: the churn-maximising bias ALONE, contract intact, gives 0 wrong results — so the failure is attributable to the contract, not to the bias. defs = [] SURVIVES. Documented, not papered over: with the conservative `uses` intact the two halves OVERLAP for the R0-R8 pool (a value live across a call must be defined above it, and the argument equation then collides with the rewritten definition). `defs`' independent duties today are the non-pool {R12,LR} and keeping the PASS from proposing colourings the oracle would only reject. The `call_effect` doc now warns that an arity-precise `uses` — the obvious next reach win — makes `defs` the SOLE soundness guard for R0-R3, so the two must be narrowed/tested together. both = [] (the pre-increment-3 effect-free `bl` — the briefed hazard) KILLED by three unit tests. `cfg_validator_rejects_a_renamed_call_argument` was sharpened after the matrix showed the original R0-staged/`bx lr` form was vacuous w.r.t. the call model: `bx lr`'s STRICT exit seed demanded R0 all by itself. It now stages R3 and renames to R2 inside a `pop {…,pc}` frame, where BOTH are dead-out per `cfg_exit_observable`, so only the argument use can reject it. Also: the measure script's docstring no longer claims the self-contained half "must be flat" (it is not, and was not in increment 2 either — 108 functions apply there now), and names the decline histogram as the actionable output that picks the next increment. Verification by REAL exit code: cargo fmt --check 0, clippy --workspace --all-targets -D warnings 0, cargo test --workspace 0 (frozen anchors 10/10), claim_check 34/34, byte differential 0, execution differential 0 (56/56, CALLSHAPES=6). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
avrabe
added a commit
that referenced
this pull request
Jul 30, 2026
…NGELOG after rebase Two fixes on top of the rebase onto main (which now carries L4 #896 and the bare-pipefail hardening #900). 1. **wat2wasm was missing.** Both failing sweeps died with `FileNotFoundError: 'wat2wasm'`. That is the gate DOING ITS JOB: these 63 oracles had never run in CI, so their host dependencies had never been discovered — exactly the class that made the #881 VFP oracle fail on the runner while passing locally (#850). Several fixtures build their .wasm from .wat at test time. wabt installed in all four sweep jobs. 2. **Restored this lane's CHANGELOG entries.** My conflict resolution kept the `# ci-status:` header lines plus main's side, which is correct for the repro scripts and WRONG for an additive CHANGELOG — it silently dropped all four of the lane's bullets. Recovered verbatim from the pre-rebase branch and merged with the existing entry. Caught by counting bullets after the rebase rather than trusting that a clean `rebase --continue` meant a clean result. Rebase conflict in vcr_dec_001_join_alloc_execution_differential.py resolved keeping BOTH sides: L4's updated "increments 2+3" docstring and this lane's ci-status header. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
v0.54 lane L4 — VCR-DEC-001 increment 3 (epic #242, the North Star allocator endgame).
Flag-off measurement spike. Do not merge before the coordinator sequences the lane fan-in.
What
Increment 2 (v0.53) colours across if/else joins. Its second-largest decline
bucket was
call/call-indirect— 68 of the measured corpus — because ablhad no modeled effect and the join CFG builder refused the whole function.This increment models the AAPCS call boundary and takes 57 of the 68.
The hazard, and how it is closed
The lane brief named it: "calls need the AAPCS clobber contract modelled in
BOTH the pass AND the validator — a validator treating
blas effect-free wouldaccept a non-identity equation across it." That was not hypothetical:
validate_cfg_rewritedid exactly that (a non-straight-line op was requiredidentical and then given NO effect), so an equation
(R4, R2)demanded after acall sailed straight through it.
liveness::call_effectnow states the contract once:and BOTH consumers read that one definition — the pass (
graph_alloc::joinsliveness / interference / identity pins) and the oracle
(
validate_cfg_rewrite's backward transfer). Sharing is deliberate: twohand-maintained copies would be the VCR-ORACLE mirror-pinning failure mode. The
honest consequence — neither validator can catch an error in the contract —
is what the execution differential is for.
reg_effectis deliberately NOT widened: itsNone-on-call is load-bearing forthe shipping pipeline (fail-safe prologue,
shrink_callee_saved_saves' decline,VCR-RA-003 invariant 1), so widening it would move shipped bytes.
Calls are emitted verbatim, with both rename maps re-checked as the identity
(
call-operand-recoloureddeclines otherwise). Single-block functionscontaining a call are taken too — increment 1 structurally cannot, since a call
is not
is_straight_line.Measured (the deliverable)
scripts/repro/vcr_dec_001_join_alloc_measure.py, ARM repro corpus. Bytes fromthe ELF symtab, cycles from
--emit-wcetsound bounds.Zero WCET-bound regressions on either path (12 bounds shrank, 0 grew).
Decline histogram (relocatable)
unmodeled-opsingle-blockcallidentity-colouringcall-indirectcall-indirect-pseudo)unreachable-blocknumeric-branchOf the 57 taken, 32 became applied colourings; the other 25 moved on to the NEXT
construct they contain (a numeric branch, an unmodeled i64/FP op, or an identity
colouring) — a decline that MOVED, never one that was deleted. The residual 11
are the high-level
Call/CallIndirectpseudo-ops, expanded downstream intoa bounds guard + table load + result move, so the register footprint here is not
the one that ships; they stay declined by name.
Gates
SYNTH_GRAPH_ALLOCunset never enters the module; frozenanchors byte-identical 10/10 (
frozen_codegen_bytes.rs+vcr_dec_001_graph_alloc_differential.pyproperty 1).validate_cfg_rewriteclean on every emitted function(observed via
SYNTH_RA003_VERBOSE, not inferred from an exit code).Necessary, not sufficient — see below.
Mutation evidence
The contract is shared, so only execution can catch an error in it. A matrix over
its two halves:
uses = [](argument half)cfg_validator_rejects_a_renamed_call_argument. Execution: 5 wrong results —call6(1,2,3,4,5,6)returns0x00651321vs wasmtime's0x00654321— once the churn bias is replaced by a churn-maximising one that stops masking it.validate_cfg_rewriteAND VCR-RA-003 both accept the mutant.defs = []anduses = [](the pre-increment-3 effect-freebl— the briefed hazard)defs = []alonecall_effect, not papered over — see below.The surviving mutant is a finding, not a hole. With the conservative
usesintact the two halves OVERLAP for the R0-R8 pool: a value live across a call must
be defined above it, and the
(r, r)argument equation the call generates thencollides with the rewritten definition. So
defs' independent duties today are(a) the non-pool
{R12, LR}and (b) keeping the PASS from proposing colouringsthe oracle would only reject — reach, not soundness. That flips the moment
usesis narrowed: an arity-precise argument set (the obvious next reach win —an unused argument register currently stays live all the way back from the call
and blocks that colour) makes
defsthe sole soundness guard for R0-R3. Thecall_effectdoc says so, in those words, next to the constants.Also fixed by the matrix:
cfg_validator_rejects_a_renamed_call_argumentwasoriginally vacuous w.r.t. the call model — its
bx lrSTRICT exit seed demandedR0 all by itself. It now stages R3 and renames to R2 inside a
pop {…,pc}framewhere both are dead-out per
cfg_exit_observable, so only the argument usecan reject it.
Execution differential (CI-wired, same commit)
New CALL population covering both halves:
local_promote_cross_call::cross_callandintra_module_callee_saved::a(both written so a caller-saved home isobservably wrong) + the self-recursive
stack_canary_687::recurse.call_5args::caller,call_6_7args::call6/call7, whosecallees pack each argument into its own nibble, so any dropped, shifted or
mis-assigned argument changes the result.
Harness: AAPCS argument passing (0-3 in R0-R3, 4+ on an 8-byte-aligned stack —
without it the 5/6/7-arg fixtures would look like miscompiles); every declared
call shape is verified to really CONTAIN a
bl/blxin its emitted bytes(self-declaration checked against bytes); its own non-vacuity floor of ≥4
divergent call-containing functions, re-asserted by the CI
awkfrom the newCALLSHAPES=summary field (#890). 56/56 checks, 19 engaged functions, 6 callshapes.
Verification (REAL exit codes)
cargo fmt --check0 ·cargo clippy --workspace --all-targets -D warnings0 ·cargo test --workspace0 ·claim_check.py34/34 ·vcr_dec_001_graph_alloc_differential.py0 ·vcr_dec_001_join_alloc_execution_differential.py0.Named follow-ups (not done here, deliberately)
usesis the mainreach cost: an argument register with no in-function definition stays live
from the entry to the call and blocks that colour across the whole prefix.
Requires threading callee signatures into the allocator. Read the
defswarning above before doing it.
shrink_callee_saved_savesis leaf-only (it declines on anyreg_effect == Noneop, i.e. every call), so evacuating R4-R8 in a call-containingfunction does not shrink its prologue today — increment 3's size win comes
from encoding widths, not from the push/pop lever that drove increment 2.
Widening it would change SHIPPING bytes, so it is out of scope for a flag-off
spike; it is where the next chunk of this reach converts into bytes.
Call/CallIndirectpseudo-ops (11 declines) need the allocator to runafter their expansion, or the expansion's register footprint modeled.
🤖 Generated with Claude Code
https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L