Skip to content

VCR-DEC-001 increment 3: graph-colouring allocator colours ACROSS CALLS — 57 of 68 declines taken, measured (#242) - #896

Merged
avrabe merged 3 commits into
mainfrom
feat/v054-l4-call-colouring
Jul 30, 2026
Merged

VCR-DEC-001 increment 3: graph-colouring allocator colours ACROSS CALLS — 57 of 68 declines taken, measured (#242)#896
avrabe merged 3 commits into
mainfrom
feat/v054-l4-call-colouring

Conversation

@avrabe

@avrabe avrabe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

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-indirect68 of the measured corpus — because a
bl had 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 bl as effect-free would
accept a non-identity equation across it."
That was not hypothetical:
validate_cfg_rewrite did exactly that (a non-straight-line op was required
identical and then given NO effect), so an equation (R4, R2) demanded after a
call sailed straight through it.

liveness::call_effect now 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

and BOTH consumers read that one definition — the pass (graph_alloc::joins
liveness / interference / identity pins) and the oracle
(validate_cfg_rewrite's backward transfer). Sharing is deliberate: two
hand-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_effect is deliberately NOT widened: its None-on-call is load-bearing for
the 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-recoloured declines otherwise). Single-block functions
containing 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 from
the ELF symtab, cycles from --emit-wcet sound bounds.

path increment 2 increment 3
relocatable −46 B (−0.11 %) / −25 cyc, 275 applied, 8 shrank / 1 grew −100 B (−0.24 %) / −33 cyc, 307 applied, 24 shrank / 5 grew
self-contained −70 B (−0.14 %) / −9 cyc, 81 applied, 10 shrank / 1 grew −120 B (−0.24 %) / −17 cyc, 108 applied, 27 shrank / 3 grew

Zero WCET-bound regressions on either path (12 bounds shrank, 0 grew).

Decline histogram (relocatable)

reason inc 2 inc 3
unmodeled-op 164 174
single-block 73 73
call 57 0
identity-colouring 18 31
call-indirect 11 11 (call-indirect-pseudo)
unreachable-block 10 11
numeric-branch 9 10

Of 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/CallIndirect pseudo-ops, expanded downstream into
a bounds guard + table load + result move, so the register footprint here is not
the one that ships; they stay declined by name.

Gates

  • Flag stays OFF. SYNTH_GRAPH_ALLOC unset never enters the module; frozen
    anchors byte-identical 10/10 (frozen_codegen_bytes.rs +
    vcr_dec_001_graph_alloc_differential.py property 1).
  • VCR-RA-003 + validate_cfg_rewrite clean on every emitted function
    (observed via SYNTH_RA003_VERBOSE, not inferred from an exit code).
    Necessary, not sufficient — see below.
  • Execution differential extended and CI-wired in the same commit.

Mutation evidence

The contract is shared, so only execution can catch an error in it. A matrix over
its two halves:

mutant outcome
uses = [] (argument half) KILLED. Unit: cfg_validator_rejects_a_renamed_call_argument. Execution: 5 wrong resultscall6(1,2,3,4,5,6) returns 0x00651321 vs wasmtime's 0x00654321 — once the churn bias is replaced by a churn-maximising one that stops masking it. validate_cfg_rewrite AND VCR-RA-003 both accept the mutant.
control: churn-maximising bias alone, contract intact 0 wrong results — so the failure above is attributable to the contract, not to the bias.
defs = [] and uses = [] (the pre-increment-3 effect-free bl — the briefed hazard) KILLED by three unit tests.
defs = [] alone SURVIVES. Documented on call_effect, not papered over — see below.

The surviving mutant is a finding, not a hole. 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 (r, r) argument equation the call generates then
collides with the rewritten definition. So defs' independent duties today are
(a) the non-pool {R12, LR} and (b) keeping the PASS from proposing colourings
the oracle would only reject — reach, not soundness. That flips the moment
uses is 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 defs the sole soundness guard for R0-R3. The
call_effect doc says so, in those words, next to the constants.

Also fixed by the matrix: cfg_validator_rejects_a_renamed_call_argument was
originally vacuous w.r.t. the call model — its bx lr 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.

Execution differential (CI-wired, same commit)

New CALL population covering both halves:

  • CLOBBER halflocal_promote_cross_call::cross_call and
    intra_module_callee_saved::a (both written so a caller-saved home is
    observably wrong) + the self-recursive stack_canary_687::recurse.
  • ARGUMENT halfcall_5args::caller, call_6_7args::call6/call7, whose
    callees 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/blx in its emitted bytes
(self-declaration checked against bytes); its own non-vacuity floor of ≥4
divergent call-containing functions, re-asserted by the CI awk from the new
CALLSHAPES= summary field (#890). 56/56 checks, 19 engaged functions, 6 call
shapes.

Verification (REAL exit codes)

cargo fmt --check 0 · cargo clippy --workspace --all-targets -D warnings 0 ·
cargo test --workspace 0 · claim_check.py 34/34 ·
vcr_dec_001_graph_alloc_differential.py 0 ·
vcr_dec_001_join_alloc_execution_differential.py 0.

Named follow-ups (not done here, deliberately)

  1. Arity-precise argument sets. The conservative all-four uses is the main
    reach 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 defs
    warning above before doing it.
  2. shrink_callee_saved_saves is leaf-only (it declines on any reg_effect == None op, i.e. every call), so evacuating R4-R8 in a call-containing
    function 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.
  3. The Call/CallIndirect pseudo-ops (11 declines) need the allocator to run
    after their expansion, or the expansion's register footprint modeled.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L

avrabe and others added 3 commits July 30, 2026 14:41
… 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

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.42308% with 22 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/synth-synthesis/src/graph_alloc.rs 93.15% 13 Missing ⚠️
crates/synth-synthesis/src/liveness.rs 50.00% 9 Missing ⚠️

📢 Thoughts on this report? Let us know!

@avrabe
avrabe merged commit c102c97 into main Jul 30, 2026
50 checks passed
@avrabe
avrabe deleted the feat/v054-l4-call-colouring branch July 30, 2026 13:55
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant