PicoDMG Devlog

Not just another Game Boy Emulator

View on GitHub
19 June 2026

Episode 07 - The Search for Speed (Part 1)

by dodger-one

The Search for Speed (Part 1)

Or maybe “Need for Speed”?
After all the holy crap seen on Episode 06, when the new Pico 2 Finally Booted, the next problem became obvious immediately:

It worked, but it was nowhere near the performance target.

The rough starting point was around the low-30 FPS range on the first ST7789-oriented attempts. The end goal was very clear from the beginning: make the emulator run at the original Game Boy cadence, which in practice meant getting stable full-speed gameplay at about 59.75 FPS.

This Episode is the a technical summary of that phase.
Sorry guys, no great pic’s (or not a lot of them at least)…

Step 1: Treat ST7789 As a Different Performance Problem

The original project lineage was built around the ILI9225 path, but the ST7789 build changed the constraints completely:

The final ST7789 driver now uses a 320x240 logical space and renders the Game Boy image into a 266x240 area, centered and shifted to fit the enclosure layout:

That was visually the right decision, but it also meant the frontend had to scale every 160x144 frame into a much larger output region. From that point on, the LCD path was no longer a small detail. It was one of the main performance battlegrounds.

Step 2: Increase SPI Throughput for the ST7789 Path

The ST7789 path also needed a different SPI configuration than the old ILI9225 one.

The split is visible in the LCD init path:

Reference:

That change mattered because the ST7789 transport is fundamentally a byte-stream path. Once the panel was working reliably, the next constraint was simple: the more bytes that could be pushed per second, the less time core 0 had to stall behind the display.

This was one of the first clearly measurable wins.

FPS impact:

Step 3: Keep LCD Work Off the Main Emulation Core

The biggest structural improvement was not a magic compiler flag. It was architectural:

That design is still present in the current source:

This was one of the most important turning points in the speed search.

Before that split, LCD output and emulation time were too entangled. Once the panel transport was pushed onto the second core, the project became much easier to reason about:

It did not solve performance by itself, but it made the rest of the work possible.

FPS impact:

Step 4: Stop Sending Tiny Pieces of Work to the Panel

The next major improvement was inside the ST7789 driver itself.

Instead of treating every line update as an isolated SPI transaction, the driver now:

  1. scales one Game Boy line from 160 pixels to 266
  2. duplicates rows vertically to reach the 240-pixel output height
  3. accumulates several adjacent rows into a small band
  4. queues that band to DMA

Relevant code:

This was a real performance optimization, not a cosmetic refactor.

The goal was to reduce command overhead and let the SPI/DMA pipeline move larger contiguous chunks instead of constantly bouncing between tiny writes. Once that banded path was in place, the ST7789 backend started behaving much more like a transport engine and much less like a debug port.

FPS impact:

Step 5: Add Real Instrumentation

For a while, performance tuning was still too qualitative:

That was not enough anymore.

The project needed to print useful timing data every second, and that is exactly what happened. The log evolved from a simple FPS counter into a set of performance buckets that exposed where time was really going.

The current tree still contains the simpler human-readable FPS report:

During the speed-search phase, the exported development thread shows the more detailed PERF: logs that broke frame cost into buckets such as:

That changed the process completely. Instead of just asking “is it faster?”, the question became:

Once those numbers were visible, several bad ideas could be rejected quickly.

FPS impact:

Step 6: Use Interlace Carefully, Understand Frame Skip Correctly

Two existing knobs from Peanut-GB kept showing up during testing:

The important thing is that they are not equivalent.

Peanut-GB’s frame_skip is a real draw-suppression mechanism:

And in this frontend, enabling frame_skip also disables audio output work:

That is why frame_skip could produce huge FPS numbers during experiments: it was not “slightly lighter rendering”. It was effectively a fast-forward mode.

interlace, on the other hand, was a much more defensible practical compromise for this hardware. The relevant Peanut-GB logic is here:

And the frontend still exposes it at boot/runtime:

That distinction mattered a lot during testing:

FPS impact:

Step 7: Reject the Optimizations That Only Looked Good on Paper

Not every experiment survived.

This is probably the most important part of the story.

During this phase there were several attempts to improve heavy scenes with increasingly aggressive heuristics:

The exported thread shows the eventual conclusion very clearly: those heuristics changed some numbers, but they did not converge toward a clean solution. They sometimes improved calm scenes, but they did not reliably fix the hard workloads.

That realization was valuable because it prevented the project from turning into a pile of unstable hacks.

The real gains came from:

Not from inventing endless new skip modes.

Step 8: Push the RP2350 Until It Complains

By this point, most of the obvious bottlenecks had already been eliminated. The LCD path had been redesigned, DMA batching was in place, SPI throughput had been improved, and the emulator could finally be profiled instead of guessed about. Yet a small gap remained. Depending on the scene, the emulator was still missing roughly 5-8 FPS from the target.

At that point there was only one remaining question:

Was the RP2350 simply running out of CPU budget?

There was an easy way to find out. The current code still reflects that phase:

The emulator raises the voltage rail, programs the PLL, and reports the resulting system clock during boot.

What followed was not particularly scientific.

The clock was increased.
Then increased again.
And again.
And again.

Until eventually one of two things would happen:

Fortunately, the emulator won.

FPS impact:

The interesting part is not that overclocking worked.

The interesting part is that it only worked because most of the other bottlenecks had already been removed. Earlier in the project, additional clock speed would simply have made the system hit the same limitations slightly faster.

At this stage, however, the remaining bottleneck was finally what everyone expected from the beginning:

CPU performance.

The Result

By the end of that ST7789-on-RP2350 phase, the project had gone from a barely convincing first port to a build capable of maintaining approximately 59.75 FPS essentially all the time in the target configuration.

For the first time, the PicoDMG stopped feeling like a promising prototype and started feeling like a real Game Boy.

That result did not come from one miracle patch.

It came from a stack of cumulative changes:

In other words: the path to full speed was not “make one thing faster”.

It was:

Turn the emulator frontend into a system that could actually be profiled, reasoned about, and improved.

What Comes Next

This was only the first phase of the search for speed.

Once the obvious ST7789 transport wins were in place, the remaining slow scenes became much more interesting, because they were no longer explained by a single easy bottleneck. That is where the work started shifting from broad structural fixes into deeper profiling and harder tradeoffs.

That will be the subject of Part 2.

tags: rp2350 - st7789 - performance - emulator - technical