TypeScript auf LLVM

Wie Perry eine für JIT-Engines konzipierte Sprache zu LLVM-IR absenkt — Monomorphisierung, NaN-Boxing, inline-Lowerings — und warum es Cranelift verlassen hat.

Warum LLVM für TypeScript?

Ein Ahead-of-Time-Compiler lebt in einem anderen Regime als ein JIT. Ein JIT kompiliert, während der Nutzer wartet, also ist die Kompilierlatenz die Einschränkung. Ein AOT-Compiler wie Perry kompiliert einmal — auf der Maschine des Entwicklers oder in CI — und die Binary wird danach millionenfach ausgeführt. Genau in dieser Asymmetrie zahlt sich ein schwergewichtiger Optimierer aus.

LLVM bringt zwei Jahrzehnte Middle-End-Arbeit mit: Schleifenvektorisierung, schleifeninvariante Code-Verschiebung, globale Wertenummerierung, spärliche bedingte Konstantenpropagation, aggressives Inlining, Alias-Analyse. Perrys Aufgabe ist es, dieser Maschinerie eine IR zu liefern, die sie tatsächlich optimieren kann — und genau hier kommt TypeScripts Typinformation ins Spiel.

Die Lowering-Pipeline

Der Quellcode wird mit SWC geparst und dann zu einer typisierten High-Level-IR (HIR) abgesenkt, in der die interessanten Entscheidungen fallen, bevor LLVM den Code überhaupt zu Gesicht bekommt:

  • Monomorphisierung. Generische Funktionen und Klassen werden pro konkreter Instanziierung spezialisiert — dieselbe Strategie, die Rust und C++ verwenden. Stack<number> und Stack<string> werden zu zwei unabhängigen, vollständig typisierten Funktionen — sodass der Optimierer mit konkreten Typen statt mit einem generischen Dispatch-Blob arbeitet und Generics zur Laufzeit nichts kosten.
  • Statischer Dispatch. Wo der Empfängertyp zur Kompilierzeit bekannt ist, werden Methodenaufrufe zu direkten Aufrufen kompiliert, die LLVM inlinen kann, statt zu Hash-Table-Lookups.
  • Direkter Feldzugriff. Objektfelder werden zu Compile-Time-Indizes aufgelöst, sodass ein Property-Read ein Ladevorgang mit festem Offset ist — kein Dictionary-Lookup.

NaN-Boxing und inline-Lowerings

Wo Werte dynamisch sind, nutzt Perry NaN-Boxing: Jeder Wert ist ein 64-Bit-Wort. Doubles werden direkt gespeichert; Objekte, Strings, Booleans, null und undefined werden in die ungenutzten Bitmuster eines IEEE-754-Quiet-NaN kodiert. Zahlen sind kostenlos — kein Boxing, keine Allokation für Arithmetik.

Der Haken ist, dass Operationen auf Nicht-Zahlen-Werten Unpack-Operate-Repack-Bitfolgen benötigen. Wenn diese Folgen als Aufrufe in eine separat kompilierte Laufzeitumgebung existieren, sieht LLVM nur undurchsichtige Blackboxes und kann nicht über sie hinweg optimieren. Deshalb gibt Perry heiße Operationen — Property-Loads, Methoden-Dispatch, Objektallokation — als inline LLVM-IR aus, die der Optimierer verschmelzen und vereinfachen kann. Objektallokation zum Beispiel kompiliert zu einer inline Thread-Local-Bump-Allokation herunter:

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

Warum nicht Cranelift?

Perrys erstes Backend war Cranelift — der Codegen hinter wasmtime, gebaut für schnelle, vorhersagbare Kompilierung. Es war der richtige Ausgangspunkt und bleibt eine hervorragende Wahl für JITs und sandboxed Laufzeitumgebungen. Zwei Dinge erzwangen den Wechsel:

  • Die Optimierer-Decke. Cranelift ist bewusst ein schneller Single-Tier-Compiler: “anständiger Code, schnell”, was der richtige Trade-off für einen JIT und der falsche für einen AOT-Compiler ist, dessen Verkaufsargument Spitzen-Performance auf nativem Code ist.
  • arm64_32. Apple Watch nutzt ein ABI (64-Bit-Instruktionen, 32-Bit-Pointer), das Cranelift nicht unterstützt. Damit watchOS als Ziel existieren konnte, war LLVM erforderlich — und zwei Backends zu pflegen bedeutete zwei Sätze von Bugs, Tests und Performance-Baselines.

Die Migration war nicht kostenlos: Das erste reine LLVM-Release verschlechterte manche Benchmarks um bis zu das 70-Fache, weil heiße Operationen zunächst über undurchsichtige Runtime-Helper-Aufrufe liefen. Die Aufholjagd — inline Lowerings, der obige Bump-Allocator, bessere Inlining-Grenzen — brachte das Backend über die Zahlen von Cranelift hinaus, und als es sich eingependelt hatte, schlug Perry Node.js in jedem Benchmark der eigenen Suite, um das 1,7- bis 24,6-Fache bei zwei Gleichständen (April 2026). Der vollständige Post-Mortem lohnt sich zu lesen: From Cranelift to LLVM.

Tiefer eintauchen

Die Seite zu den Compiler-Interna behandelt NaN-Boxing, Monomorphisierung und statischen Dispatch ausführlicher. Im Blog geht Optimizing Everything Release für Release durch die Optimierungsarbeit, und Gen GC, lazy JSON, and defensible benchmarks erklärt, wie die Benchmark-Methodik funktioniert (RUNS=11, Median + p95). Für das große Ganze starte bei der Übersicht Nativer TypeScript-Compiler.

Sieh dir die Ausgabe selbst an

perry compile main.ts — nativer Maschinencode, keine Engine im Schlepptau.