devlog.

Standing page · TerminalGB

Performance, and what it buys.

How fast TerminalGB runs, and what accuracy costs. Every figure carries the core, the picture engine and the load average it was taken on, because on this box the same binary reads 56% apart between a quiet machine and a busy one.

Fastest configuration 0.0405 ms/frame 414× real time, one core 3,735× at twenty workers A DMG frame is 16.7427 ms Updated 27 Aug 2026

The headline, with its conditions

Pokémon Blue from a real in-game save state, one worker, pinned to a P-core, thread CPU clock. The DMG's own frame is 16.7427 ms, so 1× real time is 59.7275 frames a second.

0.0405 ms
Per frame, fastest accurate config
Pixels off, no APU installed. 414× real time, load 1.3.
0.4962 ms
Per frame, exact engine + picture
34× real time. The default engine, drawing every dot.
3,735×
Aggregate, 20 workers
223,073 emulated frames a second on a quiet box.
9.5×
Faster than SameBoy
Both cycle-accurate, both optimised, measured in one session.

One core, five configurations

The number changes by an order of magnitude depending on what is switched on

Configuration ms/frame × real time Load
Collector — pixels off, APU installed, samples off0.0454369×1.3
The same, no APU installed at all0.0405414×1.3
The same, on a busy box0.0772217×16
Player — picture on, APU silent0.1582106×16
identical picture engine, picture on0.496234×16

Rows one and three are the same binary. The entire difference between 369× and 217× is the box's other occupant, reaching the measurement through frequency scaling. That is why every row above states its load average, and why a figure quoted without one should be distrusted.

Reproduce with the repository's own core_bench example: 20,000 frames, 3,000 warm-up, three repeats, pinned with taskset.

And with the speed flags on

Documented shortcuts, each behind a named switch, off by default

Everything above is the accurate machine — every peripheral on its exact cycle. A run that is driving the machine rather than judging it can take liberties, and each one is a named flag rather than a silent mode:

Modems/frame× real timeOver accurate
Accurate0.0555–0.0557300×
speed0.0510–0.0511328×+9.2%
speed,no-apu-state0.0395–0.0397423×+40.8%

Read these as ratios against the accurate row measured in the same session, not against the table above it — that is a different save state, and comparing across sessions on this box is worth nothing. One P-core, load 0.84, three interleaved rounds.

Speed mode is off by default and no accuracy gate can reach it.

Against the field, same session

One machine, one core, one cartridge, one frame count, one clock — and nobody's marketing number. Every side built with its own release configuration and its rendering and sound switched off, because that is how a headless campaign runs.

× REAL GAME BOY TIME — ONE CORE, ONE CARTRIDGE, 7,168 FRAMES, SAME MINUTE 200× 400× 600× 800× PyBoy 2.7.0 NOT cycle-accurate 869× 0.0193 ms/frame TerminalGB cycle-accurate 414× 0.0405 ms/frame SameBoy cycle-accurate 43× 0.3854 ms/frame
Pokémon Blue, 7,168 frames from reset — 120 emulated seconds — on every emulator, pinned to the same P-core, thread CPU time on every side. All three are the best of three runs interleaved with each other in the same minute on a quiet box (load 1.3). PyBoy's bar is drawn open because it is not cycle-accurate — the comparison is real, but the two are not doing the same job. Drawn from the measured figures, not to scale of any published claim.

Where the remaining gap is

It is entirely the core

Costed by turning each part on and off on both sides in the same session:

ComponentPyBoyOurs
Core only0.0198–0.02980.0672
The renderer, on top+0.0374+0.0434
The APU, silent+0.0034
The APU, producing samples+0.0255+0.0400

Our renderer is within 16% of theirs, and our APU is an order of magnitude cheaper when silent — which is the configuration a collector actually runs in. The whole difference is peripheral bookkeeping happening once per instruction here and once per horizon there.

The claim that did not hold

43×, not 1,000×

The figure in circulation is that a fast Game Boy interpreter reaches 1,000–10,000× real time. On this hardware, with this method, the reference cycle-accurate emulator reaches 43× — and it is not a slow implementation. It is written in C, compiled with -ffast-math and LTO, and it is the accuracy oracle this project uses to settle disputes.

Some arithmetic for why: 1,000× is 16.7 µs for 70,224 emulated T-cycles — about one host clock per Game Boy T-cycle. 10,000× would be a tenth of a host clock per T-cycle, which is not an interpreter at all.

There is no technique to copy from SameBoy here, because we are already ahead of it. That is not a boast; it is what closed the question.

