Skip to content

perf: optimize CPU, sprite, and color-math hot paths#66

Open
eduardovra wants to merge 4 commits into
mainfrom
perf/emulation-hotpath-optimizations
Open

perf: optimize CPU, sprite, and color-math hot paths#66
eduardovra wants to merge 4 commits into
mainfrom
perf/emulation-hotpath-optimizations

Conversation

@eduardovra

@eduardovra eduardovra commented Jun 8, 2026

Copy link
Copy Markdown
Owner

Summary

Performance pass on the emulation hot paths, motivated by FPS dips in Mega Man X stage 1 (slowdowns when many sprites are on screen — e.g. trucks — and during the lighting/brightness transitions). Every change is behaviour-preserving and verified, and none touch save-state-hashed modules, so existing .state files keep loading.

Changes

CPU (cpu/wdc65816/addressing_modes) — removed per-instruction getattr(cpu, name) register lookups and throwaway Reg(16, 0) allocations (the getattr default was evaluated on every call) from the read/write/modify addressing modes; dropped unused **kwargs from the M/X dispatch wrappers.

  • Verified against the Tom Harte 65816 SingleStepTests: 6144 cases pass (all opcodes × 12, both M/X modes).

Sprite renderer (ppu/obj_renderer.py)draw_objects runs 4× per scanline (once per priority) and re-scanned all 128 OAM entries each time; now builds the intersecting-sprite list once per scanline and reuses it, keyed on (frame, scanline). In draw_tiles, hoisted the per-tile layer tag out of the pixel loop and skip transparent pixels earlier.

  • ~22% faster on a heavy scene (128 sprites × 32×32: 2.34 → 1.82 ms/frame). Rendered output byte-for-byte identical.

Color math (ppu/color_math.py)apply_brightness_scanline now uses a cached 256-entry lookup per brightness level instead of multiply/divide per channel per pixel; composite_scanline replaces its per-pixel if/elif layer chain with a participation-table lookup.

  • composite ~36% faster (0.599 → 0.385 ms/frame), brightness ~44% faster (0.220 → 0.124 ms/frame). Output pixel-identical across all add/subtract/half/enable combos and every brightness level.

Benchmarks (scripts/) — the harnesses used to measure and verify the above.

Verification

  • 65816 accuracy: 6144 Tom Harte cases pass.
  • PPU unit tests pass (the flaky test_ppu_screenshot cases fail identically on main — pre-existing, unrelated).
  • Rendered-output hashes (sprites and color-math/brightness) are byte-for-byte identical to main.

Notes / out of scope

Two slowdown sources were investigated and found not code-fixable, documented here so they aren't re-chased:

  • JIT first-encounter spikes — when new content (a truck, a lighting transition) first becomes hot, PyPy compiles that trace mid-frame. Proven via frame replay (identical frames are 5–19 ms faster on the second pass). Inherent to the JIT; param tuning (trace_limit, trace_eagerness, threshold, loop_longevity) was same-or-worse than defaults.
  • Random 25–86 ms spikes (~1–2% of frames) are non-deterministic OS scheduler jitter (not GC, not background containers). Lever is process priority (chrt/nice), not code.

The read/write/modify addressing modes resolved their index/target register
via getattr(cpu, name) on every executed instruction, and LongRead/LongWrite/
IndirectLong* passed Reg(16, 0) as the getattr default — allocating a throwaway
Reg object on every call (the default is evaluated unconditionally). The index
register is only ever "" / "X" / "Y" and the modify target only "A" / "X" / "Y",
so resolve them with a direct branch instead.

Also drop the unused **kwargs from the M/X dispatch wrappers (the instruction
table never passes keywords), avoiding a per-call empty-dict.

Behaviour-preserving: verified against the Tom Harte 65816 SingleStepTests
(6144 cases, all opcodes x 12, both M/X modes).
draw_objects runs once per priority level (0-3) for every scanline, so it
re-scanned all 128 OAM entries up to 4x per line. Build the list of sprites
intersecting the scanline once and reuse it across the priority passes, keyed
on (frame, scanline) so it never goes stale. The cache attributes are transient
render state, created lazily so Ppu.__init__ (a save-state-hashed module) is
untouched.

In draw_tiles, hoist the per-tile layer tag out of the per-pixel loop (it is
constant for a tile) and skip transparent pixels before computing position and
bounds.

~22% faster on a heavy sprite scene (128 sprites x 32x32: 2.34 -> 1.82 ms/frame).
Rendered output (main_bgs + main_layer) is byte-for-byte identical to before.
apply_brightness_scanline did `v * brightness // 15` per channel per pixel;
replace with a 256-entry lookup built once per brightness level and cached
(a fade holds each level across many scanlines/frames). composite_scanline's
per-pixel layer if/elif chain becomes a small participation-table lookup.

Both run over the whole screen when active (color math nearly every frame;
brightness during fades / light transitions), so this directly helps the
"lighting changing" slowdown.

composite ~36% faster (0.599 -> 0.385 ms/frame), brightness ~44% faster
(0.220 -> 0.124 ms/frame). Output verified pixel-identical across all
add/subtract/half/enable combinations and every brightness level.
Harnesses used to measure and verify the hot-path optimizations from the MMX
save state (hold RIGHT):
- profile_mmx.py     compute-only per-frame timing / cProfile
- bench_windowed.py  end-to-end achieved FPS with a real SDL window
- bench_sprites.py   deterministic sprite-render micro-benchmark
- diag_mmx.py        correlate frame time with sprites / brightness / composite
- jit_replay.py      isolate JIT-warmup cost by replaying identical frames
- jit_test.py        A/B PyPy JIT params (pypyjit.set_param)

They monkeypatch savestate._compat_hash so the MMX state loads regardless of
local source edits; benchmark-only, no effect on the emulator.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant