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>undStack<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:
%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 %slowWarum 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.