Performance¶
References: ref-docs/research-report.md §Architecture options; docs/scheduler.md §Performance targets.
Purpose¶
Set quantitative performance targets, identify expected hot paths, and lay out the profiling and optimization plan.
Targets¶
| Metric | Target | Stretch |
|---|---|---|
| Frame cost (NTSC, headless core) | ≤ 2 ms on 2018-era x86_64 (Skylake) | ≤ 1 ms |
| Frame cost (full frontend) | ≤ 5 ms | ≤ 3 ms |
| Cold-start to first frame | ≤ 100 ms | ≤ 50 ms |
| Save state size (uncompressed) | ≤ 64 KB typical | — |
| Save state save/load latency | ≤ 1 ms | ≤ 0.2 ms |
| Rewind buffer (60s @ 60 fps) | ≤ 32 MB | ≤ 16 MB |
| Audio underrun rate | 0 under normal operation | — |
NTSC frame is 16.639 ms (60.0988 Hz); PAL is 19.997 ms (50.007 Hz). Even the conservative target leaves >65% budget for the OS + browser tab + other apps the user is running.
Measured baselines (engine v2.0 line — R1 master clock vs. legacy)¶
Engine-lineage note. The benchmark version markers in this file (
v2.0.x,v2.8.0) are anchors for the internal engine development line that produced RustyNES v1.0.0, not RustyNES releases of their own; RustyNES ships at v1.0.0 and these are the numbers for the technology it ships.Engine v2.0.1: the legacy integer-lockstep scheduler was removed; R1 is the only path. The A/B below was measured during the engine's v2.0 line and is kept as the historical rationale for that removal — the R1 numbers remain current; the legacy column is no longer reproducible. See
docs/benchmarks.md.
The headline measurement is the A/B between the two v2.0.0 scheduler
configurations. The full, reproducible record — methodology, host, derived
real-time factors, all benches — lives in
docs/benchmarks.md; this section is the summary.
Numbers from cargo bench on the development host (Intel Core i9-10850K @
3.6 GHz, CachyOS Linux, powersave governor, Rust 1.86, release profile
opt-level = 3 lto = "fat" codegen-units = 1 panic = "abort"), captured
2026-06-10. (This repo's [profile.release] has always been lto = "fat" —
see "fat-LTO vs thin-LTO release-profile A/B" below for the measured rationale.) They are hardware-specific; replicate on your machine before
treating any delta as a regression, and trust the deltas (same host,
back-to-back Criterion baselines) over the absolute ms figures (~±3% host noise
on a shared desktop). The benches live under crates/*/benches/ and are wired
via [[bench]] harness = false.
Headline — full_frame (end-to-end Nes::run_frame, the whole scheduler)¶
| Workload | Legacy (integer-lockstep) | R1 (default, master clock) | Δ |
|---|---|---|---|
nes_run_frame_nestest |
3.62 ms (4.59× realtime, 276 fps) | 3.92 ms (4.25× realtime, 255 fps) | +8.14% |
nes_run_frame_flowing_palette |
2.34 ms (7.12× realtime, 428 fps) | 2.49 ms (6.69× realtime, 402 fps) | +6.32% |
R1 trades ~6–8% headless frame time for the move from 94.24% → 100.00%
AccuracyCoin (the +5.76-point accuracy gain). Both configs clear the 16.639 ms
NTSC wall by 4.25–7.1× even on this 2020 desktop. The realtime/fps figures are
against the NTSC deadline; legacy is reachable via --no-default-features.
v2.0.3 — 2-cycle-ALE PPU-fetch model promoted to default (ADR 0030). Making each background VRAM access a genuine two-dot transaction (an ALE-drive dot + a multiplexed-bus splice on the read dot, replacing the whole-dot fetch) is now the only PPU fetch path. It costs ~10% over the R1 baseline:
nes_run_frame_nestestis now ~4.15 ms/frame (~4× realtime) vs the ~3.77 ms R1 figure above. Accepted as the cost of AccuracyCoin 141/141 (both the "ALE + Read"$0491and "Hybrid Addresses"$0492PPU tests now pass on the shipped default). Still ~4× under the NTSC wall. This is the current headless-frame baseline for the default build.
The +6–8% is bus-side, not the CPU core — in isolation the R1 CPU cycle loop is faster:
| Bench | Legacy | R1 substrate (mc-r1-substrate) |
Δ |
|---|---|---|---|
cpu_throughput::cpu_nop_step_x1000 |
1.08 µs | 0.70 µs | −34.6% |
R1's run-to-timestamp model batches master_clock advancement instead of a
per-cycle on_cpu_cycle callback. Since the core gets cheaper yet the frame
gets dearer, the full +6–8% comes from LockstepBus::run_ppu_to (master-clock
PPU catch-up) + unified_dma_cycle (per-cycle unified-DMA dispatch) — the
accuracy machinery, doing its job.
Scheduler-invariant controls (identical across both configs)¶
| Bench | Crate | Measured | Notes |
|---|---|---|---|
ppu_throughput::ppu_tick_one_frame |
rustynes-ppu |
~497 µs per NTSC frame (89,342 dots) | Synthetic PpuBus returns 0xA5 for every read. PPU dot loop alone ≈ 33× realtime. |
mapper_dispatch::cpu_read (NROM) |
rustynes-mappers |
~1.67 µs per 1024 reads | Real ROMs through parse(). |
mapper_dispatch::cpu_read (MMC1) |
rustynes-mappers |
~1.51 µs per 1024 reads | Serial 5-write protocol overhead. |
mapper_dispatch::cpu_read (MMC3) |
rustynes-mappers |
~1.51 µs per 1024 reads | A12 filter is on the write path; reads are cheap. |
mapper_dispatch::cpu_read (MMC5) |
rustynes-mappers |
~2.28 µs per 1024 reads | Most expensive: ExRAM mode + multiple bank slots dispatched in the read path. |
mapper_dispatch::cpu_read (M34) |
rustynes-mappers |
~1.57 µs per 1024 reads | BNROM/NINA-001 variant detection. |
mapper_dispatch::cpu_read (FME-7) |
rustynes-mappers |
~2.78 µs per 1024 reads | Per-CPU-cycle IRQ counter tick. |
spectral::blip_square_wave_0_1s_ntsc |
rustynes-apu |
~611 µs per 0.1 s NTSC audio (~179k samples) | BLEP synthesis ≈ 164× realtime; ~3.4 ns/sample. |
spectral::blip_silence_0_1s_ntsc |
rustynes-apu |
~593 µs | Drain-path baseline. |
The mapper-dispatch spread (~1.5 µs NROM → ~2.78 µs FME-7) is the evidence for
the D1 ADR on Box<dyn Mapper> vs. monomorphized MapperEnum: even the dearest
mapper is well under 1% of frame cost, so dynamic dispatch is not the
bottleneck (ADR-0001). Real frontend cost is additionally gated by wgpu
submission and cpal callback scheduling, both within the 5 ms full-frontend
target.
Historical note: the prior v0.9.0/v1.6.0 baselines (≈2.06 ms nestest, ≈0.86 µs NROM) were captured on a different host (Ryzen 9) and predate the R1 scheduler. The absolute drift vs. the numbers above is host/toolchain, not a branch regression; see
docs/benchmarks.md§6.
Hot paths (expected)¶
Based on architectural reasoning + cross-validation with TetaNES profiling notes (ref-docs/research-report.md §State of the art):
- PPU
tick()— called 89,342 times per NTSC frame (262 × 341). Each call updates loopy registers, performs at most one memory fetch, and conditionally emits one pixel. - CPU
tick()— called ~29,780 times per NTSC frame. Most cycles are simple state updates; instruction-boundary cycles do interrupt polling. - APU sample emission — called per sample (44,100/s or 48,000/s). The blip_buf-style step convolution is O(kernel width).
- Mapper
cpu_read/writeandppu_read/write— called once per CPU/PPU memory access. Trait dispatch overhead is the concern.
Optimization plan¶
Always-on¶
- Inlining: mark hot functions
#[inline]; let the compiler decide on#[inline(always)]only after profiling shows benefit. - No unnecessary allocation in the hot loop: framebuffer is a fixed
[u8; 256*240*4]; OAM is fixed-size; nothing intick()paths callsVec::pushorBox::new. - Branch-free pixel composition where it pays: BG vs sprite priority can be computed with masks rather than branches.
- Cargo profile:
[profile.release] opt-level = 3, lto = "fat", codegen-units = 1, panic = "abort"for the frontend binary; library crates honor the workspace profile. Thelto = "fat"+codegen-units = 1choice is measured, not assumed — see "fat-LTO vs thin-LTO release-profile A/B" below for the byte-identical +8–21% win that justifies it.
Profile-guided¶
After Phase 2 (CPU + basic PPU working), run:
cargo build --release --profile bench
perf record --call-graph dwarf -- ./target/release/rustynes [headless 600 frames]
perf report --stdio | head -50
Top 5 hot functions get a focused optimization pass. Specifically watch:
- Mapper trait dispatch — if it appears in top 3, switch from
Box<dyn Mapper>to aMapperEnumwith all implemented mappers as variants. - Cycle-counting overflow checks —
opt-level = 2in dev keeps them; release strips them. Verify no UB introduced.
Benchmarks (criterion) — landed in v0.9.0¶
crates/rustynes-cpu/benches/cpu_throughput.rs— NOPCpu::stepx1000 latency.crates/rustynes-ppu/benches/ppu_throughput.rs—Ppu::tickx 89,342 dots (one NTSC frame).crates/rustynes-mappers/benches/mapper_dispatch.rs—Box<dyn Mapper>::cpu_readx1024 across NROM / MMC1 / MMC3 / MMC5 / M34 / FME-7.crates/rustynes-core/benches/full_frame.rs— end-to-endNes::run_frameonnestest.nes(nes_run_frame_nestest) and the rendering-heavy CC0flowing_palette.nes(nes_run_frame_flowing_palette, added v1.6.0).
See "Measured baselines (v2.0.0)" above for the values, or
docs/benchmarks.md for the full reproducible record.
CI regression gate (landed v1.6.0). scripts/bench_regression_check.sh
runs the full_frame benches and fails if headless frame production exceeds an
absolute ceiling (default 10 ms — 60% of the 16.67 ms NTSC deadline), wired as
the bench job in .github/workflows/ci.yml. The ceiling is deliberately
generous: shared CI runners vary run-to-run by tens of percent, so a tight
percentage-regression gate would flake; the absolute ceiling instead protects
the property that matters — headless production stays comfortably real-time —
and trips only on a gross (~3x) regression. For the tighter ~5% comparison, use
criterion baselines locally:
cargo bench -p rustynes-core --bench full_frame -- --save-baseline main
# ... make changes ...
cargo bench -p rustynes-core --bench full_frame -- --baseline main
Per the v1.6.0 gap-analysis plan §5, do not monomorphize Box<dyn Mapper>
to chase dispatch cost — the mapper_dispatch benches above measure it at <1%
of frame cost; a profile must contradict that first (ADR 0001).
Performance-pass optimization landings (core micro-opts)¶
- Mapper capability flags (
Mapper::caps() -> MapperCaps, cached on the bus): the per-CPU-cycle fan-out (notify_cpu_cycle/mix_audio/notify_frame_event/irq_pending— up to 4 virtual calls × ~30 k cycles/frame) is skipped on boards whose hooks are the default no-ops. Contract is mechanical (a flag isfalseonly when the method is not overridden — skipping a no-op is provably byte-identical), pinned bymapper::caps_tests+ the full oracle gauntlet. Measured: −4.0% (nestest) / −10.1% (flowing_palette) full-frame time. This addresses the per-cycle dispatch population ADR-0001'scpu_readbenches never measured, without monomorphizing anything.
v2.0.1 legacy-flag-cleanup PR — measure-first full_frame re-check: no change adopted¶
Re-measured cargo bench -p rustynes-core (full_frame, the end-to-end
Nes::run_frame scheduler bench) after the mc-r1-dmc-abort-probe diagnostic
removal, to confirm the removal is neutral and to satisfy the standing
measure-first gate before any micro-opt. Development host (Intel Core i9-10850K),
release profile, criterion medians:
| Workload | v2.0.1 full_frame median |
vs. 16.639 ms NTSC budget | vs. ≤ 2 ms core target |
|---|---|---|---|
nes_run_frame_nestest |
3.77 ms | 4.42× realtime (~23% of budget) | above (as documented for the R1 master clock) |
nes_run_frame_flowing_palette |
2.26 ms | 7.37× realtime (~14% of budget) | ~13% over |
Both clear the hard 16.639 ms NTSC real-time deadline by 4.4–7.4× and are within
noise of the documented R1 baseline (3.92 / 2.49 ms) — the probe removal changed
nothing measurable (as expected: the flag was default-off, so the shipped build
never compiled it in). The nestest figure sits above the aspirational ≤ 2 ms
core stretch target, unchanged from and consistent with the R1 master-clock trade
already recorded in "Measured baselines" above (R1 buys +5.76 AccuracyCoin points
for ~6–8% frame time; ADR 0001 / ADR 0029).
No optimization was adopted. Per the standing contract (v1.7.0 H7, above): adopt a micro-opt only on a > 3% Criterion-stable + byte-identical bar, and the core has already had multiple measure-first passes (v1.4.0 F, v1.5.0 H, v1.7.0 H7) that exhausted the neutral-win candidates. This PR is a flag-cleanup, not a perf pass; the number is recorded as the honest current baseline and to prove the removal is neutral, not to justify a speculative change that would risk byte-identity for a marginal gain.
v2.1.8 "Performance" (A2) — software palette-index -> RGBA blitter (decision: keep scalar-u32 default)¶
A2 adds a frontend-only, reusable software blitter
(crates/rustynes-frontend/src/gfx_blit.rs) that reconstructs the RGBA frame
from the PPU's palette-index framebuffer (&[u16], (emphasis << 6) | colour)
through the same 512-entry LUT the core emits with — so its output is
byte-identical to Ppu::framebuffer by construction (asserted by
scalar_matches_core_lut_contract against build_rgba_lut). The A2 brief called
for vectorizing this conversion with portable SIMD; the honest, measure-first
result is that it is memory-bandwidth bound and does not vectorize
profitably.
Method: the Criterion bench benches/gfx_blit.rs converts a full 256x240 frame
whose indices sweep the entire 0..512 LUT domain, comparing three
byte-identical paths — the naive per-pixel [u8; 4] copy_from_slice
(copy4, the shape emit_pixel uses), a tight scalar-u32 gather+store, and
the wide::u32x8 portable-SIMD path (scalar 8-wide gather + one 256-bit store).
Run on the same host as the fat-LTO A/B below (Intel Core i9-10850K, CachyOS,
Rust 1.96, release + fat-LTO bench profile, --warm-up-time 1 --measurement-time
3). Criterion medians:
| Path | median | throughput | vs copy4 (Δ time, + = slower) |
|---|---|---|---|
copy4 (scalar reference) |
12.003 µs | 19.07 GiB/s | — |
u32 (scalar gather+store) |
12.034 µs | 19.02 GiB/s | +0.3% (within noise) |
simd (wide::u32x8) |
12.225 µs | 18.72 GiB/s | +1.8% (measurably slower) |
All three land at ~12 µs / ~19 GiB/s — which is the single-thread DRAM
bandwidth ceiling of this host, the tell-tale signature of a memory-bound
kernel. The conversion is a table gather (out[i] = lut[idx[i]]), and no
stable-target portable SIMD has a hardware gather, so the load side stays scalar
and SIMD only widens the store; the store was never the bottleneck, so the
wide path is not just within noise but marginally slower (non-overlapping CIs,
~1.8% over the scalar reference — the extra shuffle/pack around a store that
copy4/u32 already lower to a single move).
Decision: the blit dispatcher stays scalar-u32 on every target. No path
clears the project's > 3% Criterion-stable + byte-identical adoption bar, so
the memory-bound hot loop keeps the simplest reference-equivalent form. The SIMD
variants (blit_simd via wide on desktop, blit_simd_wasm via
core::arch::wasm32 v128 under +simd128 on wasm, both with the scalar
fallback) are implemented, byte-identical (guarded by
simd_equals_scalar_byte_identical, which asserts each target's SIMD path
byte-for-byte against the scalar reference over the full corpus for both the
composite and an RGB LUT), and remain directly callable — they are the requested
deliverable and a ready building block, just not the default because the
measurement did not justify displacing scalar. Determinism unaffected: the
core and its golden vectors are untouched — AccuracyCoin 141/141,
visual_regression byte-identical (the shipped on-screen frame path stays
GPU-resident and does not route through this module). This is the frontend
counterpart to the "measure, don't assume" discipline the fat-LTO A/B below
applies to the release profile: there the measurement cleared the bar and the
choice was retained; here it did not, and the SIMD path is provided-but-not-adopted.
v2.1.8 "Performance" (A4) — release wasm size/startup¶
The release wasm build now runs wasm-opt -O4 (Binaryen's aggressive speed
pipeline, SIMD + bulk-memory preserved) instead of trunk's default -Oz,
selected via data-wasm-opt="4" in crates/rustynes-frontend/web/index.html.
-O4 optimizes for runtime speed (the per-frame emulator hot loop) rather than
raw size; the wasm-opt pass still shrinks the wasm-bindgen output 12.7 MiB ->
11.1 MiB raw (~13%), and the shippable bundle lands well inside the 5 MiB gzip
budget enforced by scripts/wasm_size_budget.sh + the CI web gate. Measured on
the real trunk build --release artifact:
| Asset | raw | gzip | brotli |
|---|---|---|---|
rustynes-frontend-*_bg.wasm |
11.61 MB | 4.16 MB | 2.97 MB |
rustynes-frontend-*.js (glue) |
168.7 KB | 25.7 KB | 21.3 KB |
sw.js |
3.5 KB | 1.5 KB | 1.2 KB |
| TOTAL | 11.78 MB | 3.99 MiB | 2.85 MiB |
gzip total 3.99 MiB vs the 5.00 MiB budget — PASS, 1.01 MiB headroom. Startup
uses streaming instantiation (trunk's loader calls WebAssembly.instantiateStreaming;
sw.js serves cached responses with the application/wasm content-type
preserved, so a warm PWA cache still streams). On code-splitting: the two heavy
optional features are already out of the wasm bundle by construction —
scripting (mlua) and hd-pack are cfg(not(target_arch = "wasm32"))-only, and
the lightweight wasm-canvas embed is the existing feature-flag split; single-
cdylib dynamic-import() splitting is not supported by the pinned trunk
toolchain (documented in docs/frontend.md).
v2.1.5 — fat-LTO vs thin-LTO release-profile A/B (decision: retain fat)¶
[profile.release] ships lto = "fat" + codegen-units = 1 (see the "Cargo
profile" bullet above and Cargo.toml). That has been the profile since the
v1.0.0 engine transplant, but the choice had never been backed by an in-repo
A/B on the current core — and the historical caption at the top of this file
even mis-stated it as thin. This pass measures the difference the shipped
profile actually buys, against the standing > 3% Criterion-stable +
byte-identical adoption bar.
Method: with codegen-units = 1 and panic = "abort" held fixed, the release
profile was flipped between lto = "fat" (the shipped default) and
lto = "thin", each rebuilt from clean and benched back-to-back on the same
host (Intel Core i9-10850K, CachyOS, powersave governor, Rust 1.96, bench
process pinned with taskset -c 0-7) via
cargo bench -p rustynes-cpu -p rustynes-ppu -p rustynes-core
(--warm-up-time 1 --measurement-time 5). Criterion medians:
| Bench | Crate | thin | fat (shipped) | fat vs thin |
|---|---|---|---|---|
cpu_throughput::cpu_nop_step_x1000 |
rustynes-cpu |
217.5 ns | 216.8 ns | +0.3% (within noise) |
ppu_throughput::ppu_tick_one_frame |
rustynes-ppu |
725.6 µs | 574.5 µs | +20.8% |
full_frame::nes_run_frame_nestest |
rustynes-core |
4.667 ms | 4.277 ms | +8.4% |
full_frame::nes_run_frame_flowing_palette |
rustynes-core |
3.004 ms | 2.378 ms | +20.8% |
fat-LTO clears the > 3% bar decisively on every bench that spans a crate
boundary — the whole-scheduler full_frame paths (+8.4% / +20.8%) and the
PPU dot loop (+20.8%, which calls across into rustynes-mappers for every
CHR/nametable fetch). The single-crate cpu_throughput bench is the control:
its cycle loop links essentially one crate, so cross-crate LTO has nothing to
inline and the delta sits in the noise (+0.3%) — exactly the signature of a
cross-crate-inlining win, not a codegen-quality artifact.
Byte-identity — verified, not assumed. Both profiles were rebuilt in
release mode (so the actual LTO codegen is exercised, unlike a default
cargo test dev build) and run against the golden oracle:
cargo test --release -p rustynes-test-harness --features test-roms \
--test accuracycoin --test visual_regression --test nestest --test apu_mixer
Both lto = "fat" and lto = "thin" pass byte-for-byte identically — AccuracyCoin
141/141, the nestest golden-log 0-diff, the golden-framebuffer
visual_regression suite, and the APU apu_mixer/volume audio suites all
green under each profile — confirming LTO level affects inlining and code
layout only, never the emulated framebuffer/audio/cycle hashes (Rust emits no
fast-math).
Decision: retain lto = "fat". It was already the shipped default; this
A/B retroactively validates it well above the adoption bar at zero byte-identity
cost. No default-build change was made — this is the measured justification for
the existing profile, filling the gap the mis-stated caption had left. The one
tradeoff is release build time (fat-LTO + codegen-units = 1 serializes the
final codegen: a clean full_frame bench rebuild ran ~55–80 s here); that is a
build-time-only cost paid once per release, never at runtime, and is acceptable
for the shipping binary.
Host-tuned / target-CPU release variants (opt-in, non-default)¶
The portable release build targets the baseline x86-64 ISA so the shipped
binary runs everywhere. Two opt-in variants trade portability for a tuned
instruction set — both keep the emulated output byte-identical (Rust enables no
fast-math / FP contraction under target-cpu), but verify with the oracle
suite anyway when benchmarking with them, and never ship them as the portable
artifact:
release-native(host-tuned). The[profile.release-native]profile (inheritsrelease) exists so host-tuned objects stay out of the portable release cache; cargo profiles can't carry rustflags, so pair it withtarget-cpu=native:
x86-64-v3(portable-but-modern desktop). A middle ground that stays portable across essentially all 2015-and-later x86-64 desktops (AVX2 + BMI2 + FMA) without pinning to one exact CPU:
Useful for a self-built desktop binary; not wired into the release matrix
(which ships the maximally-portable baseline x86-64 build).
v2.1.8 "Performance" A1 — specialized visible-scanline fast dot path¶
Profile first (mandatory). A perf record of a representative mixed
workload (the PGO training corpus — nestest, flowing_palette, oam_stress,
db_apu, AccuracyCoin, and the MMC1/MMC3 Holy Mapperel boards, self-driven
past their title screens) attributes frame self-time as:
| Function | Self-time |
|---|---|
rustynes_ppu::ppu::Ppu::tick |
46.5% |
LockstepBus::cpu_clock |
22.5% |
Cpu::end_cycle |
10.4% |
Cpu::read1 |
8.0% |
LockstepBus::raw_cpu_read / Cpu::dispatch / mapper reads |
remainder |
So the PPU per-dot FSM is the single dominant hot function — the correct
target (this also corrects a stale inference from the synthetic
ppu_tick_one_frame bench, whose no-op PpuBus and rendering-disabled default
under-represent the real per-dot cost). The overwhelming majority of tick's
89,342 per-frame calls are visible-scanline background-render dots whose
surrounding event/bookkeeping branches (scanline-241 VBL set, pre-render clear,
sprite-tile fetch dots 260..=316, the OAMADDR-reset window, the dot-257
hori-copy, the PPUDATA state machine, the OAM-corruption commit, the odd-frame
skip) are all statically dead.
Design. A default-OFF runtime knob (Nes::set_fast_dotloop) gates a
specialized straight-line handler (Ppu::tick_visible_render_fast). When ON,
the tick dispatch tests a conservative guard — a visible scanline, dots
1..=256, rendering stably enabled (immediate == 1-dot-delayed == previous
dot), and no sub-dot disturbance (no $2006 copy-V delay, no PPUMASK
write-delay, no PPUDATA state machine in flight, no armed/pending
OAM-corruption, warm classification cache) — and, when it holds, runs the
handler and returns. The handler executes the identical helper sequence the
general path would for such a dot (tick_oam_corruption,
tick_sprite_eval_per_dot, tick_oam_bus, reload_bg_shift_regs, the
ale_drive_* / fetch_* pair, inc_hori_v / inc_vert_v, emit_pixel,
shift_bg) with the dead branches elided, so it is byte-identical by
construction; any disturbance falls instantly back to the exact per-dot path.
The guard is ordered to short-circuit cheaply for non-covered dots (dot range →
rendering-enabled → cache/visible → the rare disturbance flags), so the knob
costs ~nothing when the fast path does not apply. Compiled out under
ppu-state-trace (whose end-of-tick hook must observe every dot).
Why a per-dot specialization and not a whole-scanline batch. The
Mesen2/tetanes-style approach batches an entire visible scanline in one
straight-line renderer. That is architecturally precluded here by the v2.0.0
"Timebase" lockstep every-cycle-bus-access scheduler: LockstepBus::run_ppu_to
is called twice per CPU cycle (split around the bus access) and advances the PPU
by ≤3 dots per CPU cycle, and the CPU observes PPU side-effects
(A12→MMC3 IRQ at dot 260, the /NMI edge sampled between dots, sprite-0 hit and
VBL via $2002, $2004 / $2007 reads) at that 3-dot granularity. The PPU is
therefore never invited to run a scanline uninterrupted — a true batch would
require reintroducing the catch-up scheduler v2.0.0 deliberately removed and
would break the exact-dot event delivery. So A1 optimizes the per-dot work
(pruning dead branches on the hot dots), not the dot cadence.
Byte-identity — proven, not assumed. With the knob OFF (the shipped default)
the build is byte-identical to one without the field. With it ON, the
differential test crates/rustynes-test-harness/tests/fast_dotloop_diff.rs
runs a corpus (nestest, flowing_palette, oam_stress, AccuracyCoin, the
Holy Mapperel MMC1/MMC3 boards, and a mid-frame raster demo) through BOTH paths
and asserts bit-for-bit identical framebuffer + palette-index framebuffer +
audio + CPU-cycle count + full core snapshot, every frame. AccuracyCoin
holds 141/141, nestest 0-diff, the visual_regression golden set and the
APU oracle all byte-identical.
Measured — interleaved per-frame A/B (drift-robust). The development host
(Intel Core i9-10850K, 20 logical cores) was under heavy concurrent build load
during this pass, which contaminates the cross-bench Criterion full_frame
comparison (later benches absorb the load spike). An interleaved harness that
alternates OFF/ON at per-frame granularity cancels that slow drift; measured
at low load, rock-stable across three rounds:
| Workload (rendering state) | exact (OFF) | fast (ON) | fast is faster by |
|---|---|---|---|
nestest (rendering enabled, rendered menu) |
~4.54 ms/frame | ~3.98 ms/frame | +12.3% |
flowing_palette (rendering disabled — 64-colour backdrop-override demo) |
~2.64 ms/frame | ~2.64 ms/frame | +0.3% (neutral) |
The +12.3% on rendering-enabled content clears the standing >3% + byte-identical
adoption bar decisively; the rendering-disabled demo never enters the fast path
(the guard bails at rendering_enabled()), so it is neutral. Real games render
the vast majority of the time, so the representative effect is the +12.3% figure.
Criterion full_frame baselines this pass (stock, same host): nes_run_frame_nestest
~4.26 ms, nes_run_frame_flowing_palette ~2.55 ms, ppu_tick_one_frame ~541 µs.
Decision: shipped default-OFF (opt-in). The optimization is a pure, byte-identical speedup, so per this file's convention it could be the default. It is nonetheless kept default-OFF for this cut — it is the roadmap's single highest-risk item, and shipping it off keeps the default build unchanged and byte-identical while the differential test + oracle prove correctness and the A/B proves the win. Recommended for promotion to default after maintainer review and a clean-host Criterion confirmation.
v1.4.0 Workstream F — measure-first micro-opt pass (core)¶
All changes are zero-behavior / zero-synthesis: bit-identical framebuffer +
audio, AccuracyCoin 100% (139/139) held, the visual visual_regression golden
and the APU oracle (apu_mixer / apu_test) stayed byte-identical with no
snapshot re-baseline. Baseline captured with
cargo bench -p rustynes-core --bench full_frame -- --save-baseline v1.4.0-pre
on the nestest (near-idle menu) + flowing_palette (rendering-heavy,
full-BG-every-frame) inputs; the headline number is the rendering path.
- F1 — PPU scanline-stable flag cache + hot-helper inlining
(
crates/rustynes-ppu/src/ppu.rs). Thevisible/pre_render/render_lineclassifications are pure functions ofself.scanline+self.region, yet the per-dottickrecomputed them (last-visible-line + prerender-line compares, ~7 branches) on all 89,342 dots/frame. They are now computed once when the scanline changes — detected via aflags_cached_scanlinesentinel — and read fromcached_visible/cached_pre_render/cached_render_lineon every other dot. The cache is pure derived state (NOT part of the PPU save-state snapshot) and self-heals on reset / restore (the sentinel starts ati16::MIN, forcing a recompute on the first tick). The mid-scanline-mutable$2001rendering gates (rendering/rendering_gate/bg_reload_render) are deliberately NOT cached — they can change mid-scanline, so caching them would be observable. The six hot pixel-fetch / shift-register helpers (fetch_nt/fetch_at/fetch_bg_lo/fetch_bg_hi/reload_bg_shift_regs/prefetch_shift_bg_regs) are marked#[inline]. The v2.8.0 BLEP delta-ring loop (crates/rustynes-apu/src/blip.rs) was re-verified as still split into two contiguous SAXPY runs (auto-vectorizes; no change needed). - F2 — MMC5
cpu_readhot-path short-circuit (crates/rustynes-mappers/src/mmc5.rs). PRG-ROM/RAM fetches at$8000-$FFFFdominatecpu_read(every opcode + operand fetch on an MMC5 cart), while the register / ExRAM arms only fire on explicit$5xxxaccesses. An earlyif addr >= 0x8000 { return self.read_prg_window(addr); }short-circuits the common case before the$5xxxrange match — byte-identical to the0x8000..=0xFFFFarm it bypasses.
Measured full_frame deltas vs. v1.4.0-pre (criterion, two runs):
flowing_palette −7.6% to −8.7% (2.354 ms → ~2.16 ms), the rendering-heavy
path these opts target; nestest within the noise threshold (the near-idle
menu barely exercises the BG-fetch pipeline, so there is little to gain and the
result floats inside criterion's noise band). Net: the rendering path is
meaningfully faster and the idle path is neutral.
Dropped (kept out — no clear neutral win / determinism risk): the F2 BLEP
phase-row index cache (the row index genuinely depends on the per-sample phase,
so there is no cast to elide without reordering the f64→f32→i32
quantization, risking byte-identity); the F3 parse() mapper-id reorder (the
arms are already ascending 0/1/2/3/4-first and an integer match compiles to a
jump table regardless of source order); the F3 bus controller-strobe gate +
mapper_caps.cpu_cycle_hook (both already gated behind active flags); the F3
DMA get/put enum unification (a larger refactor with no clear neutral win).
v1.7.0 "Forge" Workstream H7 — tier-1 perf (measure-first): no change adopted¶
H7 named two candidate micro-opts from to-dos/plans/research/v1.7.0-detail-performance.md
(T1.2 / T1.3). The contract is the standing one: adopt only on a >3% Criterion-stable +
byte-identical bar. Both were measured against fresh baselines (full_frame on
nestest + flowing_palette; spectral on blip_square_wave + blip_silence) and
neither cleared the bar — nothing was adopted. Findings:
- T1.2 — unified-DMA cycle fast-path. The research premise (that
unified_dma_cycle"runs unconditionally every CPU cycle") does not hold for this codebase: the per-cycle dispatch already sits behind awhile bus.unified_dma_pending()floor inCpu::read1/idle_tick, andunified_dma_pending()itself leads with thepending_dmc_dmabool short-circuit, so a no-DMA cycle already costs only three bool-field reads and the heavyunified_dma_cycle_implis out-of-line (cold). The release profile islto = "fat"+codegen-units = 1, so the gate is already inlined across the crate boundary at the LTO stage. An explicit#[inline]onunified_dma_pendingmeasured "no change in performance detected" on bothfull_framebenches (point estimates straddling zero, p > 0.05), as fat-LTO predicts. REJECTED — not byte-identity (the change was byte-identical) but the >3% bar. - T1.3 — BLEP phase-row cache. This is the same optimization the v1.4.0 F2 pass
already evaluated and dropped (see above). It cannot win here for two compounding
reasons: (a)
Kernel::row()is only called on signal edges (if delta != 0.0), not per sample — theblip_silencebench (zero edges) is within noise ofblip_square_wave, so the row lookup is not the hot cost; the per-sample cost is thephase += stepaccumulate + the integrate/emit/filter.processloop, which a phase-row cache does not touch. (b) The kernel usesPHASES = 256and the NTSC step is44100 / 1_789_773 ≈ 0.0246, so the quantized phase bucket advances ~6.3 rows per sample — consecutiverow()calls essentially never share a bucket, giving a cache hit-rate near zero. A guarded(bucket -> row_index)cache (byte-identical by construction — same bucket maps to the same index maps to the same coeffs, and the APU determinism test passed) showed no stable win; under measurement it only added a branch. REJECTED — byte-identical, but < 3% (and structurally a non-win).
Measurement note for any follow-up: the bench host (20 logical cores, powersave
governor, turbo on) carries a large run-to-run variance — even pinned (taskset) the
full_frame benches floated ±~4% same-binary, i.e. the noise floor sits at the adoption
bar, so a sub-4% win is not Criterion-stably provable on this hardware. The PGO/BOLT gate
(pgo.yml) remains the project's authoritative >3%-Criterion + byte-identical promotion
path; H7 leaves it unexercised because there was no candidate to promote.
v1.5.0 "Lens" Workstream H — frontend pacing & audio-sync pass¶
Source data: a real high-refresh capture
(perf-logs/perf-Super_Mario_Bros_nes-20260616-231215.csv; 143.975 Hz display,
auto→wallclock pacing, Mailbox, run-ahead = 1, rewind on). The baseline is
reassuring — raw frame cost_mean ≈ 8.5 ms / p99 ≈ 9.2 ms / max ≈ 10.2 ms
vs. the 16.639 ms NTSC budget (~51%), so the v1.4.0 core perf pass holds and the
core synthesis is not the bottleneck. Every measured problem was in the
frontend pacing/present/audio layer (determinism-safe — pacing lives in the
frontend by contract): recurring 50–128 ms produce stalls (produced_max) with
climbing catchup_bursts (9→62) + snap_forwards (3→12) while cost stayed flat
(⇒ blocking/scheduling, not compute); audio underruns 3→12 with
audio_queued_ms oscillating 68–91 ms around the 60 ms target; and a blind
gpu_ms. This pass is measure-first and keeps the determinism contract
(AccuracyCoin 100% (139/139) + visual golden + APU oracle byte-identical after
the changes).
- H1 — decoupled triple-buffer framebuffer handoff (
present_buffer.rs). The present (winit) thread formerly copied the 240 KiB framebuffer out ofEmuCore::present_fbunder the emu mutex, serializing the present against the dedicated emu thread's wholeproduce_one_frame(~8.5 ms) — so on a 144 Hz panel the present could block up to a full produce (the flat-cost / spiky-produced_maxsignature). The copy moved onto a triple buffer guarded by a small dedicated mutex held only for the brief copy: the emu thread publishes each produced frame while it already holds the emu lock; the common present path (no NTSC composite-rt index buffer, no HD-pack) takes the freshest frame without ever blocking on produce. Native +emu-threadonly; the synchronous (--no-default-features) and wasm single-threaded paths keep the prior locked copy. Pure presentation-path change — the bytes published are exactlynes.framebuffer(); a concurrent producer/consumer unit test guards against torn frames. - H2 — pacer stall phase-break. The hybrid sleep-then-spin wall-clock pacer
(
block_until_native: sleep to withinSPIN_MARGIN, then busy-spin) and theMAX_CATCHUP_FRAMES = 3cap + snap-forward already existed; the 50–128 ms spikes were individual OS deschedules (the code already cites 10–40 ms descheduling and elevates the emu thread's priority to mitigate it), not runaway catch-up. So H2 keeps the interval rings honest rather than re-paving the pacer: when the gap since the last scheduled frame already exceeds the catch-up window, the produced/presented interval phase is broken before the gap is recorded, so one transient stall no longer dominatesproduced_max/ reads as sustained judder. Perf-ring bookkeeping only — no pacing-behavior or determinism change. - H3 — reuse the rewind keyframe-cache allocation (see the
Performance-pass section's H3 entry above;
rustynes-core, bit-identical). - H4 — audio DRC + buffer tuning. Widened
MAX_DRC_DELTAfrom the ±0.5% Near/RetroArch default to ±1% (~17 cents, far below audibility): the narrow band could not drain a catch-up-burst over-fill (a 30 ms excess took ~10 s to drain at ±0.5%, so the servo perpetually lagged and eventually underran), and ±1% roughly doubles the drain rate so the queue tracks the target. Plus a one-time +20 ms latency-target bump on high-refresh panels (> 75 Hz, capped by the 250 ms clamp, never below the user's configured floor) for ring headroom against the larger bursts. The resampler stage changes audio timing only — the core's emitted samples (the determinism + audio-oracle contract) are untouched. - H5 — GPU pass timing on by default. The
gpu-timingfeature (theTIMESTAMP_QUERY-bracketed encoder timer with async 3-deep readback) is now in the default native feature set, so the shipped Performance panel + perf log report a realgpu_msinstead of a blank-. Timestamp queries are a pure side channel (requested only when the adapter offers the feature; degrading toNoneotherwise), so the presented image is byte-identical with the feature on/off and the wasm builds (gated out) are unchanged. The panel's pacer-anomaly readout also surfaces the worst recent present gap (presented.max_ms). - H6 — high-refresh present-aligned cadence — DROPPED (measure-first). A
present-aligned-to-production cadence under Mailbox to smooth the 60-on-144 beat
carries documented pacing-regression risk (
docs/frontend.md: the deeper beat mitigation "needs on-device validation across real refresh rates") and has no headless measurement path, so it can't be validated under the measure-first rule in this environment. H1 already removes the present-blocking that amplified the beat, and thepresented_dups/produced_droppedbeat counters remain the diagnostic for whether the work is later warranted. Not implemented. - H7 — perf-log regression gate.
scripts/perf/perf_capture.shdrives a bounded windowed capture with perf logging auto-enabled (the newRUSTYNES_PERF_LOGenv hook), andscripts/perf/perf_log_check.pyparses the CSV and assertsunderruns/produced_max/catchup_bursts/snap_forwardsstay within bounds — turning them into a tracked, repeatable signal. Pacing / audio behavior only exists with the real winit present loop + cpal stream (no headless path — the same reason the v1.2.0 F1/F3 items are maintainer-manual), so the capture skips cleanly on a headless host (exit 0) and is run locally / on-display by the maintainer, mirroring the bench ceiling's deliberately non-flaky philosophy. The checker looks columns up by name, so it tracksperf_log.rs::columns()as it grows (the H8 parity guarantee). It parses the 2026-06-16 baseline and correctly flags its 12 underruns / 128.9 msproduced_max/ 62catchup_bursts. - H8 — perf-log ↔ panel parity (
perf_log.rs). The exporter had drifted behind the panel:gpu_msempty (H5), andpresent_mode_fell_back/target_ms/ the DRC servo + run-ahead/rewind state unlogged. The CSV header + every data row are now built from one orderedcolumns()list, and acsv_columns_cover_panel_metricstest asserts every panel-surfacedPerfViewmetric has a column (+ no duplicate columns, + row field count == header), so the exporter and the live panel can't silently drift again.
Profile-guided optimization (PGO) — recipe¶
scripts/pgo/run.sh adapts Mesen2's buildPGO.sh flow to cargo-pgo:
instrumented build → headless training sweep (pgo_trainer runs the
committed ROM corpus + any user dumps in tests/roms/external/PGOGames/
uncapped with scripted Start-button input, ~3600 frames each) →
cargo pgo optimize build -- -p rustynes-frontend. Prereqs: cargo install
cargo-pgo + rustup component add llvm-tools-preview. The training corpus is
the seven committed CC0/MIT ROMs (nestest, flowing_palette, oam_stress,
db_apu, AccuracyCoin, the MMC1/MMC3 holy_mapperel boards) — see the
COMMITTED list in crates/rustynes-test-harness/src/bin/pgo_trainer.rs.
CI promotion gate — .github/workflows/pgo.yml¶
The recipe is gated into a manual-/release-only workflow (PGO), NOT the
per-PR pipeline: an instrument + train + optimized-rebuild cycle compiles the
workspace twice plus a multi-ROM sweep, far too slow for the PR gate (that's the
fast absolute-ceiling bench job in ci.yml). The PGO workflow triggers on
workflow_dispatch (Actions tab → Run workflow; optional frames and
run_bolt inputs) and on push of a release tag (v*).
Its stages:
- Baseline —
cargo bench -p rustynes-core --bench full_framesaved as theplainCriterion baseline. - PGO build — runs
scripts/pgo/run.sh(instrument → train → optimized rebuild ofrustynes-frontend). - PGO bench — re-runs
full_framewith the merged profile applied, saved as thepgobaseline, A/B'd againstplainon the same runner back to back. - Determinism oracle — rebuilds + runs the full
--features test-romssuite with the PGO codegen applied (cargo pgo optimize test): AccuracyCoin 141/141,nestestgolden-log 0-diff, blargg/kevtris, the golden-framebuffervisual_regressionsuite, and the APU mixer/volume audio suites — all assert byte-exact framebuffer/audio/cycle hashes. - Gate + upload — computes the speedup and uploads the PGO binary as an artifact only when promotable.
Promotion gate — both conditions (AND):
- Faster — the PGO
full_framemean must beat plain release by > 3.0% (PGO_MIN_SPEEDUP_PCT). This is a relative A/B on one runner, so it is Criterion-stable above shared-runner noise — distinct from theci.ymlbench job's absolute 10 ms ceiling, which does not apply here. - Byte-identical — the determinism oracle (stage 4) must pass with zero divergence. PGO changes inlining + code layout, not FP semantics (Rust emits no fast-math), but the gate proves it rather than assuming it: any framebuffer/audio/cycle-hash difference fails the stage and blocks promotion.
A failed gate is informational — it never blocks a release; release.yml ships
the plain-release binary independently.
BOLT (Linux post-link, optional)¶
A second Linux-only bolt job runs behind the same > 3% + byte-identical
gate, only after the PGO stage has already promoted (needs.pgo.outputs.promotable
== 'true'), and on workflow_dispatch only when the run_bolt input is true.
It is best-effort: it probes for llvm-bolt (PATH, then apt-get install
bolt) and skips cleanly if unavailable, so the workflow never hard-fails on a
runner image without BOLT. When present it chains cargo pgo bolt build →
re-train → cargo pgo bolt optimize, re-benches, re-runs the oracle, and uploads
the BOLT binary only if it too clears > 3% and stays byte-identical (a possible
extra ~2% on top of PGO).
How to trigger¶
# Manual (from a checkout with the gh CLI):
gh workflow run PGO.yml # default 3600 frames/ROM, no BOLT
gh workflow run PGO.yml -f frames=7200 -f run_bolt=true
# Or push a release tag (runs alongside release.yml):
git tag v1.2.0 && git push origin v1.2.0
Things explicitly not in scope for v1.0¶
- JIT recompilation of CPU code. NES games are small enough that interpretation suffices; JIT complicates everything. (Higan/ares don't JIT either.)
- GPU-side CPU emulation. Out of scope.
- Multi-threading. The frame fits in 2 ms single-threaded; threading adds overhead and complexity for no win.
- SIMD CPU emulation. No vectorizable inner loop in the CPU; SIMD belongs in framebuffer post-processing if anywhere.
Profiling tools¶
perf+perf report(Linux) — primary profiler.cargo-flamegraph— visualization wrapper.samply— sampling profiler with Firefox Profiler UI; cross-platform.tracing+tracing-tracy— structured timing for the run loop, useful when chasing per-frame variance.
Memory¶
- Core working set: < 256 KB (WRAM 2 KB, VRAM 2 KB, OAM 256 B, PPU shift regs ~ 100 B, framebuffer 240 KB, mapper state ~ 1 KB, save-state buffer 64 KB).
- Rewind ring buffer: 60 s × 60 fps × ~64 KB = ~225 MB worst case; with delta compression and only saving every Nth frame, target ≤ 32 MB.
Open questions¶
- Per-platform sample rates. macOS often defaults to 44.1 kHz; Linux PipeWire often 48 kHz; Windows WASAPI varies. Plan: query cpal at startup, configure APU to emit at the platform rate. Cost: cache the sinc kernel for that rate.
- VSync vs. fixed-rate. wgpu present mode
Fifovs.Mailbox. Default toFifo(vsync), let users override. - Frameskip under load. If the run loop falls behind, drop video frames but never audio frames. Implementation deferred to Phase 5.