1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +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

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

@ -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 {

View file

@ -1 +1 @@
a7ce9d12 132084
f2dbfbf3 132084

View file

@ -1 +1 @@
d6cf6f66 132084
4e8bdb33 132084