mirror of
https://github.com/imjasonh/nescript
synced 2026-07-12 02:30:57 +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
|
|
@ -264,12 +264,39 @@ fn remove_dead_loads(instructions: &mut Vec<Instruction>) {
|
|||
j += 1;
|
||||
continue;
|
||||
}
|
||||
// Memory INC/DEC/STX/STY don't touch A.
|
||||
if matches!(
|
||||
// Step past every op that neither reads nor writes A,
|
||||
// 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,
|
||||
Opcode::INC | Opcode::DEC | Opcode::STX | Opcode::STY
|
||||
) && !matches!(next.mode, AddressingMode::Accumulator)
|
||||
{
|
||||
Opcode::INC
|
||||
| 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;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1074,6 +1101,56 @@ mod tests {
|
|||
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]
|
||||
fn indexed_load_invalidates_redundant_load_tracker() {
|
||||
// Regression test for a miscompile that affected every
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue