1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 00:45: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 ### Register allocator
All IR temps currently spill to a recycled zero-page slot (`$80-$FF`). The IR temps still spill to a recycled zero-page slot (`$80-$FF`). Most of
peephole pass already handles most obvious waste via its copy-propagation, the obvious waste is now handled:
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:
**Any cycle-saving optimization shifts audio timing.** The audio output is - The `remove_dead_loads` peephole pass steps past opcodes that touch
a deterministic function of the byte stream — frame counters advance at neither A nor the flags A cares about — INC/DEC/STX/STY/LDX/LDY in
fixed NMI offsets, and the SFX/music driver reads those counters on every memory mode, INX/INY/DEX/DEY, and the flag ops
tick. An optimization that shortens the main-loop pass between two `wait_frame` (CLC/SEC/CLI/SEI/CLD/SED/CLV). That lets a later `LDA` overwrite
calls changes how many poll iterations land before the next NMI, which kill a redundant earlier `LDA` even when an index-register load or
shifts the exact sample boundary of any one-shot SFX trigger relative to counter bump sits between them. The canonical
the captured audio. The emulator harness (`tests/emulator/run_examples.mjs`) `LDA #imm / LDY oam_cursor / LDA #imm / STA $0200,Y` shape emitted
captures a sample-exact audio hash at frame 180, so **any such change flips by every single-tile `draw` collapses to just
the audio goldens of every audio-emitting example**. The visual PNG goldens `LDY oam_cursor / LDA #imm / STA $0200,Y`, saving 2 bytes per draw.
are stable — PPU writes land in the same vblank — but the audio churn is Across the committed examples this shaved 4-9 % of the LDA count
real. 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 **Remaining wins to chase** if someone wants to push density further:
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.
Exploration during the §H-priorities branch confirmed (1): a single - **Cross-block A-tracking.** `remove_redundant_loads` clears its
`remove_dead_loads` extension that stepped past `LDX`/`LDY` (which don't A-equivalence tracker on LDX/LDY because LDX/LDY clobber the N/Z
clobber A) dropped one LDA per `draw` statement — but flipped audio flags that an immediately-following branch might rely on. Splitting
goldens for audio_demo, friendly_assets, noise_triangle_sfx, platformer, the tracker into "value still valid" vs "flags still valid" lets it
pong, and war. The PNG goldens were unchanged. The work was reverted to keep the value-side equivalence across LDX/LDY for downstream STA
keep the §A + §H commits churn-free; see the commit log on the rewrites.
`claude/prioritize-allocator-signedness` branch. - **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 ### State-local memory overlay follow-ups

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -264,12 +264,39 @@ fn remove_dead_loads(instructions: &mut Vec<Instruction>) {
j += 1; j += 1;
continue; continue;
} }
// Memory INC/DEC/STX/STY don't touch A. // Step past every op that neither reads nor writes A,
if matches!( // so a redundant LDA before the op gets dropped by its
// successor's overwrite. Stepping past LDX/LDY is the
// most impactful win — it kills one LDA per `draw`
// statement, because the IR codegen emits
// `LDA #imm / LDY oam_cursor / LDA #imm / STA $0200,Y`
// and copy propagation leaves the two `LDA #imm`s
// looking identical around the LDY. INX/INY/DEX/DEY
// and the flag ops (CLC/SEC/CLI/SEI/CLD/SED/CLV) are
// cheap additions — they don't touch A either, so the
// same scan picks them up. INC/DEC in memory mode
// (matched explicitly below) likewise leave A alone.
let skippable = matches!(
next.opcode, next.opcode,
Opcode::INC | Opcode::DEC | Opcode::STX | Opcode::STY Opcode::INC
) && !matches!(next.mode, AddressingMode::Accumulator) | Opcode::DEC
{ | Opcode::STX
| Opcode::STY
| Opcode::LDX
| Opcode::LDY
| Opcode::INX
| Opcode::INY
| Opcode::DEX
| Opcode::DEY
| Opcode::CLC
| Opcode::SEC
| Opcode::CLI
| Opcode::SEI
| Opcode::CLD
| Opcode::SED
| Opcode::CLV
) && !matches!(next.mode, AddressingMode::Accumulator);
if skippable {
j += 1; j += 1;
continue; continue;
} }
@ -1074,6 +1101,56 @@ mod tests {
assert_eq!(insts.len(), before); assert_eq!(insts.len(), before);
} }
#[test]
fn dead_load_elim_steps_past_ldx_ldy() {
// `LDA #16 / LDY oam_cursor / LDA #16 / STA $0200,Y` is the
// IR codegen's DrawSprite shape after copy propagation
// rewrites the y-position slot read into a fresh immediate.
// The first `LDA #16` is dead — the second one overwrites
// A and the intervening `LDY zp` doesn't touch A. Before
// the LDX/LDY step-past extension this pattern leaked
// through because `remove_dead_loads` bailed on any
// unexpected opcode; after the fix the first LDA is dropped,
// saving 2 bytes per draw.
let mut insts = vec![
Instruction::new(LDA, AM::Immediate(16)),
Instruction::new(LDY, AM::ZeroPage(0x09)),
Instruction::new(LDA, AM::Immediate(16)),
Instruction::new(STA, AM::AbsoluteY(0x0200)),
Instruction::new(RTS, AM::Implied),
];
optimize(&mut insts);
let lda_count = insts.iter().filter(|i| i.opcode == LDA).count();
assert_eq!(
lda_count, 1,
"expected one LDA after peephole; the `LDA #16` before \
`LDY $09` should be dropped as dead (next LDA \
overwrites): {insts:?}"
);
}
#[test]
fn dead_load_elim_preserves_lda_when_used_by_shift() {
// Counter case: an LDA whose value IS used (by ASL/LSR in
// accumulator mode) must survive the step-past extension.
// The ASL reads A, so the LDA isn't dead even though LDY
// sits between them.
let mut insts = vec![
Instruction::new(LDA, AM::ZeroPage(0x10)),
Instruction::new(LDY, AM::ZeroPage(0x09)),
Instruction::new(ASL, AM::Accumulator),
Instruction::new(STA, AM::ZeroPage(0x11)),
Instruction::new(RTS, AM::Implied),
];
let before_lda = insts.iter().filter(|i| i.opcode == LDA).count();
optimize(&mut insts);
let after_lda = insts.iter().filter(|i| i.opcode == LDA).count();
assert_eq!(
before_lda, after_lda,
"LDA feeding an ASL must survive even with LDY in between: {insts:?}"
);
}
#[test] #[test]
fn indexed_load_invalidates_redundant_load_tracker() { fn indexed_load_invalidates_redundant_load_tracker() {
// Regression test for a miscompile that affected every // Regression test for a miscompile that affected every

View file

@ -1 +1 @@
23afaaa6 132084 3bde6e46 132084

View file

@ -1 +1 @@
bc94ed30 132084 fe6514c1 132084

View file

@ -1 +1 @@
52d1836f 132084 14b1ea80 132084

View file

@ -1 +1 @@
2b03b3ec 132084 443d66c5 132084

View file

@ -1 +1 @@
6afbe82b 132084 196d7bf3 132084

View file

@ -1 +1 @@
b054facb 132084 67e9a4c0 132084