What twenty cores do

The unit that matters to a measurement campaign is not ms/frame — it is how many measurements fit in a day.

WorkersFrames/s× real timePer instance
116,590278×0.0602 ms
463,6871,066×0.0624 ms
8126,6332,120×0.0627 ms
12153,4552,569×0.0679 ms
16197,5433,307×0.0727 ms
20223,0733,735×0.0770 ms

Four cores are already past 1,000×. The per-instance figure rises only 28% from one worker to twenty, which is what silicon-limited rather than contention-limited looks like on a box with 8 P-cores and 12 E-cores.

A fan-out figure is wall clock by definition and has no CPU clock to fall back on. Quote the load average or do not quote the number.

Two different 1,000× targets, and they are not the same work

Aggregate 1,000× is already met, on four cores. Single-core 1,000× is about 3× away and is not available by shaving: pricing the per-access bus tick at +21% of a collector's frame, the whole APU at +16.7% and the whole timer at +14%, deleting all three outright — which would not be an emulator any more — is about 1.9×, roughly 550×. Everything else is a few per cent each. It needs a different execution model, not another optimisation pass. When somebody quotes a throughput figure for this emulator, they should say which one they mean.

Two picture engines, and what exactness costs

Not a quality setting and not an appearance setting. On ordinary games the two produce byte-identical framebuffers; the only axis between them is exactness against speed.

The standard engine draws a whole scanline in one step at the end of mode 3, from whatever the registers hold at that instant. The identical engine walks the real hardware pixel pipeline dot by dot — the background fetcher, the eight-pixel FIFO, sprite fetches, the window restart, the SCX & 7 discard. Only the second can represent "the wrong tile source for one dot in the middle of a line", which is why only the second can catch that.

A close-up of the terminal Status panel. The PPU row reads "identical ~2.1x".
The Status panel names the live engine and, for identical, what it is costing right now. P switches between the two without a pause or a reload — both engines share every register and the framebuffer, and identical rebuilds its pipeline at the head of the next scanline.
Reference suite standard identical
Mooneye GB (acceptance + emulator-only)95 / 103103 / 103
Mealybug Tearoom, pixel-exact rows3 / 7929 / 79
Gambatte3,176 / 5,3203,622 / 5,320
GBMicrotest252 / 513339 / 513
AGE3 / 5911 / 59
Mooneye (wilbertpol)62 / 12282 / 122
Scribbltests7 / 139 / 13
Frame cost, Pokémon Blue, picture on, one P-core0.094 ms0.373 ms

The accurate engine costs four times as much per frame and is still only 2.2% of a frame's 16.7 ms budget on a desktop core. That is cheap enough to be the default, and it is. It is not free everywhere: on the PSP frontend the emulator pins standard, because an exact picture nobody can play is worth nothing.

The method, and three ways of getting it wrong

Four things about the benchmark command are load-bearing, and three of them were learned by producing a wrong answer first.

One

Pin it

8 P-cores at 5.3 GHz, 12 E-cores at 4.6 GHz. A fresh process lands on whichever the scheduler picks, so the per-instance figure moves by 15% or more between invocations of the identical binary — even though it is CPU time.

Two

Report the load average

The benchmark reports thread CPU time and prints the wall figure beside it so the gap is visible. Contention still reaches it through frequency scaling: the same binary measured 0.0581 ms/frame at load 0.3 and 0.0909 at load 16 — a 56% spread, on the same code.

Three

Use long rounds

2,000-frame rounds are mostly the frequency governor ramping up after process start, and they reorder the variants between runs — which looks exactly like a real difference between them.

Four

Know which tool you reached for

The obvious "the core stands alone" demo reads 90× rather than 288×, and that is correct: it renders every pixel, hashes the whole framebuffer, round-trips a save state and runs 60 more frames to check it. It is not a throughput benchmark and does not claim to be.

The gaps

Each remaining red row has a named reason, and one of them must stay red forever.

Where these numbers come from. Every throughput figure was produced by the project's own core_bench example on one machine, pinned to a named core class, reporting thread CPU time, with the load average recorded beside it. The three-emulator comparison was run in a single session with every side built in its own release configuration and its rendering and sound off.

The accuracy table is read from the repository's checked-in conformance baselines, which are the expectations themselves, so a count and a claim cannot drift apart. There is no overall percentage accuracy figure here and no comparison against anybody's published marketing number.

The bug that made the exact engine worth its cost has its own write-up: the double-speed bug that cost 2,304 pixels. The rest of the project is on the TerminalGB project page.

← Back to devlog