Skip to content

RQ-60-VFPPRESSURE increment 2: frame-homed overflow VFP locals — the 13->14 homed-local wall falls, 5/5 falcon cascade stages on cortex-m7dp (Refs #1069) - #1075

Open
avrabe wants to merge 5 commits into
mainfrom
feat/vfp-spill-homed-locals-1069
Open

RQ-60-VFPPRESSURE increment 2: frame-homed overflow VFP locals — the 13->14 homed-local wall falls, 5/5 falcon cascade stages on cortex-m7dp (Refs #1069)#1075
avrabe wants to merge 5 commits into
mainfrom
feat/vfp-spill-homed-locals-1069

Conversation

@avrabe

@avrabe avrabe commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

RQ-60-VFPPRESSURE increment 2 — VFP pressure from homed locals. Refs #1069.

The measurement this is built on (and why the roadmap's fix was wrong)

jess's fixture plus a discriminating variant — same pressure, different residence:

LOCALS (homed)                    OPERAND STACK (#881-rescued)
  13 locals : ok                    30 deep : ok
  14 locals : EXHAUSTED             40 deep : ok
  16 locals : EXHAUSTED             60 deep : ok

Sixty simultaneously-live f32 on the operand stack compile; fourteen in homed locals do not. The 13→14 wall is home residence, not register-file capacity: a home S-register is pinned for the function's extent, and spill_deepest_vfp (correctly) never victims a home. Extending the pool into callee-saved S16..S31 would move the same wall from ~13 to ~29 locals at prologue/epilogue cost on every affected function — re-derived, agreed with the brief, not implemented.

Red first (literal output, commit 7f658f3)

$ synth compile scripts/repro/vfp_local_pressure_1069.wat -t cortex-m7dp --relocatable -o red.o
Compiling function 'live13'...   384 bytes of machine code
warning: skipping function 'live14': ... GI-FPU-002: VFP register file exhausted (S0..S15 all live) —
  ... the backend retries with VFP spilling (#881) and this surfaces only if that also fails
warning: skipping function 'live16': ... VFP register file exhausted ...
warning: skipping function 'live24': ... VFP register file exhausted ...
warning: skipping function 'live8d': ... GI-FPU-002 phase 2: caller-saved VFP D-register file exhausted ...
Error: #952: 4 of 5 requested export(s) were skipped   [exit 1]

After (this branch): all 5 compile; RESULT: PASS, 70/70 emulations bit-identical to wasmtime.

The three design decisions

1. Spill the local, or de-home it? — De-home at birth, never evict. A mid-function eviction must emit a transfer store that dominates every later read; on one arm of a branch it does not (the same dominance hole the stack guard's cf_floor exists to avoid), and the single-pass selector has no dominator analysis. Frame-homing from the first def sidesteps it: local.set is the store, local.get loads — dominance is inherited from wasm's own def-use semantics, since the store sits exactly where wasm writes the local.

2. Choosing the victim — a positional cap, not a next-use heuristic. No pinned local home may sit above S7/D3; a fresh local whose lowest-free grant lands above the cap (or fails) is frame-homed. First-def order decides who keeps a register — honest reason: a single forward pass has no next-use information without a pre-scan, and the cap is what guarantees the invariant that matters for soundness of the rest of the rung: S8..S15 (4 aligned D-pairs) stay reclaimable, which is exactly the worst transient demand in vfp_op_demand (the #869 conversion family, 4 pairs). A furthest-next-use / access-count pre-scan is a named quality follow-up, not a soundness need.

3. Ladder — a separate LAST-resort stage, not a change to the #881 rung. This one was corrected by measurement mid-lane: the first draft gated the cap on vfp_spill_on_exhaustion itself, and the real falcon rate.o moved bytesrate@0.7.0#tick compiles today via the plain rung with f64 homes above D3. So the cap lives behind a new selector lever (set_vfp_frame_home_locals), and the backend tries it only after the plain #881 rung and its pool-grow composition failed:

base → int rungs → #587 pool-grow
  → plain #881 VFP rung (+ pool-grow)          ← yesterday's path, verbatim
    → #1069 frame-homed locals (+ pool-grow sized depth + distinct-local-targets + 4)   ← NEW, last

plain_rung_stays_yesterdays_path_for_shapes_it_already_rescues pins the class red-first: base red, plain rung green by itself, and the frame lever provably produces different instructions for it — the ordering is load-bearing.

The two traps, handled

  • Substring-is-control-flow: the frame-homed slot pool exhaustion message is a pub const (VFP_FRAME_HOME_SLOT_EXHAUSTION) that arm_backend.rs matches directly — one source, no drifting copy. live24 (24 locals > the 8-slot pool) is the test that goes red if the message and the matcher ever separate: it compiles only if the retry fires.
  • arm: i64 register-pair + i64 spill-slot-pool exhaustion still skips functions post-Belady (v0.24.0) — the falcon regalloc remainder (#242) #587 scoping: the pool-grow rung grows slots, not registers — correct that it can't help the register wall. But frame-homed locals hold permanent slots outside the operand-stack depth bound, so the frame stage's grow retry is sized depth + distinct local.set/tee targets + 4. The plain rung's grow keeps yesterday's depth + 4 exactly.

Byte-identity evidence (currently-compiling functions)

Execution differential (CI-wired)

scripts/repro/vfp_local_pressure_1069_differential.py — new oracle_run CI step, emulations >= 40, measured 70:

  • live13/14/16/24 (f32) and live8d (f64) on -t cortex-m7dp --relocatable, linked with arm-none-eabi-ld (any diagnostic hard-fails), executed under unicorn vs wasmtime bit-exact (NaN==NaN per §4.3.3) across ±0, ±1, denormal-underfow, 1e±30, ±inf, NaN;
  • every local's round-tripped value feeds one product tree, so a spill that reloads the wrong slot is a plausible wrong float that flips result bits — the class the comparison exists to catch;
  • #973 ARM corpus sweep re-run PASS (154/167 compiled, 2502/2502 vectors; the new fixture's f32 columns compile on the m4f sweep target, live8d declines by f64 precision there, named in EXPECTED_DECLINES).

The falcon cascade, by symbol name, on cortex-m7dp (the target jess ships)

Pulled the published falcon 1.134.1 components from ghcr (the issue's public repro), compiled -t cortex-m7dp --relocatable --all-exports --embedder-data-init --embedder-global-init:

symbol main (e6a3b27) this branch
pulseengine:falcon-cascade/attitude@0.7.0#tick skipped (S-file exhausted) nm → T
pulseengine:falcon-cascade/ekf@0.7.0#estimate skipped (S-file exhausted) nm → T
pulseengine:falcon-cascade/position@0.7.0#tick skipped (D-file exhausted) nm → T
pulseengine:falcon-cascade/rate@0.7.0#tick nm → T nm → T, bit-identical
pulseengine:falcon-cascade/mixer@0.7.0#mix nm → T nm → T, bit-identical

5/5 cascade stages#1069's definition of done, on the per-stage components. position#tick was not in this increment's assigned scope (phase-2 D-file) but the same mechanism closes it: its D-pressure is inline-conversion temps plus pinned D homes, and capping the homes gives the #869 machinery its 4 pairs back. Bonus: all five also compile on single-precision cortex-m7 (where increment 1 had already cleared position only).

Gates

  • cargo fmt --all clean; cargo clippy --workspace --all-targets -- -D warnings clean; cargo test --workspace exit 0, 152 suites, 0 failures (incl. the 8 new tests and the frozen-anchor byte gates), gated on cargo's own exit code, not a pipeline tail's.
  • python3 scripts/claim_check.py claims.yaml exit 0: selector_lines_code 18910 → 19199 with a same-commit waiver (to: 19199) — growth, not substitution: every existing path is byte-frozen so nothing could be deleted in exchange. selector_lines_total 29556 → 29845. Wildcard pins unmoved (the guard override is written wildcard-free). artifacts/status.json regenerated.

Honest residuals (named, not hidden)

  • Fused-cascade verification is per-stage here, not on the meld-fused image: the local meld (0.9.0) predates fuse --memory shared --pack-rebase (issue used 0.52.0). The per-stage objects are exactly what the issue's step-1 pulls; jess's standing offer in GI-FPU-002: three named cascade entry points are the entire remaining gap to a complete falcon M7 image (attitude#tick, ekf#estimate, position#tick) — everything else in the chain now works #1069 is the right loop for the fused RT1176 image, and the delta above is stated per symbol so it can be checked against it directly.
  • The rescued functions' correctness evidence is the fixture differential, not an executed attitude/ekf run — executing the real #tick/#estimate needs the component ABI/memory environment (jess's on-target loop). The mechanism (frame round-trip under pressure) is what the 70-row differential proves bit-exact, on the same code path.
  • Quality, not soundness: frame-homed locals pay a load per local.get; first-def order (not next-use) decides who keeps a register; a pre-scan ranking locals is a named follow-up. Functions rescued by this stage previously did not compile at all.
  • WCET: a frame-homed local's VLDR/VSTR are straight-line ops on the priced path; nothing new declines, but no new bound claim is made either.
  • Params are untouched — AAPCS fixes their homes (possibly above the cap); only fresh non-param locals get the residence decision. A signature whose f32 params alone fill the file still declines, exactly as before.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L

avrabe and others added 4 commits August 27, 2026 01:27
…austed, 60-deep stack fine

jess's discriminating measurement (fixture credit: jess): the 13->14 wall is
HOME residence, not register-file capacity — the #881 rung rescues 60
simultaneously-live f32 on the operand stack but never spills pinned f32/f64
local homes. live13 is the negative control (must keep compiling, base path);
live14/live16 are the phase-1 S-file red line (attitude@0.7.0#tick /
ekf@0.7.0#estimate shape); live24 forces slot-pool composition with the #587
grow retry; live8d is the D-file twin on cortex-m7dp.

Red at this commit (cortex-m7dp --relocatable):
  live13: 384 bytes; live14/live16/live24: GI-FPU-002 S-file exhausted even
  after the #881 rung; live8d: D-file exhausted.

Refs #1069

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
…g — the 13->14 homed-local wall falls

The wall was HOME residence, not register-file capacity: a home S-register is
pinned for the function's extent and spill_deepest_vfp explicitly skips homes,
so the #881 rung rescued 60-deep operand stacks while 14 homed locals declined.

Design (rung-only, byte-identical base path by construction):
 * Residence at birth, not eviction: a fresh non-param f32/f64 local whose
   register grant would land above the S7/D3 cap (or fails) lives in the frame
   from its first def — local.set stores to a permanent [SP,#slot], local.get
   loads into a fresh temp. The store IS the wasm def, so load-over-store
   dominance is inherited from wasm's own def-use semantics; a mid-function
   eviction's transfer store could sit on one arm of a branch, which is the
   same dominance hole the stack guard's cf_floor exists to avoid.
 * Victim choice: none — the cap is positional (no pinned home above S7/D3),
   keeping S8..S15 reclaimable so the worst transient op demand (4 aligned
   D-pairs, the #869 conversion family) stays satisfiable. First-def order
   decides who keeps a register; a furthest-next-use pre-scan is a named
   quality follow-up, not a soundness need.
 * Ladder: rides the existing vfp_spill_on_exhaustion rung. The permanent
   slots can exhaust the shared 8-slot pool — the new exhaustion message is a
   pub const the backend matches (single source, no drifting mirror), and the
   grow retry now sizes the pool from operand-stack depth + distinct local.set
   targets. The substring-is-control-flow coupling is pinned red-first by the
   live24 test.

Fixture (jess's, + negative control): live13 ok on the BASE path, live14/16/24
green via the rung, live8d the f64 twin. Execution differential
(vfp_local_pressure_1069_differential.py, CI-wired): 70/70 emulations
bit-identical to wasmtime incl. ±0/NaN/inf/denormal underflow — every local's
round-tripped value feeds the product, so a wrong-slot reload flips result
bits. The #881 oracle re-runs green (109/109).

Refs #1069

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
…rung functions keep yesterday's bytes

Found on the real falcon components before this restructure: rate@0.7.0#tick
compiles TODAY via the plain #881 rung with f64 local homes ABOVE the D3 cap,
so gating the residence cap on the rung flag itself moved rate.o's bytes.
The hard requirement is byte-identity for every currently-compiling function
— including ones that compile via the rung — so the cap now lives behind a
separate selector lever (set_vfp_frame_home_locals) that the backend tries
ONLY after the plain rung (and its pool-grow composition) also failed.
Yesterday's three escapes are re-run verbatim first; the frame stage is
reached exclusively by functions that failed all of them.

Evidence: falcon rate.o and mixer.o now bit-identical main vs branch
(cmp exit 0), all five cascade stages still reach nm -> T on cortex-m7dp,
and plain_rung_stays_yesterdays_path_for_shapes_it_already_rescues pins the
class red-first: base path red, plain rung green by itself, and the frame
lever provably produces different instructions for it — the reason the
ordering is load-bearing.

Ratchet: selector_lines_code 18910 -> 19192 with a same-commit waiver
(growth, not substitution: every existing path is byte-frozen so nothing
could be deleted in exchange); selector_lines_total 29556 -> 29838; wildcard
pins unmoved (the guard override is wildcard-free by construction). Corpus
sweep: the new fixture's live8d (f64) declines by precision on the m4f sweep
target — named in EXPECTED_DECLINES; the f32 columns compile there.

Refs #1069

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
…ijacked them), fix stale references, defensively key the spill-area reservation on the frame lever

The inserted #1069 const block landed between try_lower_f32's doc comment +
too_many_arguments allow and the fn item, silently re-attaching both to
VFP_HOME_CAP_S (and explaining why clippy re-flagged the fn). Also: the
frame-map comment updated for the last-resort restructure, the const doc now
names the real live24 test, and compute_local_layout's force_spill_area
condition includes vfp_frame_home_locals so an API caller setting the lever
alone can never be handed slots aliasing the #204 param homes. Ratchet
re-derived after the doc moves: selector_lines_code 19192 -> 19199 (waiver
to: updated in the same commit), selector_lines_total 29838 -> 29845.

Refs #1069

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
avrabe added a commit that referenced this pull request Aug 26, 2026
I flipped it mechanically on "#1073 merged" without reading the artifact's own
DEFINITION OF DONE:

    those three symbols reaching `nm -> T` — 5 of 5 stages exported

Increment 1 did not achieve that. It cleared `position#tick` on single-precision
cortex-m7; on **m7dp — jess's actual flight core — all three symbols still fail
on main**. Increment 2 (#1075, open) is what delivers 5/5. The flip belongs
there.

This is the MIRROR of the orphaned-flip class I have been chasing all release.
Six times I caught a status left `proposed` over shipped code; here I set one to
`implemented` over code that had not shipped the thing the artifact promises.
Both directions under-report the truth, and RQ-60-FLIPCOUPLE should derive
status from EVIDENCE precisely so neither is a judgement call — "the PR merged"
is not the same claim as "the artifact's stated outcome holds".

ALSO: increment 2's written plan is marked RETIRED BY MEASUREMENT rather than
deleted, so it is not re-proposed. The plan was "extend the S pool into
callee-saved S16..S31". A discriminating variant — same pressure, different
RESIDENCE — showed the wall is not capacity: 60 simultaneously-live f32 on the
OPERAND STACK compile while 14 in HOMED LOCALS do not. Pool extension moves the
wall 13 -> ~29: a bigger number and the same wall further out, paid with
prologue/epilogue push/pop. Recorded because a retired plan that leaves no trace
gets proposed again.

v0.60 reads 2/8; VFPPRESSURE lands with #1075.
CI-filter OURS=0, measured 473 = floor 473, claim_check exit 0.

Refs #1069, Refs #1064
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
…tooling, not an oracle

CI red: the #890 oracle-wiring gate flagged `scripts/repro/gen_vfp_local_1069.py`
UNDECLARED. Correct catch — "an oracle nothing runs must SAY so".

The obvious fix would have been wrong. Declaring it `manual` would consume a
slot on a ceiling that is a REAL BUDGET: `count-max: 8`, currently at 8, and
scripts/repro/ORACLE_WIRING.md says in as many words that a ninth "needs a
conversation, not a commit". Spending that conversation on a fixture generator
would devalue a limit that exists to bound the un-run surface.

It is not an oracle. Its own docstring: "The checked-in .wat is the fixture of
record; this generator documents its shape." It asserts nothing, so there is
nothing for CI to fail on.

Moved to scripts/, following the explicit precedent in scripts/tier_census_1021.py:

    # NOTE on home: this lives in scripts/ ... not scripts/repro/ (defect oracles).

and carrying the same marker so the next person does not re-file it under
repro/. Both path references updated.

Verified: oracle_wiring exit 0 ("all repro scripts declare a CI status"),
claim_check exit 0, manual count UNCHANGED at 8, and the generator still
reproduces the committed fixture BYTE-FOR-BYTE (`diff -q` clean) — a moved
generator that silently stopped matching its fixture would be worse than the
red it fixed.

Refs #1069
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
avrabe added a commit that referenced this pull request Aug 27, 2026
…d 5th orphaned flips, plus a floor left slack (#1074)

* chore(rivet): RQ-60-CANARY + RQ-60-A64IMPORT implemented — the 4th and 5th orphaned flips

RQ-60-FLIPCOUPLE is now demonstrating itself. That artifact was filed BECAUSE
three status flips were orphaned in v0.59; here are two more, in the release
that scopes the fix:

  RQ-60-CANARY     its work SHIPPED IN v0.59.0 (#1061, tag-verified) — the
                   artifact was written describing already-delivered work and
                   landed as `proposed` anyway
  RQ-60-A64IMPORT  #1071 merged minutes ago and did not flip its own status

Verified ON MAIN rather than inferred from the merge list:
  expansion_canary_gate_1021.py present, 1 CI invocation, `git tag --contains`
    reports v0.59.0
  aarch64_import_dispatch_1017_differential.py present, 1 CI invocation

ALSO TIGHTENED THE FLOOR 472 -> 473. #1071 added SWVER-025 without raising it.
A floor only fails BELOW itself, so 473-measured against 472-pinned would have
passed QUIETLY — slack, not red. The gate's own rule is that a PR adding
artifacts raises the floor in that PR, and leaving it loose is the first crack
in the discipline the gate exists to create.

That is the same failure shape twice in one commit: a number that drifts in the
direction that still looks green. Both are exactly what RQ-60-FLIPCOUPLE is
scoped to make mechanical instead of vigilant.

Verified BY ID: both resolve `[implemented]`; CI-filter OURS=0; measured 473 =
floor 473; claim_check exit 0.

Refs #1064, Refs #1017, Refs #1021
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L

* chore(rivet): RQ-60-VFPPRESSURE implemented — the SIXTH orphaned flip

#1073 merged as e6a3b27 and did not flip its own status. That is three in
v0.59 and now three in v0.60, in the release whose RQ-60-FLIPCOUPLE artifact
exists to fix exactly this.

Verified ON MAIN, not inferred: 8 `__aeabi_ul2f`/`__aeabi_f2lz` references in
crates/synth-synthesis/src/, and scripts/repro/aeabi_i64_float_1069_differential.py
present.

Six for six, every one caught by querying AFTER the merge rather than by any
mechanism. That is the argument for RQ-60-FLIPCOUPLE stated as data rather than
as a worry: a discipline that depends on somebody remembering fails on the tick
they do not.

Verified BY ID: CANARY / A64IMPORT / VFPPRESSURE all `[implemented]`;
CI-filter OURS=0; measured 473 = floor 473; claim_check exit 0.

Refs #1064, Refs #1069
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L

* fix(rivet): RQ-60-VFPPRESSURE back to `proposed` — my flip was PREMATURE

I flipped it mechanically on "#1073 merged" without reading the artifact's own
DEFINITION OF DONE:

    those three symbols reaching `nm -> T` — 5 of 5 stages exported

Increment 1 did not achieve that. It cleared `position#tick` on single-precision
cortex-m7; on **m7dp — jess's actual flight core — all three symbols still fail
on main**. Increment 2 (#1075, open) is what delivers 5/5. The flip belongs
there.

This is the MIRROR of the orphaned-flip class I have been chasing all release.
Six times I caught a status left `proposed` over shipped code; here I set one to
`implemented` over code that had not shipped the thing the artifact promises.
Both directions under-report the truth, and RQ-60-FLIPCOUPLE should derive
status from EVIDENCE precisely so neither is a judgement call — "the PR merged"
is not the same claim as "the artifact's stated outcome holds".

ALSO: increment 2's written plan is marked RETIRED BY MEASUREMENT rather than
deleted, so it is not re-proposed. The plan was "extend the S pool into
callee-saved S16..S31". A discriminating variant — same pressure, different
RESIDENCE — showed the wall is not capacity: 60 simultaneously-live f32 on the
OPERAND STACK compile while 14 in HOMED LOCALS do not. Pool extension moves the
wall 13 -> ~29: a bigger number and the same wall further out, paid with
prologue/epilogue push/pop. Recorded because a retired plan that leaves no trace
gets proposed again.

v0.60 reads 2/8; VFPPRESSURE lands with #1075.
CI-filter OURS=0, measured 473 = floor 473, claim_check exit 0.

Refs #1069, Refs #1064
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.64840% with 38 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/synth-synthesis/src/instruction_selector.rs 76.55% 34 Missing ⚠️
crates/synth-backend/src/arm_backend.rs 93.22% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

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