1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 01:16:12 +00:00

peephole: drop dead LDA #imm before mem-INC/DEC + JMP

The IR codegen lowers `i -= 1` (and friends) into a `LoadImm temp,
1; Sub d, i, temp; StoreVar i, d` triple, and the optimizer
strength-reduces the Sub+StoreVar pair into `DEC i`. The
constant-load-into-A that used to feed the Sub stays around as a
dead `LDA #1`:

    LDA #1
    DEC ZeroPage(rem)
    JMP Label("__ir_blk_while_cond_…")

`remove_dead_loads` was set up to drop exactly this pattern but
gave up at the trailing `JMP` because it couldn't reason about
flow. Extend it to follow one unconditional `JMP <label>` to its
target and resume the dead-store scan from the next instruction.
The first instruction past the loop-condition label is reliably an
`LDA loop_var`, which overwrites A without reading it — so the
`LDA #1` is correctly identified as dead.

Conditional branches still end the scan (their not-taken path is
unconstrained) and only one JMP is followed (to keep the analysis
local). For SHA-256 specifically this drops two `LDA #1`s per
iteration of the rotate/shift bit-loops — about 1K cycles per
block. The same pattern fires across most examples' loop tails.

Verified: cargo test/clippy/fmt clean on rustc 1.95.0; emulator
harness 34/34; reproducibility diff clean; SHA-256 of "NES" still
computes to AE9145DB…4E0D. The cycle drift refreshes the four
audio hashes / golden frames timing-sensitive examples already
tracked.

https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
This commit is contained in:
Claude 2026-04-16 17:14:34 +00:00
parent 0600f5b872
commit df71c2bf50
No known key found for this signature in database
12 changed files with 40 additions and 22 deletions

View file

@ -231,11 +231,22 @@ fn invert_branch(op: Opcode) -> Option<Opcode> {
/// Remove `LDA …` instructions whose value is never read — the next
/// instruction overwrites A without using the current value.
///
/// The heuristic looks one instruction ahead. If the next instruction
/// is an A-clobbering load (`LDA`, `LDX`, `LDY`, `PLA`, `TXA`, `TYA`),
/// the preceding `LDA` is dead. Shifts and arithmetic ops read A, so
/// they don't qualify. A label or branch in between stops the scan
/// (we can't prove A's dead across control flow).
/// The heuristic looks forward, stepping over instructions that
/// don't touch A (memory `INC`/`DEC`/`STX`/`STY`, labels). If the
/// first instruction that *does* touch A overwrites it without
/// reading it (`LDA`, `PLA`, `TXA`, `TYA`), the preceding `LDA` is
/// dead. Shifts and arithmetic ops read A, so they end the scan
/// without marking dead.
///
/// One unconditional `JMP` is followed: we look up its target label
/// and resume scanning from the first instruction after it. This
/// catches `LDA #imm; DEC zp; JMP loop_cond; loop_cond: LDA loop_var`
/// patterns that the IR codegen leaves behind for `i -= 1`-style
/// loops, where the `LDA #1` was the constant operand of a `Sub`
/// the optimizer already strength-reduced into the `DEC`. Conditional
/// branches and `JSR` still end the scan — JSR could land on a
/// runtime helper that reads A, and a branch's not-taken path is
/// unconstrained.
fn remove_dead_loads(instructions: &mut Vec<Instruction>) {
let mut keep = vec![true; instructions.len()];
for i in 0..instructions.len() {
@ -243,19 +254,9 @@ fn remove_dead_loads(instructions: &mut Vec<Instruction>) {
if inst.opcode != Opcode::LDA {
continue;
}
// Walk forward looking for the next instruction that either
// reads A or overwrites it. If the first such instruction
// overwrites A without reading it, our LDA is dead.
//
// We can safely step across instructions that neither read
// nor write A — INC, DEC, STX, STY on memory, and label
// definitions — because they don't observe A and don't
// clobber it either. We must NOT step over any branch or
// jump: control flow at that point can reach code that
// reads A via a different path, and our local analysis
// can't see it.
let mut j = i + 1;
let mut dead = false;
let mut followed_jmp = false;
while j < instructions.len() {
let next = &instructions[j];
// Labels are passive markers.
@ -263,8 +264,7 @@ fn remove_dead_loads(instructions: &mut Vec<Instruction>) {
j += 1;
continue;
}
// Instructions that don't touch A — skip over them.
// INC/DEC on memory, STX/STY — all leave A alone.
// Memory INC/DEC/STX/STY don't touch A.
if matches!(
next.opcode,
Opcode::INC | Opcode::DEC | Opcode::STX | Opcode::STY
@ -273,6 +273,24 @@ fn remove_dead_loads(instructions: &mut Vec<Instruction>) {
j += 1;
continue;
}
// Cross one unconditional `JMP <label>` by looking the
// target up and resuming scan there. Refuse a second
// JMP — the analysis is meant to catch a single
// straight-line `loop_body → loop_cond` jump, not an
// arbitrary chain.
if !followed_jmp && next.opcode == Opcode::JMP {
if let AddressingMode::Label(target) = &next.mode {
if let Some(target_idx) = instructions.iter().position(|ins| {
ins.opcode == Opcode::NOP
&& matches!(&ins.mode, AddressingMode::Label(name) if name == target)
}) {
followed_jmp = true;
j = target_idx + 1;
continue;
}
}
break;
}
// Instructions that overwrite A without reading it.
if matches!(
next.opcode,
@ -281,8 +299,8 @@ fn remove_dead_loads(instructions: &mut Vec<Instruction>) {
dead = true;
}
// Any other instruction — might read A (STA, ADC,
// SBC, AND, …) or transfer control (JMP, Bxx, JSR,
// RTS) — stop scanning and leave the LDA alone.
// SBC, AND, JSR …) or transfer control (Bxx, RTS) —
// stop scanning.
break;
}
if dead {