El problema fundamental que aborda este trabajo es cómo lograr un rendimiento cercano al nativo para aplicaciones CPU-bound, como los emuladores de consolas, en plataformas que restringen la compilación JIT directa a código máquina, como iOS. Históricamente, la emulación de hardware ha dependido fuertemente de la compilación JIT para traducir las instrucciones de la CPU emulada a código nativo de la máquina anfitriona, optimizando así el ciclo de fetch-decode-execute. Sin estas capacidades JIT, los emuladores se ven forzados a usar intérpretes, que son inherentemente más lentos debido a la sobrecarga de decodificación y ejecución de cada instrucción en tiempo real.

La tesis central es que se puede "hacer piggyback" de las capacidades JIT existentes en los motores de JavaScript y WebAssembly de los navegadores web. Al generar bytecode Wasm en tiempo de ejecución y permitir que el motor del navegador lo compile a código nativo, se puede eludir la restricción de JIT directo. Este enfoque no solo abre la puerta a la emulación de alto rendimiento en plataformas restringidas, sino que también ofrece una solución potencialmente multiplataforma, aprovechando la ubicuidad de Wasm. La relevancia actual radica en la creciente necesidad de ejecutar cargas de trabajo complejas en entornos con políticas de seguridad estrictas o arquitecturas heterogéneas, donde la portabilidad y el rendimiento son críticos.

Arquitectura del Sistema

La arquitectura de WATaBoy se divide en dos componentes principales: el emulador de Game Boy y el subsistema JIT-to-Wasm. El emulador implementa la lógica de la CPU (Z80-like), PPU, MMIO y otros componentes de la Game Boy. Para el JIT, en lugar de generar código máquina x86 o ARM directamente, WATaBoy genera módulos WebAssembly en tiempo de ejecución. Estos módulos Wasm representan "bloques básicos" (basic blocks) de instrucciones de la Game Boy, que son secuencias de instrucciones que no contienen saltos condicionales o incondicionales.

El proceso de JIT-to-Wasm implica varias etapas. Primero, el código Rust utiliza la crate wasm-encoder para construir dinámicamente el bytecode Wasm. Esto incluye la definición de secciones de tipos, funciones, exportaciones y código, siguiendo el formato binario de Wasm. Cada bloque básico de Game Boy se traduce a una función Wasm. Segundo, una vez generado el bytecode, se utiliza una interfaz de bajo nivel para comunicarse con el entorno JavaScript (el "embedder"). Esta comunicación se realiza a través del C ABI, pasando punteros y longitudes de buffer en lugar de objetos JavaScript de alto nivel. Tercero, en el lado de JavaScript, la función linkNewModule toma el bytecode Wasm, lo compila e instancia utilizando las APIs WebAssembly.Module y WebAssembly.Instance. Finalmente, la función exportada del nuevo módulo Wasm (por ejemplo, my_add_func o execute_block) se añade a la tabla de funciones indirectas (__indirect_function_table) del módulo Wasm principal. La ejecución de estas funciones JIT-compiladas se realiza mediante la instrucción call_indirect de Wasm, invocando la función por su índice en la tabla. Para permitir la adición dinámica de funciones, la tabla de funciones se configura como "growable" (expandible) durante la compilación del módulo principal de Rust a Wasm.

Flujo de Compilación y Vinculación JIT-to-Wasm

  1. 1 Emulador (Rust) Identifica un bloque básico de instrucciones de Game Boy.
  2. 2 wasm-encoder (Rust) Genera bytecode Wasm para el bloque básico.
  3. 3 Llamada FFI (Rust) Invoca `link_new_module` en JavaScript con el bytecode.
  4. 4 linkNewModule (JS) Lee el bytecode Wasm de la memoria compartida.
  5. 5 WebAssembly.Module (JS) Compila el bytecode Wasm en un nuevo módulo.
  6. 6 WebAssembly.Instance (JS) Instancia el nuevo módulo Wasm.
  7. 7 __indirect_function_table (JS) Añade la función exportada del nuevo módulo a la tabla de funciones indirecta...
  8. 8 Retorno de Índice (JS) Devuelve el índice de la nueva función en la tabla al código Rust.

Flujo de Despacho y Ejecución de Bloque JIT

  1. 1 Emulador (Rust) Obtiene el índice de la función JIT-compilada para el bloque actual.
  2. 2 dispatch (Rust) Utiliza `asm!` con `call_indirect` para invocar la función Wasm.
  3. 3 Motor Wasm (Navegador) Ejecuta el código nativo JIT-compilado del bloque básico.
  4. 4 Retorno (Motor Wasm) Devuelve el resultado al emulador Rust.
