TypeScript su LLVM

Come Perry converte, tramite lowering, un linguaggio progettato per motori JIT in LLVM IR — monomorfizzazione, NaN-boxing, lowering inline — e perché ha abbandonato Cranelift.

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> e Stack<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:

LLVM IR — allocazione bump inline
%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

Perché 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.

Guarda tu stesso l'output

perry compile main.ts — codice macchina nativo, nessun motore collegato.