Cómo los JIT aceleran los lenguajes dinámicos
Cómo los compiladores JIT optimizan Ruby, Python y JavaScript eliminando operaciones redundantes, con ejemplos reales de YJIT y ZJIT.

Ruby tiene fama de lento. Python también. JavaScript también, o al menos la tenía, hasta que V8 lo aceleró lo suficiente para cargas de trabajo en el servidor. La historia de cómo se aceleran los lenguajes dinámicos es la historia de la compilación JIT (Just-In-Time), y es una de las áreas más fascinantes de la informática práctica. El último capítulo: ZJIT de Ruby está eliminando cargas y almacenamientos de objetos redundantes a nivel de representación intermedia, la misma clase de optimización que hizo tan efectivo a TurboFan de V8.
Si alguna vez te has preguntado por qué tu código Ruby corre a una fracción de la velocidad de C, o cómo JavaScript llegó a ser lo bastante rápido como para alimentar VS Code, la respuesta está en entender qué hacen realmente los compiladores JIT y qué hace que optimizar lenguajes dinámicos sea fundamentalmente más difícil que optimizar lenguajes estáticos.
El problema fundamental de los lenguajes dinámicos
Cuando un compilador de C ve a + b, conoce los tipos de a y b en tiempo de compilación. Si ambos son enteros, emite una única instrucción ADD. Si son flotantes, emite una suma de punto flotante. La CPU ejecuta esa instrucción en un ciclo. No hay ambigüedad ni toma de decisiones en tiempo de ejecución.
Cuando un intérprete de Ruby ve a + b, sabe casi nada. a podría ser un entero, un flotante, una cadena, un arreglo o cualquier objeto que defina un método +. El intérprete tiene que: comprobar el tipo de a, buscar el método + para ese tipo, comprobar el tipo de b, posiblemente convertir tipos, manejar casos límite (desbordamiento, objetos congelados) y, finalmente, realizar la operación. Ese único + puede implicar decenas de instrucciones, accesos a memoria y decisiones de salto.
# What the programmer writes:
def sum(a, b)
a + b
end
# What the interpreter actually has to do (pseudocode):
def sum(a, b)
method = lookup_method(a.class, :+) # Hash lookup
raise NoMethodError unless method # Branch
if a.is_a?(Integer) && b.is_a?(Integer) # Type checks
result = integer_add(a.value, b.value) # Actual math
if overflow?(result) # Overflow check
result = promote_to_bignum(result) # Bignum promotion
end
elsif a.is_a?(Float) || b.is_a?(Float)
result = float_add(to_float(a), to_float(b))
elsif a.is_a?(String)
result = string_concat(a, b.to_s)
else
result = call_method(method, a, [b]) # Generic dispatch
end
result
end
Este sobrecoste (comprobación de tipos, búsqueda de métodos, despacho) es el impuesto que pagas por la dinamicidad. La flexibilidad de escribir a + b y que funcione para enteros, flotantes, cadenas y objetos personalizados es costosa en tiempo de ejecución.
Cómo la compilación JIT contraataca
El trabajo de un compilador JIT es observar lo que el programa hace realmente en tiempo de ejecución y generar código máquina optimizado a partir de esas observaciones. La idea clave: aunque el código Ruby podría operar con cualquier tipo, en la práctica, cada punto de llamada casi siempre recibe los mismos tipos.
Si sum(a, b) se ha llamado 10.000 veces y a y b siempre han sido enteros, el JIT puede generar código máquina especializado que asume que seguirán siendo enteros. Emite una única suma de enteros con un 'guard', es decir, una comprobación rápida de tipo que vuelve a la ruta lenta si la suposición se viola en algún momento.
Interpreter execution of sum(a, b) where a and b are Integers:
1. Load a from stack
2. Check if a is an object reference
3. Load a's class pointer
4. Look up :+ in method table (hash lookup)
5. Check method visibility
6. Load b from stack
7. Check if b is an object reference
8. Check if b is compatible type
9. Unbox a's integer value
10. Unbox b's integer value
11. Perform addition
12. Check for overflow
13. Box result as new Integer object
14. Return result
JIT-compiled version (after profiling shows a,b are always Integers):
1. Guard: check a is Integer (bail if not)
2. Guard: check b is Integer (bail if not)
3. ADD instruction
4. Guard: check no overflow (bail if overflow)
5. Return result
Es un proceso de 14 pasos reducido a 5, donde los pasos 1, 2 y 4 son instrucciones de comparación simples. El cálculo en sí, el ADD, toma un ciclo de CPU. Así fue como JavaScript pasó de 'demasiado lento para algo serio' a 'lo bastante rápido para ejecutar un IDE completo'.
El camino de JIT en Ruby: de MJIT a YJIT y ZJIT
La historia de Ruby con la compilación JIT es un caso de estudio de lo difícil que es este problema. Ruby ha pasado por varias implementaciones de JIT, cada una con un enfoque distinto.
MJIT (Ruby 2.6, 2018) tradujo el bytecode de Ruby a código C y luego llamó a GCC o Clang para compilarlo. Esto producía código bien optimizado, pero con un tiempo de calentamiento terrible: compilar C tarda segundos, no milisegundos. Para cuando el código compilado por JIT estaba listo, el programa quizá ya había terminado de ejecutarse.
YJIT (Ruby 3.1, 2022) fue la contribución de Shopify, escrita primero en C y reescrita después en Rust. YJIT usa una técnica llamada 'lazy basic block versioning': compila el código un bloque básico a la vez, solo cuando realmente se ejecuta, y crea versiones especializadas según los tipos que observa. Esto da un arranque rápido (milisegundos, no segundos) con un buen rendimiento máximo. YJIT suele mejorar el rendimiento de Ruby entre un 15 y un 30 % en cargas reales como las aplicaciones Rails.
ZJIT es la siguiente evolución, y ahí es donde las cosas se ponen realmente interesantes. ZJIT introduce una representación intermedia (IR): una representación estructurada del programa entre el bytecode y el código máquina. Esta IR permite aplicar optimizaciones clásicas de compiladores que el enfoque directo de YJIT, de bytecode a código máquina, no podía hacer fácilmente.
Eliminación de cargas y almacenamientos redundantes
La optimización concreta que ZJIT acaba de incorporar, eliminar cargas y almacenamientos redundantes de objetos, suena arcana, pero tiene un impacto enorme. Esto es por qué.
Los objetos de Ruby guardan sus variables de instancia en una tabla de propiedades. Cada vez que lees @name, el intérprete carga el valor de la tabla de propiedades del objeto desde memoria. Cada vez que escribes @name = value, almacena en esa tabla. En un método que accede varias veces a la misma variable de instancia, el intérprete la carga de memoria cada vez, porque en el caso general algo podría haber cambiado el valor entre lecturas.
class Rectangle
def area
@width * @height
end
def perimeter
# @width and @height are each loaded TWICE from memory
2 * (@width + @height)
end
def scale(factor)
@width = @width * factor # Load @width, multiply, store @width
@height = @height * factor # Load @height, multiply, store @height
self
end
end
# In a tight loop, these redundant loads add up:
rects.each { |r| r.scale(2).perimeter }
Con una IR, ZJIT puede realizar eliminación de cargas: si @width ya se cargó y nada la ha modificado desde entonces, reutiliza el valor de un registro en lugar de volver a cargarlo de memoria. También puede realizar eliminación de almacenamientos: si escribes dos veces seguidas en @width sin que nadie la lea entre escrituras, la primera escritura puede eliminarse.
Estas optimizaciones son lo mínimo en los compiladores de lenguajes estáticos: GCC y LLVM las llevan décadas aplicando. Pero hacerlo en un lenguaje dinámico es mucho más difícil por el aliasing. En Ruby, llamar a cualquier método podría modificar las variables de instancia de cualquier objeto (mediante instance_variable_set, method_missing o hooks de trazado). El JIT tiene que demostrar que entre dos lecturas de @width nada pudo haberla cambiado, lo que exige analizar qué podría hacer cada operación intermedia.
La ventaja de la IR
La razón por la que ZJIT introdujo una IR (y por la que TurboFan de V8, el DFG/FTL de JavaScriptCore y el C2 de HotSpot también usan IRs) es que estas optimizaciones se vuelven componibles. Una IR es básicamente un grafo de operaciones donde las optimizaciones se aplican como transformaciones de ese grafo.
- Plegado de constantes: si ambos operandos de una suma son constantes conocidas, se sustituye la operación por su resultado.
2 + 3se convierte en5en tiempo de compilación. - Eliminación de código muerto: si el resultado de una operación nunca se usa, se elimina por completo.
- Eliminación de subexpresiones comunes: si el mismo cálculo aparece dos veces, se calcula una sola vez y se reutiliza el resultado.
- Eliminación de cargas y almacenamientos: se eliminan las operaciones de memoria redundantes, tal como se describió antes.
- Análisis de escape: si un objeto se crea y nunca sale del método actual, se reserva en la pila en lugar de en el heap (o se elimina la asignación por completo).
- Inlining: se sustituye una llamada a método por el cuerpo del método, lo que abre más oportunidades para las demás optimizaciones.
Estas optimizaciones se acumulan. Al incluir un método en línea, sus operaciones quedan expuestas al contexto del llamador, lo que puede revelar valores constantes, lo que habilita el plegado de constantes, lo que deja código muerto y, por tanto, eliminable. Una sola decisión de inlining puede desencadenar la eliminación de decenas de operaciones.
La red de seguridad de la desoptimización
Todo lo que hace un compilador JIT es especulativo. Asume que los tipos no cambiarán, que los métodos no se redefinirán y que el monkey-patching no invalidará su código optimizado. Cuando esas suposiciones fallan, el JIT necesita 'desoptimizar': descartar el código optimizado y volver al intérprete.
La desoptimización es una de las partes más difíciles del diseño de un JIT. El código optimizado puede haber eliminado variables locales, reordenado operaciones o incluido en línea llamadas muy anidadas. Para volver al intérprete, el JIT debe reconstruir su estado (todas las variables locales, la pila de llamadas y el contador de programa) a partir de lo que el código optimizado tenga disponible. Esto exige mantener metadatos (llamados 'on-stack replacement' o mapas OSR) que relacionan los estados del código optimizado con los del intérprete.
Cuando la desoptimización ocurre con frecuencia (una situación llamada 'deopt thrashing'), el rendimiento puede ser peor que el de la interpretación pura. El JIT pasa el tiempo compilando código optimizado, ejecutándolo brevemente, desoptimizando y repitiendo el ciclo. V8 lo gestiona contando las desoptimizaciones y, al final, renunciando a optimizar una función concreta. YJIT adopta un enfoque más simple: genera varias versiones de cada ruta de código para distintas combinaciones de tipos, lo que reduce la necesidad de desoptimizar a costa de generar más código.
Por qué esto importa más allá de Ruby
El camino de Ruby refleja lo que ocurre en todos los lenguajes dinámicos. El JIT de copy-and-patch de Python (incluido en CPython 3.13) es el primer paso hacia una verdadera compilación JIT para Python. LuaJIT lleva años siendo notablemente rápido gracias a un JIT de trazado agresivo. El JIT de PHP en PHP 8.0+ usa LLVM para sus optimizaciones basadas en IR.
El patrón es consistente: empezar con un intérprete, añadir perfilado para entender el comportamiento en tiempo de ejecución, compilar los caminos calientes con especialización de tipos, introducir una IR para las optimizaciones clásicas y refinar. Cada lenguaje se enfrenta a los mismos retos (despacho dinámico, objetos mutables, eval, monkey-patching) y llega a soluciones similares.
Para quienes usan estos lenguajes, la conclusión práctica es que la brecha de rendimiento entre lenguajes dinámicos y estáticos se está reduciendo. Nunca desaparecerá del todo: las comprobaciones de tipo y los guards siguen costando algo, y la desoptimización es una sobrecarga inherente. Pero un JIT bien optimizado puede quedarse a 2-5 veces del código C equivalente en cargas computacionales, lo que es 'lo bastante rápido' para la gran mayoría de aplicaciones.
La conclusión más sutil: escribe código directo. Los compiladores JIT optimizan patrones predecibles. Los puntos de llamada monomórficos (donde un método siempre recibe los mismos tipos) se optimizan bien. Los polimórficos (donde los tipos varían) son más difíciles. Los megamórficos (decenas de tipos) quizá nunca se optimicen. El código que es sencillo de entender para una persona suele serlo también para un JIT, lo cual es una agradable alineación de incentivos.


