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:
parent
82b3d0d20a
commit
b6e4c368e1
53 changed files with 135 additions and 41 deletions
|
|
@ -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.
BIN
examples/war.nes
BIN
examples/war.nes
Binary file not shown.
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
23afaaa6 132084
|
3bde6e46 132084
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
bc94ed30 132084
|
fe6514c1 132084
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
52d1836f 132084
|
14b1ea80 132084
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
2b03b3ec 132084
|
443d66c5 132084
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
6afbe82b 132084
|
196d7bf3 132084
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
b054facb 132084
|
67e9a4c0 132084
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue