1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +00:00

peephole: step past non-A ops in remove_dead_loads

`remove_dead_loads` now scans past opcodes that touch neither A nor
the flags an LDA sets, so a redundant LDA gets caught by its
successor's overwrite even when an index load or counter bump sits
between them. The extension covers LDX/LDY/INX/INY/DEX/DEY and the
flag ops (CLC/SEC/CLI/SEI/CLD/SED/CLV) alongside the INC/DEC/STX/STY
opcodes the pass already stepped past.

The highest-leverage case is the shape every single-tile `draw`
emits. After copy propagation and dead-store elimination do their
work, the stream reads:

    LDA #<y>      ; stray producer, value never consumed
    LDY oam_cursor
    LDA #<y>      ; real load before STA
    STA $0200,Y

The first LDA was surviving because the pass bailed on the LDY.
With the step-past, it drops. One LDA gone per draw, 2 bytes each.

Measured LDA-count reduction on committed examples:

  platformer  242 → 221   (-21, -8.7 %)
  war         785 → 754   (-31, -4.0 %)
  pong        843 → 827   (-16, -1.9 %)

**Audio goldens.** The cycle savings shift the main-loop/NMI boundary
in audio-emitting programs, which re-times which frame each SFX
trigger lands in. Six audio hashes re-baseline as a result:
audio_demo, friendly_assets, noise_triangle_sfx, platformer, pong,
war. All 50 PNG goldens, the platformer/war/pong demo gifs, and
every non-audio program stay byte-identical. The re-baselined
output is still sample-accurate; what changed is the first-SFX
offset within the captured 132 084-sample window. This is the
audio-shift tradeoff documented in future-work.

Two new peephole unit tests lock in the behaviour:
- `dead_load_elim_steps_past_ldx_ldy` — the DrawSprite shape folds.
- `dead_load_elim_preserves_lda_when_used_by_shift` — a subsequent
  ASL on A keeps the LDA alive across an intervening LDY.

Also updates future-work.md to reflect the shipped change and the
remaining register-allocator wins worth chasing next.
This commit is contained in:
Claude 2026-04-19 01:43:58 +00:00
parent 82b3d0d20a
commit b6e4c368e1
No known key found for this signature in database
53 changed files with 135 additions and 41 deletions

View file

@ -181,40 +181,57 @@ on the next `wait_frame`.
### Register allocator
All IR temps currently spill to a recycled zero-page slot (`$80-$FF`). The
peephole pass already handles most obvious waste via its copy-propagation,
dead-store, and dead-load passes; a real CFG-aware allocator that holds
short-lived temps in `A`/`X`/`Y` would cut more LDA/STA pairs, but there's
a subtle constraint worth recording before anyone tackles it:
IR temps still spill to a recycled zero-page slot (`$80-$FF`). Most of
the obvious waste is now handled:
**Any cycle-saving optimization shifts audio timing.** The audio output is
a deterministic function of the byte stream — frame counters advance at
fixed NMI offsets, and the SFX/music driver reads those counters on every
tick. An optimization that shortens the main-loop pass between two `wait_frame`
calls changes how many poll iterations land before the next NMI, which
shifts the exact sample boundary of any one-shot SFX trigger relative to
the captured audio. The emulator harness (`tests/emulator/run_examples.mjs`)
captures a sample-exact audio hash at frame 180, so **any such change flips
the audio goldens of every audio-emitting example**. The visual PNG goldens
are stable — PPU writes land in the same vblank — but the audio churn is
real.
- The `remove_dead_loads` peephole pass steps past opcodes that touch
neither A nor the flags A cares about — INC/DEC/STX/STY/LDX/LDY in
memory mode, INX/INY/DEX/DEY, and the flag ops
(CLC/SEC/CLI/SEI/CLD/SED/CLV). That lets a later `LDA` overwrite
kill a redundant earlier `LDA` even when an index-register load or
counter bump sits between them. The canonical
`LDA #imm / LDY oam_cursor / LDA #imm / STA $0200,Y` shape emitted
by every single-tile `draw` collapses to just
`LDY oam_cursor / LDA #imm / STA $0200,Y`, saving 2 bytes per draw.
Across the committed examples this shaved 4-9 % of the LDA count
in the larger programs (platformer, war, pong).
- Copy propagation + dead-store elimination in the same pass handle
the `STA $80 / … / LDA $80` spill/reload pattern whenever the
source and destination aren't separated by a JSR or a label.
A workable register allocator therefore needs one of:
**Constraint worth recording.** Any cycle-saving optimization shifts
audio timing: the SFX/music driver reads a frame counter on every NMI,
and shortening the main-loop pass between two `wait_frame` calls
changes how many poll iterations complete before the next NMI fires.
The emulator harness captures a sample-exact audio hash at frame 180,
so any such change flips the audio goldens for every audio-emitting
example (audio_demo, friendly_assets, noise_triangle_sfx, platformer,
pong, war). The visual PNG goldens stay stable — PPU writes still
land in the same vblank — but the audio churn is real and has to be
accepted on every pass that touches the codegen. The initial LDX/LDY
extension on this branch went through that re-baseline once; future
passes will too.
1. An acceptance that every code-density improvement comes with a
re-baselined audio golden set, explained in the commit message.
2. A pass that saves ROM bytes but preserves cycle counts (hard on 6502 —
most short-form ops cost the same cycles as their long forms).
3. An opt-in flag (`--O2`) so default builds stay cycle-identical and only
size-hungry users pay the churn.
**Remaining wins to chase** if someone wants to push density further:
Exploration during the §H-priorities branch confirmed (1): a single
`remove_dead_loads` extension that stepped past `LDX`/`LDY` (which don't
clobber A) dropped one LDA per `draw` statement — but flipped audio
goldens for audio_demo, friendly_assets, noise_triangle_sfx, platformer,
pong, and war. The PNG goldens were unchanged. The work was reverted to
keep the §A + §H commits churn-free; see the commit log on the
`claude/prioritize-allocator-signedness` branch.
- **Cross-block A-tracking.** `remove_redundant_loads` clears its
A-equivalence tracker on LDX/LDY because LDX/LDY clobber the N/Z
flags that an immediately-following branch might rely on. Splitting
the tracker into "value still valid" vs "flags still valid" lets it
keep the value-side equivalence across LDX/LDY for downstream STA
rewrites.
- **CFG-aware allocation into X / Y.** The codegen never picks X or Y
as a temp slot today; they're used only in the specific shapes the
existing IR ops emit. A pass that takes a temp with ≤2 uses, lives
in a straight-line block, and picks X or Y would remove both the
STA and the matching LDA.
- **Skipping the spill for temps consumed by the very next op.** The
IR codegen always emits `STA slot` after producing a temp; when the
next op's first source is that temp, the value could stay in A and
the consumer skips its `LDA`. The peephole's `remove_sta_then_lda`
+ `remove_dead_temp_stores` already pick up most cases post-hoc,
but producing tighter code at codegen avoids the overhead and
handles cases where an intervening label blocks the peephole.
### State-local memory overlay follow-ups