Devlog ·
A PlayStation 2 in a browser tab at a steady 50 fps
Retrario runs PlayStation 2 games in a browser tab, aiming at a steady 50 frames per second with no lag you can feel. Where it stands: in a settled match our reference PC is under the line; the first minutes after a new engine build are not yet.
The budget: 20 ms per frame
The game we run, the PlayStation 2 version of PES 2013, is a PAL title, so it draws 50 frames a second and each frame has 20 ms. In that time the emulator runs the main CPU (a MIPS core), the I/O processor, the vector units and the sound processor, and hands a picture and a block of audio to the browser.
The emulator is a fork of Play!, compiled with Emscripten to WebAssembly. It uses pthreads, which in a browser are Web Workers sharing one memory, so the page has to be cross-origin isolated. Three threads do the emulation:
- The VM thread runs the main CPU and the I/O processor. It makes or misses the 20 ms.
- The graphics thread owns VIF1, the VU1 vector unit and the GIF. Before it existed, that pipeline took about 29 % of the VM thread's time in a profile of a heavy match scene. The VM thread now queues the data and waits only at points fixed in the emulated order (register reads, VBlank), so the result never depends on thread timing.
- The sound thread runs the SPU.
Play! translates each block of MIPS code the first time it runs; in our fork the output is WebAssembly. A block becomes a Wasm function, grouped in packs: one WebAssembly.Module of up to 1,024 functions.
Why the first minutes are the hard part
A translated block is cheap once it exists. Making one is not: the engine writes the Wasm bytes, the browser builds and instantiates a module, and V8 compiles each function lazily at its first call. That last step happens on the VM thread, inside a frame, and no counter saw it: frames that installed 300 to 900 cached blocks still had 35 to 58 ms nobody claimed until we traced it to the lazy compile.
Four mechanisms keep that work out of play:
- The compiled-code cache. Translated blocks are saved in IndexedDB under a key derived from the engine binary. The next session starts with its code ready.
- The seed. Each release ships a cache recorded by a scripted run of the game; play-r111's holds 160,129 blocks in 194 MB. A first visit fetches it once.
- The carry-over. A new engine build has a new key, so every player's cache is stale on the first visit after a release. The page hands the old cache's block list to a carry thread that translates it again in the background. In the owner's first session on play-r109 that was 167,396 blocks at about 2,850 blocks a second, done 58 s after the start.
- The warm worker. The compiled code of a module is shared by all its instances, across workers too, so a helper worker calls each function once in a sandbox and pays V8's compile instead of the VM thread. In the first probe a cold call fell from 30.4 ms to 0.7 ms.
The play-r109 session showed the leak. The match began at about 82 s. Carried packs were built on the VM thread at their first use and compiled lazily there, and the warm worker, which only gets a pack once it is built, then held back on its busy rule in the match's first minute: 233 of 343 packs were warm at minute 6.6.
What changed
- Packs built in the idle time. A carried pack is built while the VM thread waits for the next frame, one per frame, when the time left allows it. On the bench, packs built on demand in the first 3,000 gameplay frames went from 125-129 per run to 0.
- The warm worker's pace. Short batches, and no rest while the VM is idle (menus, loads): 0.5-0.7 % of the first 3,000 gameplay frames over the line became 0.2 %.
- Hot lists that survive a new build. The blocks a match used are kept with the cache and carried first. In the owner's play-r110 session, 96.9 % of the listed blocks the match used were built and warmed before their first use.
- An I/O processor mini seed. The carry-over keeps main-CPU blocks only; a 3,142-block, 4.4 MB side file covers the rest. The boot's I/O processor compiles fell from 2,467 to 1.
- A play gate (play-r111): during play the warm worker only warms packs that hold a listed block.
How we measure
The engine writes a record per frame: the VM thread's work, its waits by class, compiles, cache hits, underruns. The headline is the share of gameplay frames whose work went over 20.5 ms, the 20 ms deadline plus a small margin, next to wall-clock stalls over 40 ms and underruns.
Every release passes a chain before staging: the seed run, then a warm A/B in Chrome against the previous release in two blocks of two pairs, each behind an idle rule (load under 12 % before, at most 15 % after, else void and repeated). The gate: the new release's pooled share over the line at most 1 % and at most the old one's plus 0.5 points, its median work p50 at most 12.5 ms and p99 at most 18 ms. A failure stops the chain before any deploy.
Changes aimed at the first minutes have their own bench, which reproduces the first session after a release: the previous build's cache is installed under its old key, the page runs its own migration, and three windows are scored (the boot, the load, the first 3,000 gameplay frames). Four pairs in balanced order; a change is kept only when it is lower in every pair, by at least 20 %, and by more than the noise bound of A/A runs of one build (0.1 point on our Mac). The game's files are copied once into the browser's private storage; the bench keeps one persistent profile and clears only the compiled-code cache, so a run reaches its first frames in 3.5 s instead of 23.1 s.
The numbers
The owner's first session after each of two engine changes, on the reference PC (Intel i5-9300HF, 4 cores and 8 threads, RTX 2060):
| Session | play-r109 | play-r110 |
|---|---|---|
| Minute 1, frames over 20.5 ms | 7.27 % | 1.97 % |
| Minute 2 | 4.00 % | 3.31 % |
| Minute 3 | 1.50 % | 0.63 % |
| Whole session | 1.56 % | 0.83 % |
| Sound underruns | 33 | 17 |
| VM-thread compiles | 4,970 | 2,793 |
The settled match, on the release gate's heavy scene: play-r105 on the reference PC read work p50 12.05 ms and p99 17.0 ms with 0.86 % of frames over the line. On our Mac M4 (a screening machine, never the gate) play-r111 read work p50 6.02 ms and p99 7.86 ms against play-r110's 6.42-6.63 and 8.7-9.1: the play gate gave the VM thread about half a millisecond per frame.
What is still open
- The first minute. The goal is under 1 % of the first 3,000 gameplay frames of such a session on the reference PC; minutes 1 and 2 read 1.97 % and 3.31 %. Next: functions built but not warmed before their first run, and the match's own setup work.
- The tail. In the play-r110 session minutes 6 to 11 read 0.10-0.97 % where play-r109's read 0.23 % or less; the warm worker ran through the whole match at about 1.1 ms per function on that PC. The play gate in play-r111 is the answer we built; the PC's long A/B has not run yet.
- The 0.1 % line, the second step of the target, is not reached in a settled match either.
None of this exists without the Play! emulator and its contributors: the Play! project on GitHub. To see the numbers on your own machine, play in your browser.