From 33640f824a54a5f78010e9219fb645d4e27851fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 16:36:15 +0000 Subject: [PATCH] performance.md: scratchpad for the SHA-256 perf milestone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six wins surfaced by the inner-loop analysis. This file gets deleted when everything's shipped — anchors the work + the after-each-change checklist while it's in flight. https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v --- performance.md | 143 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 performance.md diff --git a/performance.md b/performance.md new file mode 100644 index 0000000..4ba4147 --- /dev/null +++ b/performance.md @@ -0,0 +1,143 @@ +# Performance work + +Six performance wins surfaced by the SHA-256 example's inner-loop +analysis. Working through them on this branch; this file is a +scratchpad for the milestone and gets deleted in the final commit +once everything is shipped. + +The numbers are per 64-byte SHA-256 block on the example, where +the baseline is ~550K cycles ≈ 18 NTSC frames per block. + +## #1 — Skip parameter spill in leaf functions + +**Status**: TODO +**Where**: `src/codegen/ir_codegen.rs` +**Estimate**: ~66K cycles/block, ~2.2 frames + +Every function currently opens with `LDA $04 / STA ` to +spill the param transport slots into a per-function RAM slot, +defending against nested calls that would re-clobber `$04-$07`. +Functions that never `JSR` from inside their body are leaves — +the spill is dead work for them. + +Fix: in `IrCodeGen::new`, scan each function for an `IrOp::Call`. +Functions with none get their parameters mapped directly to the +transport slots `$04-$07`, and `gen_function` skips emitting the +spill prologue. + +## #2 — Direct-branch comparisons (drop bool materialization) + +**Status**: TODO +**Where**: `src/codegen/ir_codegen.rs` (`Lt`/`Gt`/`Eq`/etc. lowering) +**Estimate**: ~9K cycles/block, ~0.3 frames + +`if x < N` currently lowers to "compute the result as 0 or 1 in A, +then `BEQ` on it": + +``` +LDA x ; 3 +CMP #N ; 2 +BCC cmp_t ; 2/3 +LDA #0 ; 2 +JMP cmp_e ; 3 +cmp_t: LDA #1 ; 2 +cmp_e: BEQ end ; 3 +``` + +The canonical 6502 idiom is one `CMP` + one branch (8 cycles vs. +~16). The IR already gives us the false target label — we just +need to teach `gen_op` for the comparison ops to branch directly +when their result feeds straight into a conditional. + +## #3 — Drop dead `LDA #imm` before `INC`/`DEC` + +**Status**: TODO +**Where**: `src/codegen/peephole.rs` +**Estimate**: ~5K cycles/block, ~0.2 frames + +`i += 1` currently emits: + +``` +LDA #1 ; 2 — A is overwritten by the next op +INC ZeroPage(i) ; 5 +``` + +The `LDA #1` is dead. A peephole rule "drop `LDA #imm` if A is +re-written or never read before the next `LDA`/branch/RTS" should +catch this. The same rule fires elsewhere (any `+= const` / +`-= const` that strength-reduced to INC/DEC). + +## #4 — Specialize `rotr_wk` per amount (.ne refactor) + +**Status**: TODO +**Where**: `examples/sha256/sha_core.ne` +**Estimate**: ~45K cycles/block, ~1.5 frames + +`rotr_wk(dst, n)` is a generic loop wrapper. Every SHA-256 +rotation amount is a compile-time constant (2, 6, 7, 11, 13, 17, +18, 19, 22, 25), so the loop body is wasted work — the compiler +can't see through the runtime `n`. + +Fix: declare one `rotr_wk_(dst)` per used amount, each calling +the appropriate sequence of `byte_rotr_wk` and `rotr1_wk`. The +sigma helpers swap `rotr_wk(SIG, 6)` → `rotr_wk_6(SIG)`. The +loop wrapper stays available for any future caller that needs a +runtime amount. + +## #5 — Inline-asm `{param}` substitution after `inline fun` splice + +**Status**: DEFERRED +**Where**: `src/ir/lowering.rs::try_inline_call_stmt` + + `src/codegen/ir_codegen.rs::substitute_asm_vars` +**Estimate**: ~45K cycles/block (potential), ~1.5 frames + +Marking primitives `inline fun` would eliminate JSR + RTS + the +rest of the call apparatus — but the inline-asm `{param}` +substitution today resolves names against the analyzer's per- +function allocation table, which doesn't see the inline frame. +A spliced `cp_wk(32, 28)` ends up emitting `LDX {dst}` against +the *caller's* scope where `dst` doesn't exist. + +Properly fixing this is non-trivial: substitution needs to be +addressing-mode-aware (immediate `#$20` vs. zero-page `$27` vs. +absolute) and depends on whether the inline arg is a constant or +a runtime value. Documenting the design here so a future pass can +take it. + +The simpler half of the win: at inline expansion time, build a +per-frame map from param-name → arg-temp. Pass that map down to +`gen_op`'s asm handler, which substitutes `{name}` with the +arg-temp's allocated slot instead of the param's address. For +constant args, fold further to immediates. This is ~150 lines of +Rust; deferring to a follow-up. + +## #6 — Const-fold `r << 2` style index math + +**Status**: TODO +**Where**: `src/optimizer/mod.rs` +**Estimate**: <1K cycles/block, negligible + +`round_one(r << 2)` recomputes the shift on every iteration of the +phased compression driver. The optimizer already folds shifts when +both operands are constant; extending it to fold "shift a constant +by a constant" inside the IR would catch this case. Trivial in +cycles but worth the cleanliness. + +--- + +## After-each-change checklist + +For every codegen change, verify: + +- [ ] `cargo fmt --check` +- [ ] `cargo clippy --all-targets -- -D warnings` on rustc 1.95.0 +- [ ] `cargo test --all-targets` passes +- [ ] Rebuild every committed `examples/*.nes` +- [ ] Run the emulator harness — if any golden drifts, eyeball the + diff and update with `UPDATE_GOLDENS=1` +- [ ] Regenerate `docs/{platformer,war,pong}.gif` if any of those + examples' captured frames changed + +For each `.ne` change: + +- [ ] Just the example-side checks (rebuild ROM, harness, gif).