El problema fundamental que aborda este trabajo es cómo lograr un rendimiento cercano al ensamblador en la emulación de máquinas virtuales de pila, sin incurrir en la complejidad y los riesgos de seguridad del código ensamblador manual. La hipótesis es que las "tail calls" (llamadas de cola) pueden permitir que un compilador optimice las llamadas a funciones de manera que el estado de la VM se mantenga en registros de CPU, evitando el crecimiento de la pila y el overhead de las llamadas a funciones tradicionales. Esto es particularmente relevante en el contexto de intérpretes de bytecodes y máquinas virtuales, donde la ejecución de instrucciones es una secuencia de llamadas a funciones pequeñas y repetitivas. La técnica busca reducir la sobrecarga de despacho de instrucciones y mejorar la predictibilidad de la rama, un desafío clásico en el diseño de VMs de alto rendimiento.
Históricamente, los intérpretes de máquinas virtuales han lidiado con el "overhead de despacho" (dispatch overhead), donde cada instrucción requiere una lógica para determinar la siguiente operación. Las técnicas como el "threaded code" (código enhebrado), popularizadas en los años 70 y 80, distribuyen esta lógica de despacho al final de cada instrucción, permitiendo saltos directos a la siguiente. Este enfoque, si bien eficiente en ensamblador, es propenso a errores y difícil de mantener. La introducción de become en Rust nightly ofrece una vía para lograr un comportamiento similar al "threaded code" con la seguridad y abstracción de un lenguaje de alto nivel.
Arquitectura del Sistema
La arquitectura del sistema se centra en la emulación de la CPU Uxn, una máquina de pila simple con 256 instrucciones, dos pilas de 256 bytes, 64KB de RAM y 256 bytes de memoria de dispositivo. La implementación tradicional en Rust utiliza un bucle principal que lee un opcode y llama a una función op que, a su vez, despacha a implementaciones de instrucciones específicas. Estas implementaciones de instrucciones son inlined en la función op.
La implementación de "tail call" modifica esta estructura. En lugar de un bucle, cada función de instrucción (por ejemplo, inc) recibe el estado completo de la VM (datos de pila, índices, memoria de dispositivo, RAM, program counter) como argumentos. Después de ejecutar su lógica, la función determina el siguiente opcode, incrementa el program counter, y luego usa la palabra clave become para llamar a la función correspondiente al siguiente opcode. Esto asegura que la llamada de función se optimice como un salto (branch) en lugar de una llamada tradicional (branch-and-link), reemplazando el stack frame del llamador con el del llamado y evitando el crecimiento de la pila. El despacho de opcodes se realiza a través de una tabla de funciones (FunctionTable) indexada por el opcode. La reconstrucción y deconstrucción del objeto UxnCore dentro de cada función de instrucción es un patrón clave para mantener el estado en argumentos de función, aunque se utiliza una macro para reducir el boilerplate. La clave es que el compilador de Rust, con become, garantiza que no se asigna espacio persistente en la pila para estas llamadas, logrando un comportamiento similar al "threaded code" de ensamblador.
Flujo de Ejecución de Instrucción con Tail Call
- 1 Función de Instrucción (ej. INC) Recibe el estado completo de la VM como argumentos.
- 2 Ejecutar Lógica Modifica el estado de la VM (ej. incrementa valor en pila).
- 3 Determinar Siguiente Opcode Lee el siguiente byte de RAM en el PC actual.
- 4 Incrementar PC Avanza el Program Counter.
- 5 Despacho con 'become' Usa `become TABLE.0[op as usize](...)` para llamar a la siguiente función de ...
- 6 Compilador (Rust Nightly) Genera un salto (branch) en lugar de una llamada (branch-and-link), reemplaza...
| Capa | Tecnología | Justificación |
|---|---|---|
| compute | Rust (nightly) | Lenguaje de programación principal para la implementación del emulador Uxn, utilizando la característica `become` para optimización de tail calls. vs Rust (estable), Ensamblador ARM64, Ensamblador x86-64, C Característica `become` de Rust nightly; `extern "rust-preserve-none"` para la convención de llamada en x86. |
| compute | Uxn CPU | Arquitectura de máquina virtual de pila emulada, sirviendo como el objetivo de rendimiento. 256 instrucciones, 2 pilas de 256 bytes, 64KB RAM, 256 bytes de memoria de dispositivo. |
| compute | ARM64 Assembly | Implementación de referencia de alto rendimiento para el emulador Uxn, superada por la versión de tail call en Rust. Uso de "threaded code" (token threading) para despacho de instrucciones. |
| compute | x86-64 Assembly | Implementación de referencia de alto rendimiento para el emulador Uxn, que la versión de tail call en Rust aún no supera. Uso de "threaded code" para despacho de instrucciones. |
| compute | WebAssembly (WASM) | Plataforma de ejecución para el emulador, donde la implementación de tail call mostró un rendimiento inferior al esperado. Ejecución en Firefox, Chrome y wasmtime. |
Trade-offs
Ganancias
- ▲ Rendimiento en ARM64
- ▲▲ Seguridad y mantenibilidad del código
- △ Reducción de boilerplate con macros
Costes
- ▲ Rendimiento en x86-64
- ▲▲ Rendimiento en WebAssembly
- ▲ Dependencia de características de Rust nightly
- △ Complejidad de macros para reducción de boilerplate
match core.inc::<FLAGS>(pc) {
Some(pc) => {
let op = core.next(&mut pc);
become TABLE.0[op as usize](
core.stack.data,
core.stack.index,
core.ret.data,
core.ret.index,
core.dev,
core.ram,
pc,
vdev,
)
}
None => (core, pc)
}macro_rules! tail_fn! {
($name:ident $(::<$flags:ident>)?) => {
tail_fn!($name $(::<$flags>)?[][vdev: &mut dyn Device]);
};
($name:ident $(::<$flags:ident>)?($($arg:ident: $ty:ty),*)) => {
tail_fn!($name $(::<$flags>)?[$($arg: $ty),*][]);
};
($name:ident $(::<$flags:ident>)?[$($arg0:ident: $ty0:ty),*][$($arg1:ident: $ty1:ty),*]) => {
extern "rust-preserve-none" fn $name<'a, $(const $flags: u8)?>(
stack_data: &'a mut [u8; 256],
stack_index: u8,
rstack_data: &'a mut [u8; 256],
rstack_index: u8,
dev: &'a mut [u8; 256],
ram: &'a mut [u8; 65536],
pc: u16,
$($arg0: $ty0),*
$($arg1: $ty1),*
) -> (UxnCore<'a>, u16) {
let mut core = UxnCore {
stack: Stack {
data: stack_data,
index: stack_index,
},
ret: Stack {
data: rstack_data,
index: rstack_index,
},
dev,
ram,
};
match core.$name::<$($flags)?>(pc, $($arg0),*) {
Some(mut pc) => {
let op = core.next(&mut pc);
become TABLE.0[op as usize](
core.stack.data,
core.stack.index,
core.ret.data,
core.ret.index,
core.dev,
core.ram,
pc,
$($arg0),*
$($arg1),*
)
}
None => (core, pc),
}
}
};
}Fundamentos Teóricos
El concepto de "tail calls" y su optimización es un pilar en la teoría de compiladores y lenguajes funcionales, remontándose a los trabajos iniciales sobre Lisp y Scheme. La optimización de llamadas de cola (Tail Call Optimization, TCO) fue formalizada y se convirtió en un requisito para implementaciones conformes de Scheme, por ejemplo, en el "Revised^5 Report on the Algorithmic Language Scheme" (R5RS). El principio subyacente es que si la última operación en una función es una llamada a otra función, el stack frame de la función actual ya no es necesario y puede ser reutilizado o reemplazado por el stack frame de la función llamada. Esto previene el desbordamiento de pila en recursiones de cola, transformando la recursión en iteración.
El "threaded code" o código enhebrado, mencionado en el artículo como la técnica de ensamblador que se busca emular, tiene sus raíces en sistemas como Forth y la implementación de máquinas virtuales en los años 70. La idea de almacenar el estado de la VM en registros y saltar directamente a la siguiente instrucción es una forma de "direct threaded code". El artículo también hace referencia al "Massey Meta Machine writeup", que es un ejemplo de cómo estas ideas han sido reinventadas y aplicadas en contextos modernos para construir intérpretes eficientes. La conexión es clara: la palabra clave become en Rust es la materialización de la TCO, permitiendo a los ingenieros aplicar principios de lenguajes funcionales y técnicas de bajo nivel como el "threaded code" en un entorno de lenguaje de sistema moderno y seguro.