CapaTecnologíaJustificación
compute WebAssembly (Wasm) Target de compilación JIT para ejecución de código de Game Boy, aprovechando el JIT del navegador. vs Generación de código máquina nativo (x86/ARM) directamente, Intérprete nativo sin JIT --export-table, --growable-table para la tabla de funciones indirectas.
data-processing Rust Lenguaje de implementación del emulador y del generador de código Wasm. vs C++, Go Nightly Rust para `asm_experimental_arch`, `cdylib` para el tipo de crate.
data-processing wasm-encoder (Rust crate) Biblioteca para la generación programática de bytecode WebAssembly. vs Escritura manual de bytes Wasm, Herramientas de alto nivel como `wasm-bindgen` (rechazadas por ergonomía)
orchestration JavaScript (Embedder) Entorno de ejecución que compila, instancia y vincula dinámicamente los módulos Wasm generados en tiempo de ejecución. vs Un runtime Wasm nativo con soporte JIT-interface (futuro) Uso de `WebAssembly.Module`, `WebAssembly.Instance`, `__indirect_function_table.grow`.

Trade-offs

Ganancias
  • Rendimiento de emulación en plataformas restringidas (ej. iOS)
  • Portabilidad del código JIT-compilado (a cualquier navegador/runtime Wasm)
Costes
  • Complejidad de la generación de código Wasm y la vinculación dinámica
  • Incapacidad para optimizaciones de bajo nivel (ej. hardware fastmem de Dolphin) debido al sandbox de Wasm
  • Dependencia del rendimiento del JIT del navegador
use wasm_encoder::*;

fn make_add_module() -> Vec<u8> {
    let mut module = Module::new();
    let mut types = TypeSection::new();
    let params = vec![ValType::I32, ValType::I32];
    let results = vec![ValType::I32];
    types.ty().function(params, results);
    module.section(&types);

    let mut functions = FunctionSection::new();
    let type_index = 0;
    functions.function(type_index);
    module.section(&functions);

    let mut exports = ExportSection::new();
    exports.export("my_add_func", ExportKind::Func, 0);
    module.section(&exports);

    let mut codes = CodeSection::new();
    let locals = vec![];
    let mut my_add_func = Function::new(locals);
    my_add_func
        .instructions()
        .local_get(0)
        .local_get(1)
        .i32_add()
        .end();
    codes.function(&my_add_func);
    module.section(&codes);

    module.finish()
}
Uso de la crate `wasm-encoder` para construir un módulo Wasm que define una función 'add' con sus secciones de tipos, funciones, exportaciones y código.
#![feature(asm_experimental_arch)]
use std::arch::asm;

fn dispatch(index: i32, left: i32, right: i32) -> i32 {
    let mut result: i32;
    unsafe {
        asm!(
            "local.get {right}",
            "local.get {left}",
            "local.get {index}",
            "call_indirect (i32, i32) -> (i32)",
            "local.set {result}",
            index = in(local) index,
            left = in(local) left,
            right = in(local) right,
            result = lateout(local) result,
        );
    }
    result
}
Uso de `asm!` de Rust para ejecutar la instrucción `call_indirect` de Wasm, permitiendo el despacho a funciones JIT-compiladas.
const linkNewModule = (bufferPtr, bufferLen) => {
    const bytecode = new Uint8Array(
        instance.exports.memory.buffer,
        bufferPtr,
        bufferLen
    );
    const newModule = new WebAssembly.Module(bytecode);
    const newInstance = new WebAssembly.Instance(newModule);
    instance.exports.__indirect_function_table.grow(
        1,
        newInstance.exports.my_add_func
    );
    return instance.exports.__indirect_function_table.length - 1;
}
Función `linkNewModule` en JavaScript que lee bytecode Wasm, lo compila, instancia y añade su función exportada a la tabla de funciones indirectas del módulo principal.

Fundamentos Teóricos

Este trabajo se conecta directamente con los principios fundamentales de la compilación Just-In-Time, un concepto que ha sido objeto de investigación académica desde las primeras implementaciones en lenguajes como Smalltalk y Lisp. La idea de traducir código de alto nivel o intermedio a código máquina en tiempo de ejecución para mejorar el rendimiento se formalizó y popularizó con sistemas como HotSpot de Java. La optimización de bucles y la especialización de código en caliente son conceptos clave que se remontan a trabajos como el de Deutsch y Schiffman (1984) sobre la implementación de Smalltalk-80.

La técnica de "piggybacking" en un JIT existente, como el de un motor JavaScript, tiene paralelos con la investigación en meta-compilación y la construcción de compiladores auto-hospedados. La idea de generar un lenguaje intermedio (en este caso, Wasm) que luego es optimizado por un sistema de ejecución de propósito general, se alinea con los principios de las máquinas virtuales y los entornos de ejecución gestionados. Aunque no se cita un paper específico que prediga esta combinación exacta de Wasm y JIT para emulación, los fundamentos de la compilación dinámica y la optimización de código en tiempo de ejecución son bien establecidos en la literatura de sistemas operativos y lenguajes de programación.