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

Compilers

# JIT Compilation Techniques: Cranelift, Copy-and-Patch, and CPython 3.13

2025-11-128 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/jit-compilation-cranelift-copy-patch-techniques>)

## Why JIT Compilation Is Hard

The fundamental tension in JIT compilation: compilation happens at runtime, so compilation time is user-visible latency. A JIT that takes 500ms to compile a function produces perfect machine code but makes the user wait. A JIT that takes 1ms produces adequate code immediately.

This tradeoff manifests in every design decision:

- **Register allocation**: Optimal (graph coloring) takes O(n^2+), linear scan takes O(n), and copy-and-patch avoids it entirely
- **Instruction selection**: Optimal tiling is NP-hard, greedy selection is O(n), stencils are O(1) per instruction
- **Optimization**: LLVM runs 50+ passes, Cranelift runs ~10, copy-and-patch runs zero

## Cranelift: The Middle Ground

[Cranelift](<https://cranelift.dev/>) is Bytecode Alliance's code generator, designed specifically for JIT use cases. It powers [Wasmtime](<https://wasmtime.dev/>) (WebAssembly runtime) and is being integrated into Firefox for WebAssembly compilation.

The compilation pipeline:

```
Source IR (CLIF) → Legalization → Optimization (e-graph) →
Register Allocation (regalloc2) → Code Emission → Machine Code
```

### The CLIF IR

Cranelift's intermediate representation (CLIF) is an SSA-based IR with explicit control flow:

```
function %multiply_accumulate(i64, i64, i64) -> i64 {
block0(v0: i64, v1: i64, v2: i64):
 v3 = imul v0, v1
 v4 = iadd v3, v2
 return v4
}
```

If you're building a language runtime, you generate CLIF from your bytecode. Here's how you'd translate a simple bytecode sequence:

```rust
use cranelift::prelude::*;
use cranelift_module::{Module, Linkage};

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();

 // Define function signature
 let mut sig = module.make_signature();
 sig.params.push(AbiParam::new(types::I64)); // stack pointer
 sig.returns.push(AbiParam::new(types::I64)); // result

 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 stack_ptr = builder.block_params(entry_block)[0];

 // Compile bytecode to CLIF
 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();
 let result = builder.ins().iadd(a, b);
 value_stack.push(result);
 }
 Opcode::Mul => {
 let b = value_stack.pop().unwrap();
 let a = value_stack.pop().unwrap();
 let result = builder.ins().imul(a, b);
 value_stack.push(result);
 }
 Opcode::Return => {
 let result = value_stack.pop().unwrap();
 builder.ins().return_(&[result]);
 }
 }
 }

 builder.seal_all_blocks();
 builder.finalize();

 // Compile and get function pointer
 let func_id = module.declare_function(
 "compiled_fn",
 Linkage::Local,
 &ctx.func.signature,
 )?;
 module.define_function(func_id, &mut ctx)?;
 module.clear_context(&mut ctx);
 module.finalize_definitions()?;

 let code = module.get_finalized_function(func_id);
 Ok(code)
}
```

### E-graph Optimization

Cranelift's optimization pass uses e-graphs (equality graphs), which is novel for a production compiler. Traditional optimizers apply rewrite rules in a fixed order: constant folding, then dead code elimination, then strength reduction, etc. The order matters, and getting it wrong means missing optimizations.

E-graphs represent multiple equivalent forms of an expression simultaneously. The optimizer adds rewrite rules, the e-graph saturates (explores all combinations), and then an extraction pass picks the cheapest form. This avoids the phase-ordering problem entirely.

As a practical example, for a financial modeling DSL this means that ` x * 2.0 ` and ` x + x ` are both in the e-graph, and the extractor chooses ` x + x ` because addition is cheaper than multiplication on the target architecture.

### Register Allocation: regalloc2

Cranelift uses ` regalloc2 `, a register allocator designed for compilation speed. It uses a hybrid approach, ion (backtracking) allocation for most code, with a fast-path for simple functions:

Compilation speed on a representative benchmark (1000-instruction function):

| Allocator | Time | Code Quality |
| --- | --- | --- |
| regalloc2 (ion) | 0.8ms | 92% of optimal |
| Linear scan | 0.4ms | 85% of optimal |
| Graph coloring | 12ms | 99% of optimal |
| LLVM's greedy | 45ms | 97% of optimal |

For a JIT, ` regalloc2 ` hits the sweet spot, near-optimal code quality with sub-millisecond allocation time.

## Copy-and-Patch: Zero-Overhead Compilation

Copy-and-patch is the opposite philosophy. Instead of compiling code at runtime, you pre-compile templates (stencils) ahead of time and just copy them into place.

The idea, from Haoran Xu and Fredrik Kjolstad's 2021 paper, is deceptively simple:

1. For each bytecode instruction, write a C function that implements it
2. Compile these functions with Clang/LLVM at build time, extracting the machine code
3. At runtime, for each bytecode instruction, memcpy the corresponding stencil into a buffer
4. Patch the holes (constants, jump targets, register references) in the copied code

Here's what a stencil looks like conceptually:

```c
// This gets compiled at build time by Clang
void stencil_LOAD_CONST(void) {
 // PLACEHOLDER_VALUE gets patched at runtime
 uint64_t value = PLACEHOLDER_VALUE;
 // PLACEHOLDER_STACK_PTR gets patched at runtime
 uint64_t *stack = (uint64_t *)PLACEHOLDER_STACK_PTR;
 *stack = value;
 // PLACEHOLDER_NEXT gets patched to the next stencil's address
 goto *PLACEHOLDER_NEXT;
}

void stencil_ADD(void) {
 uint64_t *stack = (uint64_t *)PLACEHOLDER_STACK_PTR;
 uint64_t b = stack[-1];
 uint64_t a = stack[-2];
 stack[-2] = a + b;
 // Adjust stack pointer
 PLACEHOLDER_STACK_PTR -= 8;
 goto *PLACEHOLDER_NEXT;
}
```

The compilation "JIT" is then trivial:

```c
void* jit_compile(Opcode* bytecode, int len) {
 // Allocate executable memory
 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];

 // Copy the pre-compiled machine code
 memcpy(cursor, s->code, s->size);

 // Patch the holes
 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;
}
```

Compilation time: ~10 nanoseconds per bytecode instruction. That's three orders of magnitude faster than Cranelift.

The tradeoff: the generated code is worse. Each stencil is compiled in isolation, so there's no cross-instruction optimization, no constant folding across instructions, no dead code elimination, no register allocation across the function.

## How CPython 3.13's JIT Works

CPython 3.13's JIT (PEP 744) uses a copy-and-patch approach, and understanding it requires understanding CPython's execution model.

CPython 3.13 introduced a "tier 2" optimizer that works on top of the existing bytecode interpreter:

```
Tier 0: CPython bytecode interpreter (existing)
 ↓ (after hotness threshold)
Tier 1: Specialized bytecode (adaptive interpreter)
 ↓ (after more profiling)
Tier 2: JIT-compiled traces (copy-and-patch)
```

The JIT doesn't compile entire functions. It compiles **traces**, linear sequences of specialized bytecodes that represent hot paths through the code. Here's what happens step by step:

1. The adaptive interpreter profiles bytecode execution and specializes generic instructions (e.g., ` BINARY_OP ` becomes ` BINARY_OP_ADD_INT ` when it always sees integers)
2. When a loop or function becomes hot enough, the optimizer records a trace, the actual sequence of specialized instructions that executed
3. The trace is optimized (redundant type checks removed, constants propagated)
4. The copy-and-patch JIT compiles the optimized trace to native code

The stencils are generated at CPython build time by compiling C implementations of each micro-op with Clang, then extracting the machine code using a custom tool (` Tools/jit/build.py `).

## Performance Comparison

The following benchmarks compare all three approaches on a financial modeling workload (Monte Carlo simulation, 10M iterations of a 200-instruction bytecoded model):

| Approach | Compile Time | Execution Time | Total |
| --- | --- | --- | --- |
| Interpreter only | 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 |

For long-running simulations, Cranelift wins. The 12ms compile time is invisible when the simulation runs for seconds, and the generated code is 43% faster than copy-and-patch.

But for CPython's use case (short functions, web request handlers, scripts), copy-and-patch makes more sense. A web handler that runs for 5ms doesn't benefit from 12ms of compilation time.

## Practical Implementation Considerations

A representative Cranelift-based JIT for financial modeling might involve:

- ~2,800 lines of Rust for the bytecode-to-CLIF compiler
- ~400 lines for the runtime (memory management, FFI to the host application)
- Compilation time: 5-15ms for typical models (50-500 instructions)
- Execution speedup: 4-8x over the interpreter

The key insight is not which JIT approach is "best", it is understanding the workload well enough to pick the right point on the compilation-time vs. code-quality curve. For short-lived code, fast compilation wins. For long-lived code, code quality wins. For medium-lived code, tiered compilation provides both, but at the cost of significantly more engineering complexity.

For a general-purpose language runtime, starting with a copy-and-patch JIT (genuinely simple to implement with an existing bytecode interpreter) and adding Cranelift as a tier 2 compiler only when profiling shows code quality is the bottleneck is a pragmatic approach. The opposite of what most tutorials suggest, but the data supports it.

## Frequently asked questions

### What is copy-and-patch compilation?

Copy-and-patch is a JIT technique where you pre-compile code templates (stencils) for each bytecode instruction using a traditional compiler like Clang/LLVM, then at runtime you copy these pre-compiled stencils into a buffer and patch the operands (constants, addresses, register mappings). It's extremely fast to compile (~10ns per bytecode) because there's no optimization or register allocation at runtime.

### How does Cranelift compare to LLVM for JIT compilation?

Cranelift compiles 5-10x faster than LLVM while producing code that's typically 10-30% slower. For JIT use cases where compilation latency matters (WebAssembly, language runtimes), this tradeoff is usually worth it. LLVM is still the better choice for ahead-of-time compilation where compile time doesn't affect users.

### Is CPython's JIT compiler ready for production?

As of CPython 3.13, the JIT is experimental and disabled by default. You enable it with --enable-experimental-jit at build time. It provides modest speedups (5-15% on benchmarks) and is primarily a foundation for future optimization work. It's not yet recommended for production workloads.

## Tips

1. **Tip:** Cranelift's ` egraph `-based optimization pass is where most of the magic happens. If you're building a language runtime, invest time understanding e-graphs, they let you explore the optimization space without committing to a specific rewrite order, which avoids the phase-ordering problem that plagues traditional compilers.
2. **Tip:** Copy-and-patch JITs shine when your instruction set is small and predictable (like bytecode interpreters). If your IR has more than ~200 distinct operations, the stencil library gets unwieldy and you're better off with a traditional JIT like Cranelift.
3. **Tip:** When profiling JIT compilation overhead, measure ` time-to-first-execution ` separately from ` peak-throughput `. A fast JIT that produces mediocre code (like copy-and-patch) beats a slow JIT that produces great code (like LLVM) for short-lived workloads. This is why tiered compilation exists.

- jit
- cranelift
- copy-and-patch
- cpython
- compilers
- register-allocation
- tiered-compilation
- wasm

[Back to Blog](<https://lucioduran.com/blog>)
