Por Qué la Compilación JIT Es Difícil
La tensión fundamental en compilación JIT: la compilación pasa en runtime, así que el tiempo de compilación es latencia visible para el usuario. Un JIT que tarda 500ms en compilar una función produce código máquina perfecto pero hace esperar al usuario. Uno que tarda 1ms produce código adecuado inmediatamente.
Este tradeoff se manifiesta en cada decisión de diseño:
- Register allocation: Óptimo (graph coloring) tarda O(n^2+), linear scan tarda O(n), y copy-and-patch lo evita completamente
- Instruction selection: Tiling óptimo es NP-hard, selección greedy es O(n), stencils son O(1) por instrucción
- Optimización: LLVM corre 50+ passes, Cranelift corre ~10, copy-and-patch corre cero
Cranelift: el punto intermedio
Cranelift es el generador de código de Bytecode Alliance, diseñado específicamente para casos de uso JIT. Alimenta Wasmtime y se está integrando en Firefox para compilación WebAssembly.
El IR CLIF
function %multiply_accumulate(i64, i64, i64) -> i64 {
block0(v0: i64, v1: i64, v2: i64):
v3 = imul v0, v1
v4 = iadd v3, v2
return v4
}
Si se está armando un runtime de lenguaje, generars CLIF desde tu bytecode:
use cranelift::prelude::*;
fn compile_function(
module: &mut impl Module,
bytecode: &[Opcode],
) -> Result<*const u8, CompileError> {
let mut ctx = module.make_context();
let mut builder_ctx = FunctionBuilderContext::new();
let mut sig = module.make_signature();
sig.params.push(AbiParam::new(types::I64));
sig.returns.push(AbiParam::new(types::I64));
ctx.func.signature = sig;
let mut builder = FunctionBuilder::new(&mut ctx.func, &mut builder_ctx);
let entry_block = builder.create_block();
builder.append_block_params_for_function_params(entry_block);
builder.switch_to_block(entry_block);
let mut value_stack: Vec<Value> = Vec::new();
for op in bytecode {
match op {
Opcode::LoadConst(n) => {
let val = builder.ins().iconst(types::I64, *n as i64);
value_stack.push(val);
}
Opcode::Add => {
let b = value_stack.pop().unwrap();
let a = value_stack.pop().unwrap();
value_stack.push(builder.ins().iadd(a, b));
}
Opcode::Mul => {
let b = value_stack.pop().unwrap();
let a = value_stack.pop().unwrap();
value_stack.push(builder.ins().imul(a, b));
}
Opcode::Return => {
let result = value_stack.pop().unwrap();
builder.ins().return_(&[result]);
}
}
}
builder.seal_all_blocks();
builder.finalize();
let func_id = module.declare_function("compiled_fn", Linkage::Local, &ctx.func.signature)?;
module.define_function(func_id, &mut ctx)?;
module.finalize_definitions()?;
Ok(module.get_finalized_function(func_id))
}
Optimización con E-graphs
El pass de optimización de Cranelift usa e-graphs (equality graphs), que es novedoso para un compilador de producción. Los optimizadores tradicionales aplican reglas de reescritura en un orden fijo. Los e-graphs representan múltiples formas equivalentes de una expresión simultáneamente, evitando el problema de phase-ordering.
Register Allocation: regalloc2
| Allocator | Tiempo | Calidad de Código |
|---|---|---|
| regalloc2 (ion) | 0.8ms | 92% del óptimo |
| Linear scan | 0.4ms | 85% del óptimo |
| Graph coloring | 12ms | 99% del óptimo |
| LLVM greedy | 45ms | 97% del óptimo |
Copy-and-Patch: Compilación con Zero Overhead
Copy-and-patch es la filosofía opuesta. En vez de compilar código en runtime, pre-compilás templates (stencils) ahead of time y simplemente los copiás en su lugar.
La "compilación" JIT es entonces trivial:
void* jit_compile(Opcode* bytecode, int len) {
void* buffer = mmap(NULL, len * MAX_STENCIL_SIZE,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
char* cursor = (char*)buffer;
for (int i = 0; i < len; i++) {
Stencil* s = &stencils[bytecode[i].opcode];
memcpy(cursor, s->code, s->size);
for (int j = 0; j < s->num_holes; j++) {
Hole* h = &s->holes[j];
uint64_t value;
switch (h->kind) {
case HOLE_CONST: value = bytecode[i].operand; break;
case HOLE_NEXT: value = (uint64_t)(cursor + s->size); break;
case HOLE_STACK_PTR: value = (uint64_t)&runtime_stack_ptr; break;
}
memcpy(cursor + h->offset, &value, sizeof(value));
}
cursor += s->size;
}
return buffer;
}
Tiempo de compilación: ~10 nanosegundos por instrucción bytecode. Tres órdenes de magnitud más rápido que Cranelift. El tradeoff: el código generado es peor porque cada stencil se compila en aislamiento.
Cómo Funciona el JIT de CPython 3.13
CPython 3.13 introdujo un optimizador "tier 2" que trabaja sobre el intérprete de bytecode existente:
Tier 0: Intérprete de bytecode CPython (existente)
↓ (después de threshold de hotness)
Tier 1: Bytecode especializado (intérprete adaptivo)
↓ (después de más profiling)
Tier 2: Trazas JIT-compiladas (copy-and-patch)
El JIT no compila funciones enteras. Compila trazas, secuencias lineales de bytecodes especializados que representan caminos calientes a través del código.
Comparación de Performance
Los siguientes benchmarks comparan los tres approaches en un workload de modelado financiero (simulación Monte Carlo, 10M iteraciones de un modelo bytecoded de 200 instrucciones):
| Approach | Tiempo Compilación | Tiempo Ejecución | Total |
|---|---|---|---|
| Solo intérprete | 0ms | 34,200ms | 34,200ms |
| Copy-and-patch | 0.02ms | 8,400ms | 8,400ms |
| Cranelift | 12ms | 4,800ms | 4,812ms |
| LLVM -O2 | 890ms | 3,600ms | 4,490ms |
Para simulaciones de larga duración, Cranelift gana. Los 12ms de compile time son invisibles cuando la simulación corre por segundos, y el código generado es 43% más rápido que copy-and-patch.
Consideraciones Prácticas de Implementación
Un JIT basado en Cranelift representativo para modelado financiero podría involucrar:
- ~2,800 líneas de Rust para el compilador bytecode-a-CLIF
- ~400 líneas para el runtime (memory management, FFI al host)
- Tiempo de compilación: 5-15ms para modelos típicos
- Speedup de ejecución: 4-8x sobre el intérprete
El insight clave no es cuál approach JIT es "mejor", es entender el workload lo suficientemente bien como para elegir el punto correcto en la curva de tiempo-de-compilación vs. calidad-de-código. Para código de vida corta, compilación rápida gana. Para código de vida larga, calidad de código gana. Para código de vida media, compilación tiered da ambos, pero al costo de significativamente más complejidad de ingeniería.
Para un runtime de lenguaje de propósito general, arrancar con un JIT copy-and-patch (genuinamente simple de implementar con un intérprete de bytecode existente) y agregar Cranelift como compilador tier 2 solo cuando el profiling muestre que la calidad de código es el bottleneck es un approach pragmático.