Os dados técnicos e as notas de medição são mantidos em inglês para evitar divergências entre traduções.

TypeScript on LLVM

How Perry lowers a language designed for JIT engines into LLVM IR — monomorphization, NaN-boxing, inline lowerings — and why it left Cranelift.

Why LLVM for TypeScript?

An ahead-of-time compiler lives in a different regime than a JIT. A JIT compiles while the user waits, so compile latency is the constraint. An AOT compiler like Perry compiles once — on the developer's machine or in CI — and the binary is executed millions of times afterwards. That asymmetry is exactly where a heavyweight optimizer pays for itself.

LLVM brings two decades of middle-end work: loop vectorization, loop-invariant code motion, global value numbering, sparse conditional constant propagation, aggressive inlining, alias analysis. Perry's job is to hand that machinery IR it can actually optimize — which is where TypeScript's type information comes in.

The lowering pipeline

Source is parsed with SWC, then lowered to a typed high-level IR (HIR) where the interesting decisions happen before LLVM ever sees the code:

  • Monomorphization. Known function and generic call shapes can be specialized for a bounded set of representations. Stack<number> and Stack<string> become can therefore give the optimizer more specific inputs, while unproved or highly polymorphic calls retain dynamic fallbacks.
  • Static dispatch. Where the receiver type is known at compile time, method calls can compile to direct calls that LLVM can inline. Unknown receivers retain runtime method and prototype dispatch.
  • Direct field access. Shape-proved object fields can resolve to fixed offsets. Dynamic property access keeps the runtime lookup path required for JavaScript behavior.

NaN-boxing and inline lowerings

Perry's canonical dynamic value is a 64-bit NaN-boxed word, while representation selection can keep proved values in native forms. Doubles are stored directly; objects, strings, booleans, null, and undefined can be encoded in the unused bit patterns of an IEEE 754 quiet NaN. This can avoid heap boxing for numeric arithmetic, but conversions and dynamic operations can still have runtime cost.

The catch is that operations on non-number values need unpack-operate-repack bit sequences. If those sequences live as calls into a separately-compiled runtime, LLVM sees opaque black boxes and can't optimize across them. So Perry emits hot operations — property loads, method dispatch, object allocation — as inline LLVM IR that the optimizer can fuse and simplify. Object allocation, for example, compiles down to an inline thread-local bump allocation:

LLVM IR — inline bump allocation
%off_ptr = getelementptr i8, ptr %state, i64 8
%offset  = load i64, ptr %off_ptr        ; current bump offset
%new_off = add i64 %offset, 96           ; headers + 8 fields
%sz_ptr  = getelementptr i8, ptr %state, i64 16
%size    = load i64, ptr %sz_ptr         ; block capacity
%fits    = icmp ule i64 %new_off, %size
br i1 %fits, label %fast, label %slow

Why not Cranelift?

Perry's first backend was Cranelift — the codegen behind wasmtime, built for fast, predictable compilation. It was the right starting point, and it remains an excellent choice for JITs and sandboxed runtimes. Two things forced the switch:

  • The optimizer ceiling. Cranelift is deliberately a fast single-tier compiler: “decent code quickly,” which is the right trade for a JIT and the wrong one for an AOT compiler whose selling point is peak native performance.
  • arm64_32. Apple Watch uses an ABI (64-bit instructions, 32-bit pointers) that Cranelift doesn't support. For watchOS to exist as a target, LLVM was required — and maintaining two backends meant two sets of bugs, tests, and performance baselines.

The migration was not free: the first LLVM-only release regressed some benchmarks by up to 70x because hot operations initially went through opaque runtime helper calls. Recovering — inline lowerings, the bump allocator above, better inlining boundaries — took the backend past its earlier baseline. That April 2026 result is historical, not a current universal speed claim. The current public suite includes Perry wins, mixed rows, and losses against Node.js and Bun. The migration post-mortem is worth reading: From Cranelift to LLVM.

Going deeper

The compiler internals page covers NaN-boxing, monomorphization, and static dispatch in more detail. On the blog, Optimizing Everything walks through the optimization work release by release, and Gen GC, lazy JSON, and defensible benchmarks explains how the benchmark methodology works (RUNS=11, median + p95). For the bigger picture, start at the TypeScript native compiler overview.

See the output yourself

perry compile main.ts — native machine code with the Perry runtime and GC statically linked, and no external JavaScript engine by default.