Outils

WATaBoy : compiler du Game Boy en WASM à la volée bat un interpréteur natif

30 juin 2026 Mehdi 06:38
JIT WebAssembly émulateur Game Boy

La compilation JIT vers WebAssembly peut-elle battre du code natif ? C’est la question que pose WATaBoy, un émulateur Game Boy conçu comme projet de fin de licence. La réponse, benchmarks à l’appui, est oui.

JIT WebAssembly et émulation Game Boy : le contexte

Tout part d’une contrainte bien connue des développeurs iOS : Apple interdit la compilation JIT dans les apps tierces. Dolphin, l’émulateur GameCube, ne peut donc pas tourner sur iPhone. La seule exception concerne les navigateurs web, qui utilisent JavaScriptCore pour compiler du JavaScript et du WebAssembly en code machine natif.

L’auteur de WATaBoy a eu l’idée d’exploiter cette exception. Au lieu de générer du code machine directement, pourquoi ne pas générer du bytecode WASM à la volée, et laisser le moteur du navigateur le compiler en natif ? C’est exactement ce que font déjà des projets comme le Jiterpreter et v86, mais personne n’avait encore appliqué cette technique à l’émulation de console ni comparé ses performances à un interpréteur tournant nativement.

Architecture technique de WATaBoy

Génération de bytecode WASM depuis Rust

WATaBoy est écrit en Rust et compile vers wasm32-unknown-unknown. Pour la génération de bytecode à l’exécution, le projet utilise la crate wasm-encoder, qui offre un pattern builder pour émettre des instructions WASM sans manipuler des tableaux d’octets bruts.

Le flux de travail est le suivant :

  • Le JIT recompile chaque instruction Game Boy non branchante pour former un bloc de base
  • Ce bloc devient un module WASM avec une fonction execute_block
  • Le module est transmis à JavaScript via l’ABI C (pointeurs et longueurs de buffer)
  • JavaScript compile, instancie et lie le module dans la table de fonctions indirectes du module principal
  • Le code Rust appelle ensuite la fonction via l’instruction call_indirect

Deux flags LLD sont indispensables : --export-table pour exposer la table de fonctions indirectes, et --growable-table pour permettre son extension à l’exécution.

Le problème de l’architecture Harvard de WASM

WASM est une architecture Harvard, pas von Neumann. On ne peut pas exécuter directement du bytecode généré à l’exécution depuis l’intérieur du runtime. Il faut passer par l’environnement hôte (JavaScript) pour compiler et instancier le nouveau module, puis le lier dynamiquement. C’est le coût de l’indirection, et le projet montre que ce coût est rentabilisé par les gains de la compilation JIT.

Pour appeler call_indirect depuis Rust, WATaBoy recourt à de l’inline WebAssembly via la feature instable asm_experimental_arch, car aucune intrinsèque standard ne couvre cette instruction.

Émulation cycle-accurate malgré le JIT

La Game Boy est un cas particulier : son CPU (le SM83) est loin d’être le goulot d’étranglement principal d’un émulateur moderne. La précision cycle-accurate complique encore les choses. WATaBoy s’appuie sur les techniques décrites par le projet GameRoy :

  • Prédiction des interruptions pour éviter les sorties de bloc JIT inutiles
  • Repli sur l’interpréteur de manière paresseuse
  • Évaluation différée des composants non-CPU accessibles via MMIO

Résultats : le JIT WASM gagne

Le benchmark compare trois configurations sur un MacBook Air M2 sous macOS 26.5 : le JIT-to-Wasm dans le navigateur, l’interpréteur dans le navigateur, et l’interpréteur compilé nativement. Trois ROMs ont servi de base de test : Pokémon Bleu, Zelda Link’s Awakening et Tobu Tobu Girl.

Sur Pokémon Bleu, le JIT-to-Wasm atteint environ 1,2x la vitesse de l’interpréteur natif et 1,5x celle de l’interpréteur tournant dans WASM. C’est notable : on ajoute une couche d’indirection (navigateur + runtime WASM) et on reste quand même plus rapide que le binaire natif.

Côté moteurs JS, Safari se montre le plus rapide sur ce benchmark, ce qui est une bonne nouvelle pour la piste iOS puisque WebKit y est obligatoire.

Limites et perspectives

L’auteur est transparent sur les points de friction actuels :

  • Le PPU (rendu graphique) représente encore l’essentiel du temps d’exécution, car plusieurs interruptions PPU ne font pas encore l’objet d’une prédiction
  • Les instructions branchantes ne sont pas encore recompilées, ce qui force des retours fréquents à l’interpréteur entre blocs
  • L’outillage de génération de bytecode WASM reste artisanal : chaque projet écrit ses propres outils, sans équivalent de DynASM ou Cranelift pour WASM
  • Certaines optimisations bas niveau comme le fastmem de Dolphin sont impossibles dans le sandbox WASM

Audio et support Game Boy Color manquent aussi à l’appel. Le projet est open source sur GitHub et l’auteur prévoit de continuer à pousser les deux approches dans leurs retranchements.

Ce qu’il faut retenir

  • Un JIT ciblant WASM peut surpasser un interpréteur natif, même en ajoutant une couche d’indirection via le navigateur
  • La technique repose sur la génération de bytecode WASM à l’exécution en Rust, la liaison dynamique via JavaScript, et le dispatch par call_indirect
  • Safari obtient les meilleures performances sur ce benchmark, ce qui valide l’approche pour iOS malgré la restriction JIT d’Apple
  • L’outillage de codegen WASM reste le principal frein à l’adoption plus large de cette technique dans l’écosystème émulation
  • WATaBoy est avant tout une preuve de concept solide, pas un émulateur grand public, mais ses résultats ouvrent des perspectives réelles pour l’émulation cross-platform

Si ce type d’exploration à l’intersection de la compilation, de la sécurité des runtimes et des contraintes d’exécution vous intéresse, la suite est sur le blog. N’hésitez pas à échanger sur le sujet.

Sources

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *