Optimizations
What was done to make the slow emulators faster, what each change was worth on the board, and what was tried and did not pay. Every number is a board measurement; a change that does not move the measured number is reverted.
The method. Each emulator prints its own profile line once a second
(SNES_PROF, GEN_PROF, GBAPROF, MAMEPROF, A78PROF): time per frame of
each part (CPU, video, sound, display). One change at a time, the same scene
(save state or a scripted input), the picture checked identical, on the PC
where a reference build exists (SNES, GBA, arcade, Atari 7800) and on the
webcam.
What the board is like
- Two cores at 240 MHz, 32 KB instruction cache, 64 KB data cache (the firmware's configuration), about 100–190 KB of internal RAM free while a game runs, 8 MB of PSRAM behind the data cache.
- The display is an 8-bit parallel bus at 20 MHz: a full 480×320 frame takes 15 ms, so about 60 frames a second is the most any core can show.
- Core 1 is free during games: moving work there is usually the largest gain.
SNES
From 45–52 emulated fps (10–13 drawn) on 2026-09-12 to 60 fps on every test scene.
| Change | Effect |
|---|---|
| Audio per frame computed from the ROM's frame rate, audio pacing credit | Super Mario World 46 → 60 fps |
| Data cache 32 → 64 KB, instruction cache 16 → 32 KB | kept for every app |
| Main z-buffer in internal RAM | Mario Kart 38 → 57 fps |
| Blank tiles cached by depth | −1500 tile conversions a frame in SMW |
| Mode 7 loop with its invariants in registers | −12 % |
| Colour-math fast path when the sub screen is empty | Zelda 24.9 → 9.5 ms a drawn frame, Super Metroid 24.6 → 14.4 |
Tile writers rewritten (restrict, depths in registers) | SMW −13 %, Zelda −16 %, Mega Man X −18 % |
| Backdrop and palette 0–15 read per line instead of a new strip | Donkey Kong Country 97 strips → 1, 16.1 → 11.6 ms |
| S-DSP (sound) on core 1, one frame of audio latency | Mario Kart 57 → 57–62, mostly 60 |
| SuperFX (GSU) on core 1 | Star Fox 22–30 → 53–60 fps |
Did not pay: the whole renderer in IRAM (1–6 %), VRAM in internal RAM (nothing), a Mode 7 loop per colour window (slower), a 4-pixel skip in the backdrop combine (slower). What is left in the renderer is instruction count: 20–45 instructions a pixel on a single-issue core.
Audio stays on the main chip
A separate audio coprocessor (an ESP32-S3-MINI module) was planned before the board existed. The board showed the 65816 + SPC700 fit in 8.5 ms and the renderer was the bottleneck, so the coprocessor was dropped (2026-09-26) and the sound moved to core 1 instead. The real audio problem is analog (the PDM carrier hiss); the next board plans an I2S class-D amplifier with its own DAC.
Mega Drive
The YM2612 FM synthesis (6 ms a frame, 37 % of the budget) runs on core 1: register writes are logged with their clock and replayed one frame later. 20 → 30 drawn fps at 60 emulated.
Game Boy Advance: the Xtensa dynarec
gpSP's ARM/Thumb code is translated into native Xtensa code at run time (source: pjcau/xtensa-68000-dynarec, GPL-2.0). It gives the same video and audio as gpSP's own x86 dynarec (bit-identical hashes in QEMU). The interpreter stays in the app as an automatic fallback (a game that rewrites its own code, or a crash), and the choice is remembered per game.
| Game | Interpreter | Dynarec, first run | Now (emulated / shown) |
|---|---|---|---|
| Sonic Advance | 55.5 | 48 | 58.8 / 54.5 |
| Metal Slug Advance | 47.4 | 29 | 55.7 / 55.6 |
| TMNT | 44.7 | 52 | 59.5 / 58.7 |
| Change | Effect |
|---|---|
| Direct aligned loads and stores, 16-bit instruction forms | Metal Slug 29 → 35 fps |
| Hot C helpers in IRAM | 35 → 40–45 fps |
| Three frame buffers (emulation stops waiting for the LCD) | Sonic 49.7 → 57.5 emulated |
| Renderer's own copy of VRAM (no wait on core 1) | −9 to −13 % CPU time |
| Render task below the display task | Sonic 46.6 → 54.5 shown |
| Bitmap modes 4/5: the back frame copied for the renderer only when shown (2026-10-13) | NFS Underground race 23.6 → 27.6 fps (core 0 42.2 → 37.0 ms) |
| RAM translation cache 384 → 640 KB (ROM 2 → 1.75 MB, same PSRAM), rewound at 3/4 (2026-10-13) | NFS race 27.4 → 42.1 fps: its ~276 KB of hot IWRAM code no longer retranslated 8-11 times a second (core 0 37.0 → 23.4 ms) |
Loads in short assembly routines called with callx0 (2026-10-14) | NFS cpu 23.6 → 21.8 ms, Doom 26.2 → 30.8 fps |
| Code rewritten by the game: only the blocks holding the address are dropped (index by 256-byte page), with the block's pending cycles handed over at a redirect (2026-10-14) | Doom 32.5 → 25.5 ms, Crash 28.2 → 18.6 ms; exact (state hashes) |
| IWRAM, EWRAM and VRAM stores in assembly, the redirect taken by the store routine itself (2026-10-14) | NFS 22.97 → 20.38 ms |
| Hot tier: the most-run blocks (sampled at 1 kHz) translated again into up to 32 KB of internal RAM, reached through a trampoline in the block's prologue (2026-10-14) | Mario Kart 14.4 → 13.0 ms, Crash 18.0 → 16.9, NFS 20.5 → 20.1; exact |
In play (2 minutes of real driving or fighting, 2026-10-14): NFS Underground 52, Crash Nitro Kart 56, DOOM 40, Mario Kart and F-Zero 60. Measured on the board's counters, NFS spends about a quarter of core 0's cycles waiting for instructions: the translated code lives in PSRAM behind a 32 KB instruction cache shared by both cores, about a fifth of that wait is PSRAM contention with core 1 and the display. Tried and dropped: LDM/STM in one call (exact, not faster), and shaving bytes off the translated code (the instruction wait did not move). DOOM rewrites its own code hundreds of times a second, so the hot tier cannot keep its blocks.
Arcade: the 68000
A 68000 dynarec was built on the same core and is exact (same screen hashes
as the interpreter), but slower: 13.1 ms of 68000 a frame on Metal Slug
against the interpreter's 7.7 ms. Retried on 2026-10-13 with the Virtual
Boy's recipe (code cache in internal RAM, hot blocks only, refills; QEMU fuzz
clean): in play only 5-18 KB of internal RAM is free, the cache fell back to
PSRAM and lost again (Final Fight 24.3 vs 6.9 ms, Metal Slug 42.5 vs 7.0 ms).
It needs 24-32 KB of free internal RAM, which these games do not have.
Kept the same day: the CPS1 layers drawn in 16-line bands in internal RAM
(fork 69fb5fba, on by default): the tile blitter, which never clipped, skips
the rows of other bands, the layer loops visit only the tile rows of the band,
the scroll 2 cache is built once a frame and its row-scroll copy visits only
the band's rows (without that SF2 lost 22 → 17.8 fps). With every frame drawn
Final Fight 34.3 → 37.1 fps and SF2 22.0 → 23.6; in play FF 56-61 and SF2
41-55 in a fight; PC gate neogeo_frames.py cps1band identical. Handing the
bands straight to the display (V2, as the Neo Geo) lost: FF 30.8 fps, the
display's per-band scaling on core 1 became the limit, and SF2 ran out of
memory for the band stage. Added on 2026-10-14: the bands for the 16-bit CPS1 games too (Knights 24.8 →
29.3 fps with every frame drawn, once the bands stopped taking the internal
RAM the sound board's task needs on core 1); the arcade flash cache grown to
4.25 MB, so that Street Fighter II's tiles leave PSRAM room for the second
frame bitmap and its frames are converted on core 1 again (38.4 → 52.7 fps,
play 47 → 53); and tiles of scroll 3 / scroll 1 hidden under fully opaque
tiles of later layers left out (per band, one bit a visible pixel; switched
off for 30 frames when a frame skips under 4 %; PC gate neogeo_frames.py cps1cover identical): Knights 29.1 → 31.3 fps, play 49 → 52. In a fight now:
Final Fight 60, Street Fighter II 52-53, Knights 52.
Also dropped the same day: the CPS1 layers drawn on core 1 (Final Fight
34.7 → 30.0 fps in the bench, Knights 43-45 → 30-37 in play, flicker): core 1
shares the PSRAM and already runs the sound board and the display. It turns each 68000 instruction into 70–80
bytes of host code fetched through the 32 KB instruction cache, against 2–6
bytes of guest code for the interpreter. It is off. Of a Neo Geo frame (24.7
ms in play), the 68000 is 10.9 ms and the video 10.4 ms, and core 1 is as full
with sound and display: a faster 68000 alone does not reach 60.
What was done on the interpreter and the drivers instead: idle-loop skipping (Aero Fighters 47–57), frame clears skipped when a layer covers the screen (CPS1: the clears were about 16 % of core 0), the band renderer and display buffers of the play build, sound chips at 16 kHz.
Atari 7800
| Change | Effect (Donkey Kong in play) |
|---|---|
| Start | 42 fps; MARIA (graphics chip) 17.1 ms + 6502 6.5 ms a frame |
| 64 KB address space in internal RAM | nothing |
| MARIA's line colours read once a line, its code in IRAM | 50 fps, MARIA 13.0 ms |
| MARIA's common case written directly, the core's small state in internal RAM | 56 fps, MARIA 10.9 ms |
| A frame that is not shown skips MARIA's colour write (frameskip 1) | 59 fps, MARIA 9.7 ms |
MARIA's direct objects stored with its state in locals (fork e21a7296, in master 2026-10-09) | 58–59 fps, MARIA 9.2 ms, 28–29 frames drawn a second (was 19–20) |
Neo Geo Pocket
| Change | Effect (Metal Slug 1st Mission, board 2026-10-09) |
|---|---|
| Start | 60 fps, ~29 frames drawn; TLCS-900H 9.0 ms, Z80 2.9, graphics 3.2, of 16.6 |
| Frameskip decided per frame (was fixed at 1) | drawn 20–31 → 45–61 in the fight, 30 → 60 on a quiet screen |
| Work RAM tested first in the CPU's byte read | TLCS-900H −0.5 to −0.6 ms |
| Sound DC blocker in fixed point (no soft doubles) | mix 0.72 → 0.32 ms |
| VDP registers read once a line | nothing |
What did not pay, across emulators
Three predictions in a row about memory were wrong on the board, which is why nothing is promised before it is measured:
- Moving data from PSRAM to internal RAM: the 7800's address space, the SNES VRAM, the 68000 program from flash to PSRAM — no gain each time.
- Writing pixels four at a time from a buffer instead of one byte at a time (CPS1, 2026-10-08): slower (25.6 → 26.6 ms a frame). Byte stores to PSRAM are already merged by the data cache; the buffer added work.
- Fewer instructions is not faster (GBA renderer, 2026-10-09): three changes that
removed the per-pixel transparency test (7 → 4 Xtensa instructions a pixel,
output identical) made
renderslower on all three games tested (Mario Kart 12.50 → 12.87 ms, Sonic 5.90 → 6.36, Metal Slug Advance 5.35 → 5.66); branchgba-speedkept as the record, not merged. - Code in IRAM helps only for code that misses the instruction cache (GBA helpers, MARIA); for code already hot it gives 1–6 %.
What is left
| System | Measured | Next lever |
|---|---|---|
| Atari 7800 | 58–59 fps (emulation 15.9 ms a frame) | MARIA 9.2 ms; the 6502 is 6.6 ms |
| CPS1 | Final Fight 55, SF2 38–47 | the scroll-2 copy (18 % of core 0), the opaque drawer |
| Neo Geo raster games | Metal Slug 2, KOF95 52–55 | 16-bit video path (about 16 ms) |
| GBA | Sonic, Metal Slug Advance 60 (all drawn); Mario Kart 51 | Mario Kart is ARM-bound (cpu 19.3 ms a frame, core 1 20 % idle): the emulated CPU, not the renderer |
| System 16 | 54–58 | not profiled yet |
The plans behind these are in Arcade 60 fps plan and JIT (dynarec) plan.