Skip to content

feat(#851): aarch64 non-param locals — zero-init stack slots, copy-semantics get/set/tee#856

Merged
avrabe merged 3 commits into
mainfrom
feat/aarch64-locals-851
Jul 23, 2026
Merged

feat(#851): aarch64 non-param locals — zero-init stack slots, copy-semantics get/set/tee#856
avrabe merged 3 commits into
mainfrom
feat/aarch64-locals-851

Conversation

@avrabe

@avrabe avrabe commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

What

The -b aarch64 backend loud-declined every function with a local declaration beyond its params (local.get {i}: non-param locals not yet supported), blocking almost all real functions. This lane (L2 of the v0.51 hub) lowers non-param locals.

Closes part of #851.

How

Lowering (selector.rs):

  • Each non-param local (index >= num_params) gets a fixed 8-byte stack slot at [sp, #(idx-num_params)*8]. The frame is sized to a 16-byte SP-aligned multiple and only materialized when the function actually has one or more non-param locals — a params-only function is byte-identical to before (localizing guard).
  • Prologue lowers SP and zero-initializes every slot with str xzr (WASM locals default to 0). 64-bit stores clear the whole slot so both i32 and i64 read back intact.
  • local.get LOADS the slot into a fresh temp (ldr) — a copy, never the home location — so a later local.set of the same index cannot alias a value already on the value stack. This is the aliasing miscompile the register-by-reference param path would hit.
  • local.set stores + pops; local.tee stores without popping.
  • Writing a PARAMETER is LOUD-DECLINED (params live in arg registers by reference; homing written params is a later increment) — never a silent write. FP non-param locals fall out honestly via the GP file-check (local types are not threaded to the backend).
  • Epilogue restores SP before ret. Interacts correctly with the Feature challenge: add an aarch64 (host-native) backend so output runs and debugs natively on arm64 dev machines #538-cf void blocks: the block End takes the ctrl-pop path (no epilogue), so SP is balanced on every path.

Encoder (encoder.rs): adds str_x_imm/ldr_x_imm (64-bit unsigned-offset load/store) and sub_imm64/add_imm64 (SP adjust), each cross-checked against clang -target aarch64-linux-gnu ground truth.

Gate evidence (RED-first)

scripts/repro/aarch64_locals_851_differential.py compiles aarch64_locals_851.wat with synth compile -b aarch64 and diffs every exported function against wasmtime under two oracles: unicorn (portable/CI) and — on an arm64 host — native MAP_JIT (the real silicon calls the emitted .text through ctypes).

  • RED (before): a fix-less binary declines all 8 functions (non-param locals not yet supported / unsupported wasm op: LocalSet/LocalTee); the harness detects the decline and reports RED. Verified.
  • GREEN (after): 17/17 cases match wasmtime across all three oracles.

Load-bearing cases (pass a naive lowering, fail a correct one):

  • get_set_get_no_aliaslocal.get must COPY (returns 5, not the 10 a read-by-reference model gives).
  • zero_init_read_first — a local read before any write is 0.
  • i64_local — the 64-bit slot preserves full width.
  • local_across_block — non-param local read/written across a void block + br_if; SP balanced on both paths.

Verification

  • cargo test -p synth-backend-aarch64 — 65 passed (incl. 7 new locals tests).
  • cargo clippy -p synth-backend-aarch64 --all-targets -- -D warnings — clean.
  • cargo fmt — clean.
  • Frozen ARM anchors untouched: frozen_codegen_bytes 10/10 (separate backend, ARM codegen has no dep on this crate).
  • No regression: aarch64_m2_538_differential.py 182/182 still green.

Coordination

Does NOT touch .github/workflows/ci.yml or scripts/repro/aarch64_matrix.sh (lane L1 owns those — memory + gate vendoring + CI wiring). This lane touches only synth-backend-aarch64 source + its own new differential (aarch64_locals_851.{wat,py}) + doc prose.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L

avrabe and others added 3 commits July 23, 2026 19:38
…et/set/tee

The `-b aarch64` backend loud-declined every function with a `local`
declaration beyond its params (`local.get {i}: non-param locals not yet
supported`), blocking almost all real functions.

Lower non-param locals to a stack-slot frame:
- Each non-param local (index >= num_params) gets a fixed 8-byte slot at
  `[sp, #(idx-num_params)*8]`. The frame is sized to a 16-byte multiple
  (SP alignment) and only materialized when the function actually has one
  or more non-param locals — a params-only function is byte-identical to
  before (localizing guard, verified by `no_non_param_locals_is_byte_identical`).
- Prologue lowers SP and ZERO-INITs every slot with `str xzr` (WASM locals
  default to 0). 64-bit stores clear the whole slot so both i32 and i64 locals
  read back intact.
- `local.get` LOADS the slot into a FRESH temp (`ldr`) — a copy, never the home
  location — so a later `local.set` of the same index cannot alias a value
  already on the value stack. This is the aliasing miscompile the register-by-
  reference param path would hit; gated by `local_get_set_get_no_alias`
  (0+5=5, not 5+5=10).
- `local.set` stores + pops; `local.tee` stores WITHOUT popping.
- Writing a PARAMETER is LOUD-DECLINED (params live in arg registers by
  reference; homing written params is a later increment) — never a silent write.
- FP non-param locals fall out honestly: `pop_gp`/the tee file-check reject an
  FP value (local types are not threaded to the backend), so an FP local is an
  honest error, not wrong code.

Encoder: add `str_x_imm`/`ldr_x_imm` (64-bit unsigned-offset load/store) and
`sub_imm64`/`add_imm64` (SP adjust), each cross-checked against
`clang -target aarch64-linux-gnu` ground truth.

Does not touch the ARM/RV32 backends or any frozen anchor (separate backend).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
`aarch64_locals_851_differential.py` compiles `aarch64_locals_851.wat` with
`synth compile -b aarch64` and diffs every exported function against wasmtime,
under TWO oracles: unicorn (portable, CI path) and — on an arm64 host — native
MAP_JIT (the real silicon calls the emitted `.text` through ctypes).

Every function uses a non-param local, so:
- BEFORE #851 the compile DECLINES all 8 (`local.get {i}: non-param locals not
  yet supported` / `unsupported wasm op: LocalSet/LocalTee`) — the harness
  detects the decline and reports RED. Verified against a pre-fix binary.
- AFTER #851 all 14 cases match wasmtime under both oracles — GREEN.

The load-bearing cases (pass a naive lowering, fail a correct one):
- `get_set_get_no_alias` — local.get must COPY (returns 5, not the 10 a
  read-by-reference model gives).
- `zero_init_read_first`  — a local read before any write is 0.
- `i64_local`             — the 64-bit slot preserves full width.

Does not touch ci.yml or the vendored `aarch64_matrix.sh` (lane L1 owns those).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
…e case

- selector.rs module doc: the "still declined" list no longer claims non-param
  locals are unsupported; documents the #851 slot model + the remaining
  written-param / FP-local declines.
- FEATURE_MATRIX.md AArch64 row: adds the non-param-locals capability and the
  written-param / FP-local declines.
- New unit test `local_across_void_block_balances_sp` + differential function
  `local_across_block`: a non-param local read/written across a void `block`
  with an early `br_if` out, asserting exactly one prologue `sub sp` and one
  epilogue `add sp` (SP balanced on both the taken and fall-through paths, the
  block End takes the ctrl-pop path with no epilogue). Green across all three
  oracles (wasmtime/unicorn/native): param0=0→2, param0!=0→1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
@avrabe
avrabe merged commit f2ccce1 into main Jul 23, 2026
43 of 44 checks passed
@avrabe
avrabe deleted the feat/aarch64-locals-851 branch July 23, 2026 18:15
@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.74775% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/synth-backend-aarch64/src/selector.rs 97.38% 5 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