JIT: cómo V8 convierte JavaScript en código máquina al vuelo
@programacionLo esencial
- Un compilador JIT (just-in-time) traduce bytecode a código máquina nativo mientras el programa ya está corriendo, no antes.
- V8, el motor de Chrome y Node.js, usa dos etapas: Ignition interpreta bytecode y TurboFan recompila las funciones más usadas a código optimizado.
- TurboFan entra en juego cuando una función se ejecuta muchas veces seguidas: V8 la marca como candidata a optimización.
- Si los tipos de datos de una función cambian a mitad de ejecución, V8 hace deoptimization y vuelve al bytecode interpretado.
- El flag node --trace-opt --trace-deopt muestra en la consola cuándo TurboFan optimiza o descarta una función real.
- Las inline caches guardan la forma (shape) de un objeto visto antes para saltarse la búsqueda de propiedades en llamadas repetidas.
- En scripts que corren una sola vez, como una función serverless de arranque frío, el JIT nunca llega a activarse.
Qué es un compilador JIT
Node.js ejecuta el mismo bucle de JavaScript de dos formas distintas en menos de un segundo: primero como bytecode interpretado, después como código máquina nativo. El cambio lo decide V8, el motor que corre detrás de Chrome y Node, y se llama compilación just-in-time (JIT).
Un compilador tradicional (C, Rust, Go) traduce todo el programa a binario antes de ejecutarlo. Un intérprete puro, en cambio, lee y ejecuta cada instrucción una por una, sin generar binario. El JIT combina ambos: arranca interpretando para no perder tiempo compilando código que quizás se ejecute una sola vez, y solo compila a código nativo las partes que se repiten.
Cómo conviven el intérprete y el compilador
En V8, el primer paso lo da Ignition, el intérprete de bytecode. Cuando el motor recibe una función de JavaScript, el parser genera un árbol de sintaxis y luego bytecode, un formato intermedio compacto pensado para ejecutarse rápido sin pasar por una compilación completa.
Ese bytecode ya corre el programa, pero más lento que código máquina nativo. V8 lo acepta a cambio de arrancar casi sin demora: cargar una página web con miles de líneas de JavaScript no puede esperar a que todo se compile primero.
Tiered compilation: Ignition y TurboFan
Mientras Ignition interpreta, V8 cuenta cuántas veces se invoca cada función. A partir de cierto uso repetido, la función pasa a manos de TurboFan, el compilador optimizador de V8, que genera código máquina específico para esa función usando los tipos de datos que observó en las ejecuciones anteriores.
Este esquema de niveles (interpretar primero, compilar después solo lo caliente) se llama tiered compilation y lo usan también SpiderMonkey en Firefox (Baseline e Ion) y JavaScriptCore en Safari (Baseline y DFG). La lógica es la misma: no vale la pena compilar código que se ejecuta una sola vez.
Verlo en acción con node --trace-opt
Con Node.js podés observar cuándo V8 decide optimizar una función real. Guardá este archivo como suma.js:
function sumar(a, b) {
return a + b;
}
for (let i = 0; i < 100000; i++) {
sumar(i, i + 1);
}Corré node --trace-opt --trace-deopt suma.js. En la salida vas a ver líneas como [marking 0x... <JSFunction sumar> for optimized recompilation] cuando TurboFan decide compilar sumar a código nativo, y líneas [deoptimizing] si en algún momento V8 descarta esa versión optimizada porque los argumentos dejaron de ser siempre números.
Inline caches: la otra mitad del truco
Además de compilar código caliente, V8 acelera el acceso a propiedades de objetos con inline caches. La primera vez que el motor ve obj.x, memoriza la forma (shape, también llamada hidden class) del objeto: en qué posición de memoria vive x. En llamadas siguientes con objetos de la misma forma, salta directo a esa posición en vez de recorrer el objeto propiedad por propiedad.
Esto explica por qué crear objetos con la misma estructura desde el inicio (mismos campos, mismo orden) es más rápido que agregar propiedades después de construirlos: cada forma distinta rompe la caché y obliga a V8 a volver a buscar.
Cuándo el JIT no ayuda
El JIT tiene un costo de arranque: compilar a código nativo toma tiempo de CPU que solo se amortiza si la función se ejecuta muchas veces. En una función serverless con arranque frío, que procesa una sola petición y termina, ese código nunca llega a calentarse: corre entero en bytecode interpretado, más lento que si hubiera sido compilado desde el principio.
El otro costo real es la deoptimization. Si una función optimizada para recibir números de pronto recibe un string, V8 descarta el código nativo generado y vuelve al intérprete para no ejecutar instrucciones basadas en tipos incorrectos. Funciones polimórficas (que reciben tipos distintos todo el tiempo) rara vez llegan a beneficiarse de TurboFan.
Conclusión
El JIT no es magia: es una apuesta calculada entre arrancar rápido y correr rápido, resuelta observando en tiempo real qué código se repite. Entender esto cambia cómo se escribe JavaScript para rendimiento: mantener los tipos consistentes y las formas de los objetos estables le da a V8 la información que necesita para optimizar de verdad.