Perché LLVM per TypeScript?
Un compilatore ahead-of-time vive in un regime diverso da un JIT. Un JIT compila mentre l'utente aspetta, quindi la latenza di compilazione è il vincolo. Un compilatore AOT come Perry compila una sola volta — sulla macchina dello sviluppatore o in CI — e il binario viene poi eseguito milioni di volte. Questa asimmetria è esattamente dove un ottimizzatore pesante si ripaga da solo.
LLVM porta con sé due decenni di lavoro sul middle-end: vettorizzazione dei cicli, loop-invariant code motion, global value numbering, sparse conditional constant propagation, inlining aggressivo, analisi degli alias. Il compito di Perry è fornire a quella macchineria un IR che possa davvero ottimizzare — ed è qui che entrano in gioco le informazioni di tipo di TypeScript.
La pipeline di lowering
Il codice sorgente viene analizzato con SWC, poi sottoposto a lowering in un IR tipizzato di alto livello (HIR) dove avvengono le decisioni interessanti prima che LLVM veda mai il codice:
- Monomorfizzazione. Le funzioni e le classi generiche vengono specializzate per ogni istanziazione concreta, la stessa strategia usata da Rust e C++.
Stack<number>eStack<string>diventano due funzioni indipendenti e completamente tipizzate — così l'ottimizzatore lavora con tipi concreti invece di un blob di dispatch generico, e i generici non costano nulla a runtime. - Dispatch statico. Dove il tipo del receiver è noto a tempo di compilazione, le chiamate ai metodi compilano in chiamate dirette che LLVM può fare inline, non in ricerche in hash-table.
- Accesso diretto ai campi. I campi degli oggetti si risolvono in indici a tempo di compilazione, quindi una lettura di proprietà è un load a offset fisso — non una ricerca in un dizionario.
NaN-boxing e lowering inline
Dove i valori sono dinamici, Perry usa il NaN-boxing: ogni valore è una parola a 64 bit. I double sono memorizzati direttamente; oggetti, stringhe, booleani, null e undefined sono codificati nei bit pattern inutilizzati di un IEEE 754 quiet NaN. I numeri sono a costo zero — nessun boxing, nessuna allocazione per l'aritmetica.
Il problema è che le operazioni sui valori non numerici richiedono sequenze di bit per scompattare, operare e ricompattare. Se quelle sequenze vivono come chiamate a un runtime compilato separatamente, LLVM vede scatole nere opache e non può ottimizzare attraverso di esse. Perry emette quindi le operazioni calde — caricamenti di proprietà, dispatch di metodi, allocazione di oggetti — come IR LLVM inline che l'ottimizzatore può fondere e semplificare. L'allocazione di oggetti, ad esempio, compila fino a un'allocazione bump inline thread-local:
%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 %slowPerché non Cranelift?
Il primo backend di Perry era Cranelift — il codegen dietro wasmtime, costruito per una compilazione veloce e prevedibile. Era il punto di partenza giusto, e resta una scelta eccellente per i JIT e i runtime sandboxati. Due cose hanno forzato il cambio:
- Il tetto dell'ottimizzatore. Cranelift è deliberatamente un compilatore veloce a singolo livello: “codice decente velocemente,” che è il compromesso giusto per un JIT e quello sbagliato per un compilatore AOT il cui punto di forza è la performance nativa massima.
- arm64_32. L'Apple Watch usa un ABI (istruzioni a 64 bit, puntatori a 32 bit) che Cranelift non supporta. Perché watchOS potesse esistere come target, LLVM era necessario — e mantenere due backend significava due insiemi di bug, test e baseline di performance.
La migrazione non è stata gratuita: la prima release solo-LLVM ha fatto regredire alcuni benchmark fino a 70x perché le operazioni calde inizialmente passavano attraverso chiamate opache a helper del runtime. Il recupero — lowering inline, il bump allocator sopra, confini di inlining migliori — ha portato il backend oltre i numeri di Cranelift, e una volta assestato Perry ha battuto Node.js su ogni benchmark della sua suite, da 1,7x a 24,6x con due pareggi (aprile 2026). Il post-mortem completo vale la lettura: Da Cranelift a LLVM.
Per approfondire
La pagina dei meccanismi interni del compilatore tratta NaN-boxing, monomorfizzazione e dispatch statico più in dettaglio. Sul blog, Ottimizzare tutto ripercorre il lavoro di ottimizzazione release dopo release, e GC generazionale, JSON lazy e benchmark che reggono il controllo spiega come funziona la metodologia di benchmark (RUNS=11, mediana + p95). Per il quadro più ampio, inizia dalla panoramica compilatore nativo per TypeScript.