La compilación Just-In-Time (JIT) es una técnica fundamental para optimizar el rendimiento de programas cuya lógica de ejecución se define o refina en tiempo de ejecución. Históricamente, la implementación de compiladores JIT ha sido una tarea compleja, requiriendo un conocimiento profundo de la arquitectura de la CPU y la programación en ensamblador, lo que ha limitado su adopción en sistemas de software de propósito general, como las bases de datos.
El problema fundamental que aborda la compilación JIT es la brecha de rendimiento entre la flexibilidad de la interpretación y la eficiencia del código compilado estáticamente. Al generar código máquina optimizado en tiempo de ejecución, los sistemas pueden aprovechar información dinámica (ej. tipos de datos, patrones de acceso) que no está disponible en tiempo de compilación. Este artículo explora cómo las herramientas modernas, incluyendo la asistencia de IA, pueden reducir significativamente la barrera de entrada para desarrollar compiladores JIT, haciendo que esta técnica sea más accesible y viable para una gama más amplia de aplicaciones, como motores de bases de datos o de expresiones regulares, donde la latencia de compilación es crítica.
Arquitectura del Sistema
El sistema se compone de un motor de expresiones regulares que, en lugar de interpretar un Árbol de Sintaxis Abstracta (AST), lo compila a código máquina ARM64 en tiempo de ejecución. La arquitectura se basa en tres componentes principales: el AST de la expresión regular, un 'Emitter' de código máquina y un cargador de código.
El AST representa la expresión regular con nodos para literales, concatenaciones y repeticiones. El 'Emitter' traduce este AST en una secuencia de instrucciones ARM64 utilizando la técnica de 'copy-and-patch'. Esta técnica emplea 'stencils', que son plantillas de código ensamblador predefinidas para operaciones comunes (ej. comparación de caracteres, saltos condicionales). El 'Emitter' rellena estas plantillas con valores específicos (ej. el byte del carácter a comparar, la dirección de salto) y las concatena para formar el programa completo. Para el backtracking en las repeticiones, se utiliza una pila explícita, gestionada por los registros x1 (top) y x2 (bottom) de ARM64, almacenando la dirección de reanudación y la posición de la cadena. La cadena de entrada se termina con un byte nulo para simplificar las comprobaciones de fin de cadena.
Finalmente, el cargador de código utiliza mmap para asignar un bloque de memoria con permisos de lectura, escritura y ejecución (PROT_READ | PROT_WRITE | PROT_EXEC). El código máquina generado por el 'Emitter' se copia a esta región de memoria. En macOS, se utilizan funciones específicas como pthread_jit_write_protect_np para gestionar la protección de escritura de la memoria JIT y sys_icache_invalidate para invalidar la caché de instrucciones, asegurando que la CPU ejecute el código recién generado. Una vez cargado, el puntero a esta memoria se 'transmuta' a una función Rust invocable, permitiendo la ejecución directa del código compilado.
Flujo de Compilación JIT de Expresión Regular
- 1 AST de Regex Expresión regular parseada en un Árbol de Sintaxis Abstracta (Node::Literal, ...
- 2 Cálculo de Tamaño El Emitter calcula el tamaño total en palabras de las instrucciones ARM64 par...
- 3 Asignación de Memoria mmap asigna un bloque de memoria con permisos R/W/X para el código máquina.
- 4 Generación de Stencils El Emitter recorre el AST, seleccionando y rellenando stencils de código ARM6...
- 5 Copy-and-Patch Los stencils rellenados se concatenan en el buffer de memoria asignado, ajust...
- 6 Protección de Memoria pthread_jit_write_protect_np(0) habilita la escritura, luego (1) la deshabili...
- 7 Invalidación de Caché sys_icache_invalidate asegura que la CPU cargue el nuevo código.
- 8 Transmutación a Función El puntero a la memoria se transmuta a una función Rust (`MatchFn`) invocable.
Flujo de Ejecución de Regex JIT
- 1 Invocación de Función La función JIT compilada (`MatchFn`) es llamada con el puntero de entrada y l...
- 2 Prólogo Inicializa los registros x1 (top de pila) y x2 (bottom de pila).
- 3 Comparación de Carácter Carga un byte de entrada (ldrb), compara con el carácter esperado (cmp), salt...
- 4 Avance de Posición Si coincide, avanza el puntero de entrada (add x0, x0, #1).
- 5 Manejo de Repetición Guarda dirección de reanudación y posición en pila (stp), salta al cuerpo de ...
- 6 Fallback Si falla, comprueba si la pila está vacía (cmp x1, x2). Si no, pop (ldp) y sa...
- 7 Fin de Cadena/Éxito Comprueba fin de cadena (cbnz w9), retorna 1 (éxito) o 0 (fallo).
| Capa | Tecnología | Justificación |
|---|---|---|
| compute | ARM64 Assembly | Lenguaje objetivo para la compilación JIT, proporcionando control directo sobre las instrucciones de la CPU para máxima eficiencia. vs x86-64 Assembly, LLVM IR, WebAssembly |
| orchestration | Rust | Lenguaje de implementación principal para el compilador JIT y el motor de expresiones regulares, elegido por su seguridad de memoria y rendimiento. vs C++, Go, Zig |
| security | mmap (libc) | Asignación de memoria con permisos de ejecución para el código JIT, esencial para cargar y ejecutar dinámicamente el código máquina. vs VirtualAlloc (Windows), posix_memalign PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANON | MAP_JIT (macOS específico) |
| security | pthread_jit_write_protect_np (macOS) | Manejo de la protección de escritura en regiones de memoria JIT, necesario para cumplir con las políticas de seguridad W^X de Apple. vs mprotect (Linux/BSD), VirtualProtect (Windows) |
| cache | sys_icache_invalidate (macOS) | Invalidación de la caché de instrucciones de la CPU para asegurar que el procesador ejecute el código recién modificado en la memoria JIT. vs __builtin___clear_cache (GCC/Clang), FlushInstructionCache (Windows) |
Trade-offs
Ganancias
- ▲▲ Rendimiento de ejecución
- ▲ Flexibilidad (compilación en runtime)
Costes
- ▲ Complejidad de implementación
- ▲ Portabilidad (dependencia de arquitectura y OS)
- △ Tiempo de compilación JIT (aunque bajo en este caso)
enum Node {
Literal(&'static str),
Concatenation(Box<Node>, Box<Node>),
Repetition(Box<Node>),
}fn stencil_char(byte: u8, stencil_pos: usize, fail_pos: usize) -> [u32; CHAR_WORDS] {
[
0x39400009, // ldrb w9, [x0]
0x7100013F | ((byte as u32) << 10), // cmp w9, #byte
0x54000001 | cond_branch_offset(stencil_pos + 2, fail_pos), // b.ne fail
0x91000400, // add x0, x0, #1
]
}unsafe {
let buf = libc::mmap(
std::ptr::null_mut(),
nbytes,
libc::PROT_READ | libc::PROT_WRITE | libc::PROT_EXEC,
libc::MAP_PRIVATE | libc::MAP_ANON | libc::MAP_JIT,
-1,
0,
) as *mut u32;
// ... copy code ...
let matcher: MatchFn = std::mem::transmute(self.buf);
matcher(nul_terminated.as_ptr(), self.bstack.as_mut_ptr()) != 0
}Fundamentos Teóricos
El concepto de compilación JIT tiene raíces profundas en la investigación de lenguajes de programación y sistemas operativos. La idea de generar código en tiempo de ejecución para optimizar el rendimiento se remonta a los primeros sistemas Lisp y Smalltalk. La técnica de 'copy-and-patch' utilizada en este artículo es una forma simplificada de generación de código, donde fragmentos de código precompilados (stencils) se ensamblan y modifican dinámicamente. Esto se relaciona con los principios de 'template-based code generation' y 'dynamic code generation' explorados en la literatura de compiladores.
Aunque el artículo no cita un paper específico, el manejo de expresiones regulares mediante autómatas finitos (deterministas o no deterministas) es un campo clásico de la informática teórica, formalizado por Kleene en los años 50. La compilación de expresiones regulares a código máquina es una aplicación directa de la transformación de un autómata a un programa imperativo, donde cada estado y transición se mapea a instrucciones de CPU. El uso de una pila para el backtracking en el manejo de repeticiones es una técnica común en la implementación de motores de expresiones regulares que emulan un autómata finito no determinista (NFA), similar a los algoritmos descritos por Thompson (1968) para la construcción de NFAs a partir de expresiones regulares.