<!-- Canonical: https://lucioduran.com/es/blog/jit-compilation-cranelift-copy-patch-techniques
Language: es
Content-SHA256: c8d068b2b3f25bead72d33b6a3cb1974b1d65c9ed20ba2961a478c70d01c1a82
-->

Compilers

# Técnicas de Compilación JIT: Cranelift, Copy-and-Patch y CPython 3.13

2025-11-125 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/jit-compilation-cranelift-copy-patch-techniques>)

## 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](<https://cranelift.dev/>) es el generador de código de Bytecode Alliance, diseñado específicamente para casos de uso JIT. Alimenta [Wasmtime](<https://wasmtime.dev/>) 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:

```rust
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:

```c
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.

## Preguntas frecuentes

### ¿Qué es la compilación copy-and-patch?

Copy-and-patch es una técnica JIT donde se precompilan templates de código (stencils) para cada instrucción bytecode usando un compilador tradicional como Clang/LLVM, y luego en runtime se copian estos stencils precompilados a un buffer y se parchean los operandos (constantes, direcciones, mapeos de registros). Es extremadamente rápido de compilar (~10ns por bytecode) porque no hay optimización ni asignación de registros en runtime.

### ¿Cómo se compara Cranelift con LLVM para compilación JIT?

Cranelift compila 5-10x más rápido que LLVM produciendo código típicamente 10-30% más lento. Para case es de uso JIT donde la latencia de compilación importa (WebAssembly, runtimes de lenguajes), este tradeoff generalmente vale la pena. LLVM sigue siendo mejor para compilación ahead-of-time donde el tiempo de compilación no afecta a los usuarios.

### ¿El JIT de CPython está listo para producción?

A CPython 3.13, el JIT es experimental y deshabilitado por defecto. Se habilita con --enable-experimental-jit al compilar. Proporciona mejoras de rendimiento modestas (5-15% en benchmarks) y es primariamente una base para trabajo de optimización futuro. Todavía no se recomienda para cargas de trabajo de producción.

## Tips

1. **Tip:** El pase de optimización basado en ` egraph ` de Cranelift es donde ocurre la mayor parte de la optimización. Al construir un runtime de lenguaje, conviene invertir tiempo en comprender los e-graphs, permiten explorar el espacio de optimización sin comprometerse a un orden de reescritura específico, lo que evita el problema de phase-ordering que afecta a compiladores tradicionales.
2. **Tip:** Los JITs copy-and-patch funcionan mejor cuando el conjunto de instrucciones es reducido y predecible (como intérpretes de bytecode). Si el IR tiene más de ~200 operaciones distintas, la biblioteca de stencils se vuelve inmanejable y conviene un JIT tradicional como Cranelift.
3. **Tip:** Al perfilar el overhead de compilación JIT, medir ` time-to-first-execution ` por separado de ` peak-throughput `. Un JIT rápido que produce código mediocre (como copy-and-patch) supera a un JIT lento que produce código excelente (como LLVM) para cargas de trabajo de vida corta. Por ello existe la compilación por niveles.

- jit
- cranelift
- copy-and-patch
- cpython
- compiladores
- register-allocation
- compilacion-tiered
- wasm

[Volver al Blog](<https://lucioduran.com/es/blog>)
