La historia de los lenguajes dinámicos rápidos suele contarse a través de los grandes JITs: V8, LuaJIT, PyPy, JavaScriptCore. Pero en 2026 un proyecto personal llamado Zef recordó algo que muchos equipos olvidan cuando arrancan un intérprete dinámico: antes de escribir un compilador just-in-time, hay enormes ganancias esperando en decisiones mundanas como la representación de valores, el modelo de objetos y las cachés inline. El autor de Zef documentó 21 cambios que llevaron a su intérprete AST de ser 35 veces más lento que Python a ubicarse a menos del 40% de distancia de QuickJS, todo sin generar una sola instrucción de máquina en tiempo de ejecución.
El resultado es relevante para cualquiera que en LATAM esté construyendo un lenguaje de scripting embebido, un DSL para una plataforma SaaS, o un motor de reglas para una herramienta de bajo código. Lo que Zef demuestra es que un intérprete "honesto" (AST walker, sin bytecode, sin JIT, sin GC sofisticado) puede competir con los runtimes de referencia si uno entiende bien los cuellos de botella reales. Este artículo disecciona las técnicas que se usaron, las razones por las que funcionan, y cómo traducirlas a tu propio proyecto.
Qué pasó: 16.6x de speedup sin JIT
Zef es un lenguaje dinámico experimental creado por su autor "por diversión". La primera versión era un AST walker clásico: cada nodo del árbol sintáctico tenía un método evaluate() que recorría recursivamente los hijos, y los objetos se almacenaban como std::unordered_map. En el benchmark ScriptBench (Richards, DeltaBlue, N-Body y Splay), esta versión corría 35 veces más lento que CPython 3.10, 79 veces más lento que Lua 5.4.7 y 22 veces más lento que QuickJS-ng 0.14.0.
Después de 21 cambios incrementales, la misma base de código (todavía un AST walker, todavía sin bytecode, todavía sin JIT) logra estos números contra los mismos competidores:
- vs Python 3.10: 2.13x más lento (antes: 35.4x más lento)
- vs Lua 5.4.7: 4.78x más lento (antes: 79.6x más lento)
- vs QuickJS-ng: 1.36x más lento (antes: 22.6x más lento)
Y cuando el intérprete se recompila con un toolchain C++ tradicional (en lugar del runtime seguro Fil-C++ que usa por defecto), las tablas se dan vuelta: Zef pasa a ser 1.89x más rápido que Python, 1.19x más lento que Lua y 2.97x más rápido que QuickJS. El factor total de mejora es de 67x respecto al punto de partida. Ninguna de las técnicas usadas requiere generación de código dinámico.
El AST walker pasa de hashmaps a shapes con inline caches sin tocar el parser.
Contexto e historia: por qué los AST walkers tienen mala fama
Durante décadas la sabiduría convencional fue clara: para un intérprete dinámico serio hay que compilar a bytecode y ejecutar con un loop despachador (switch, computed goto o direct threading). CPython, Ruby MRI y Lua siguen ese camino. Los AST walkers quedaron asociados con prototipos, lenguajes de enseñanza y proyectos académicos. La razón es simple: recorrer árboles objeto por objeto implica saltos indirectos, llamadas virtuales y bajas tasas de hit en la caché de instrucciones.
Sin embargo, la experiencia de Zef cuestiona ese prejuicio. Las optimizaciones que realmente movieron la aguja no tienen que ver con el modelo de ejecución (AST vs bytecode), sino con dos capas más bajas: cómo se representan los valores en memoria y cómo se resuelven las propiedades de los objetos. Un intérprete AST con buena representación de valores y un modelo de objetos tipo "shapes" puede acercarse mucho a un intérprete de bytecode con el modelo de objetos ingenuo.
La técnica central, NaN-boxing, fue popularizada por JavaScriptCore de WebKit alrededor de 2010. La idea explota el hecho de que el estándar IEEE 754 reserva un rango masivo de bit patterns para representar NaN (Not-a-Number). En ese espacio libre caben enteros de 32 bits, punteros y valores especiales (null, undefined, true, false) sin necesidad de boxing ni tipos variantes. Todo cabe en un único uint64_t.
Datos y cifras: el impacto de cada optimización
Las ganancias no son uniformes. Algunos cambios duplicaron la velocidad; otros sumaron apenas 1-3%. Lo interesante es el patrón: tres cambios produjeron el 60% del speedup total, mientras que las 18 optimizaciones restantes aportaron el 40% acumulando mejoras pequeñas.
Los tres cambios de alto impacto fueron:
-
Cambio #6 — Object Model e Inline Caches (6.82x acumulado): reemplazó
std::unordered_mappor un sistema de shapes (también llamadas hidden classes o maps en V8). Cada shape representa una estructura de objeto; los accesos a propiedades se cachean por shape en cada nodo del AST. - Cambio #7 — Arguments (9.05x acumulado): pasó los argumentos de función por registros/pila en lugar de arrays dinámicos.
- Cambio #11 — Hashtable (11.76x acumulado): sustituyó el contenedor estándar por una tabla hash abierta, cache-friendly, con probing lineal.
El resto fue lo que el autor llama common sense optimizations: evitar std::optional donde un tagged value bastaba, inlinar métodos calientes, usar una sqrt rápida, especializar rutas de evaluación para literales de array, mejorar los caminos lentos de toString, y deduplicar código en los operadores de asignación con punto (a.b += c).
💭 Clave: la mayor lección de Zef no es una técnica específica, sino la disciplina de medir. Cada cambio fue evaluado con 30 corridas aleatorias interleaved, y ningún cambio se mergeó sin demostrar mejora en el promedio del benchmark suite.
Cómo funciona NaN-boxing en la práctica
La representación de valores es el primer dominó. Un lenguaje dinámico debe saber, en tiempo de ejecución, si el valor que tiene en las manos es un entero, un double, un puntero a objeto o un booleano. La implementación naif usa un struct con un tag y una unión:
struct Value {
enum Tag { Int, Double, Object, Bool } tag;
union {
int32_t i;
double d;
Object* obj;
bool b;
} data;
};
Esta estructura ocupa 16 bytes en la mayoría de arquitecturas (8 por el tag alineado, 8 por la unión) y requiere dos lecturas de memoria para procesar cualquier valor. NaN-boxing condensa todo en 8 bytes explotando el espacio de NaNs del IEEE 754:
class Value {
uint64_t bits;
public:
static constexpr uint64_t DOUBLE_OFFSET = 0x1000000000000ULL;
bool isDouble() const { return bits >= DOUBLE_OFFSET; }
bool isInt32() const { return bits >= 0x1ULL && bits = 0x100000000ULL && bits (bits); }
Object* asObject() const { return reinterpret_cast(bits); }
};
El beneficio va más allá del tamaño: un chequeo de tipo se vuelve un simple test de rango sobre un entero de 64 bits, algo que la CPU ejecuta en un solo ciclo. Y como los enteros pequeños caben sin boxing, el 80% de las operaciones aritméticas en benchmarks como N-Body evitan por completo el heap.
⚠️ Ojo: NaN-boxing asume que ningún puntero válido tendrá un valor menor a
0x100000000. En sistemas modernos de 64 bits esto es cierto en la práctica, pero no está garantizado por el estándar. Si tu runtime corre en entornos exóticos (wasm32, embedded, sandboxes personalizados) conviene validar la hipótesis o usar bits altos (0xffff000000000000) como tag.
Shapes e inline caches: el otro dominó
Una vez que los valores son baratos, el siguiente cuello de botella es el acceso a propiedades. obj.x se traduce, en un intérprete ingenuo, a un hashmap.lookup("x"). Eso implica calcular un hash, hacer probing, comparar strings. Para un benchmark como Richards, donde hay millones de accesos a campos, el costo es devastador.
Los intérpretes modernos resuelven esto con shapes (también conocidos como hidden classes). Cada objeto apunta a un shape; el shape describe qué propiedades existen y en qué offset dentro del storage. Cuando dos objetos comparten la misma estructura, comparten el mismo shape.
graph LR;
A[obj1: {x, y}] --> S1[Shape A: x=0, y=1];
B[obj2: {x, y}] --> S1;
C[obj3: {x, y, z}] --> S2[Shape B: x=0, y=1, z=2];
S1 -.transition z.-> S2;
Sobre esto se monta la inline cache: en cada nodo del AST que hace obj.prop, guardamos el último shape que vimos y el offset resuelto. Si el próximo objeto tiene el mismo shape, saltamos al offset directamente en O(1) sin tocar el hashmap. Es la misma idea que V8 usa desde hace más de una década, portada a un AST walker.
Las técnicas de Zef son accesibles para cualquier equipo que diseñe un DSL.
Watchpoints y optimizaciones especulativas
Los watchpoints son una idea más sutil. Permiten que el intérprete asuma invariantes globales (por ejemplo: "la función Math.sqrt no ha sido reemplazada", "el prototipo de Array no tiene map sobrescrito") y ejecute rutas rápidas especializadas mientras esos invariantes se mantienen. Si algún día el programa rompe el invariante, el watchpoint se dispara, invalida las rutas rápidas y vuelve al camino lento.
En Zef, los watchpoints se usaron para hacer inline de los operadores aritméticos cuando no hay clases que los sobrecarguen, para acelerar length y sqrt, y para eliminar chequeos redundantes en la llamada a métodos. El patrón es simple: registrar un callback "invalidar esta optimización" y ejecutar la ruta rápida mientras nadie dispare el watchpoint.
Impacto y análisis: qué significa para quien construye un lenguaje
Si estás diseñando un lenguaje dinámico, un DSL, o un motor de scripting embebido, Zef entrega tres lecciones concretas.
Primera: no escribas un JIT en la primera iteración. El tiempo de ingeniería necesario para un JIT competente (tier-up, deoptimización, generación de código, soporte multiplataforma) es enorme, y el retorno es menor que el que obtendrías pintando bien los pilares básicos. Zef demuestra que con decisiones de diseño correctas un AST walker puede vivir cómodamente a 2-3x de un intérprete de bytecode maduro.
Segunda: invertí en medición desde el día uno. ScriptBench es un acto de disciplina: permitió al autor rechazar intuiciones, confirmar ganancias reales y evitar regresiones. Para un proyecto LATAM que arranca un lenguaje, escribir un benchmark suite con 4-5 cargas realistas es tan importante como escribir el parser.
Tercera: la representación de datos domina. NaN-boxing, shapes, hashtables con layout plano, eliminar std::optional en hot paths, pasar argumentos por registros: todas estas son decisiones de representación. El código ejecutivo (bytecode, AST, stack machine, register machine) importa menos de lo que la folklore sugiere.
💡 Tip: si vas a implementar shapes en tu propio intérprete, empezá con un modelo "shape per object property transition" (estilo V8). Es simple, tiene buen comportamiento en la mayoría de workloads, y podés evolucionarlo a estructuras más sofisticadas solo cuando el profiler lo pida.
Qué sigue: del AST walker al bytecode
El autor de Zef menciona que, pese al 16.6x, el intérprete todavía está 35% detrás de QuickJS. Cerrar esa brecha sin un JIT probablemente requiera pasar a bytecode con un despachador de computed goto, porque el overhead de las llamadas recursivas a evaluate() empieza a dominar cuando los nodos internos son tan baratos como lo son después de todas estas optimizaciones.
Más allá de Zef, el fenómeno interesante es que la industria está redescubriendo que los intérpretes "simples y rápidos" son una categoría viable. En 2026 vemos productos como Roc, Koka, Grain, Gleam y compañía explorando ese espacio: lenguajes sin JIT pero con representaciones de valor modernas y object models disciplinados. Para desarrolladores LATAM que quieran construir un lenguaje para su comunidad, el listón de entrada nunca ha sido más bajo.
📖 Resumen en Telegram: Ver resumen
Preguntas frecuentes
¿Qué es un AST walker y por qué se considera lento?
Un AST walker es un intérprete que recorre directamente el árbol sintáctico del programa, llamando a un método evaluate() sobre cada nodo. Se lo considera lento porque implica saltos indirectos (virtual calls) y una mala tasa de hit en la caché de instrucciones. Sin embargo, con representación de valores eficiente y un modelo de objetos basado en shapes, puede alcanzar performance competitiva con intérpretes de bytecode.
¿Qué es NaN-boxing y por qué se usa en lenguajes dinámicos?
NaN-boxing es una técnica que aprovecha el espacio de bit patterns reservados para NaN en el estándar IEEE 754 para codificar múltiples tipos (enteros, punteros, booleanos) dentro de un único valor de 64 bits. Se usa para evitar heap allocations en operaciones numéricas y para que los chequeos de tipo sean tests de rango de un solo ciclo.
¿Qué son las inline caches?
Las inline caches (ICs) son estructuras de datos asociadas a los sitios de acceso a propiedades del código. Guardan el último shape visto y el offset resuelto, permitiendo que el próximo acceso con el mismo shape salte directamente al valor sin consultar el hashmap. Es una de las optimizaciones más rentables en lenguajes dinámicos con tipado estructural.
¿Cuándo conviene implementar un JIT en mi lenguaje?
Generalmente conviene postergar el JIT hasta agotar las optimizaciones de representación e inline caching. Un JIT tiene un costo de ingeniería enorme (tier-up, deoptimización, generación de código, portabilidad multiplataforma). Zef demuestra que sin JIT se puede llegar al orden de magnitud de CPython, Lua y QuickJS con técnicas mucho más simples.
¿Puedo aplicar estas técnicas en un lenguaje escrito en Rust, Go o Zig?
Sí. NaN-boxing, shapes, inline caches y watchpoints son independientes del lenguaje de implementación. En Rust se implementan con union, transmute o crates como bytemuck. En Go y Zig las primitivas también existen. La única restricción real es que necesitas control sobre el layout de memoria y acceso a operaciones bit a bit.
¿Dónde puedo aprender más sobre diseño de intérpretes rápidos?
Recursos clásicos incluyen "Crafting Interpreters" de Robert Nystrom, los blog posts de Filip Pizlo sobre JavaScriptCore, los papers de V8 y la documentación interna de LuaJIT. Para el enfoque práctico documentado en este artículo, leer el código fuente de Zef en GitHub y comparar cada commit con la tabla de speedups es ejercicio altamente recomendable.
Referencias
- zef-lang.dev — How To Make a Fast Dynamic Language Interpreter — Artículo original con la tabla completa de 21 optimizaciones y metodología de benchmarks.
- Wikipedia — NaN (IEEE 754) — Referencia técnica sobre el estándar IEEE 754 y el espacio de bit patterns reservados para NaN.
- Hacker News — Discusión técnica de la comunidad sobre intérpretes dinámicos, optimizaciones y benchmarks.
- ArXiv — Repositorio de papers académicos sobre compiladores, intérpretes y técnicas de optimización de lenguajes.
📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.
Top comments (0)