Skip to main content

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.

ChangeEffect
Audio per frame computed from the ROM's frame rate, audio pacing creditSuper Mario World 46 → 60 fps
Data cache 32 → 64 KB, instruction cache 16 → 32 KBkept for every app
Main z-buffer in internal RAMMario 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 emptyZelda 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 stripDonkey Kong Country 97 strips → 1, 16.1 → 11.6 ms
S-DSP (sound) on core 1, one frame of audio latencyMario Kart 57 → 57–62, mostly 60
SuperFX (GSU) on core 1Star 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.

GameInterpreterDynarec, first runNow (emulated / shown)
Sonic Advance55.54858.8 / 54.5
Metal Slug Advance47.42955.7 / 55.6
TMNT44.75259.5 / 58.7
ChangeEffect
Direct aligned loads and stores, 16-bit instruction formsMetal Slug 29 → 35 fps
Hot C helpers in IRAM35 → 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 taskSonic 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​

ChangeEffect (Donkey Kong in play)
Start42 fps; MARIA (graphics chip) 17.1 ms + 6502 6.5 ms a frame
64 KB address space in internal RAMnothing
MARIA's line colours read once a line, its code in IRAM50 fps, MARIA 13.0 ms
MARIA's common case written directly, the core's small state in internal RAM56 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​

ChangeEffect (Metal Slug 1st Mission, board 2026-10-09)
Start60 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 readTLCS-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 linenothing

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 render slower on all three games tested (Mario Kart 12.50 → 12.87 ms, Sonic 5.90 → 6.36, Metal Slug Advance 5.35 → 5.66); branch gba-speed kept 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​

SystemMeasuredNext lever
Atari 780058–59 fps (emulation 15.9 ms a frame)MARIA 9.2 ms; the 6502 is 6.6 ms
CPS1Final Fight 55, SF2 38–47the scroll-2 copy (18 % of core 0), the opaque drawer
Neo Geo raster gamesMetal Slug 2, KOF95 52–5516-bit video path (about 16 ms)
GBASonic, Metal Slug Advance 60 (all drawn); Mario Kart 51Mario Kart is ARM-bound (cpu 19.3 ms a frame, core 1 20 % idle): the emulated CPU, not the renderer
System 1654–58not profiled yet

The plans behind these are in Arcade 60 fps plan and JIT (dynarec) plan.