TypeScript sobre LLVM

Como o Perry rebaixa uma linguagem projetada para motores JIT em LLVM IR — monomorfização, NaN-boxing, lowerings inline — e por que ele deixou o Cranelift.

Por que LLVM para TypeScript?

Um compilador ahead-of-time vive em um regime diferente de um JIT. Um JIT compila enquanto o usuário espera, então a latência de compilação é a restrição. Um compilador AOT como o Perry compila uma vez — na máquina do desenvolvedor ou na CI — e o binário é executado milhões de vezes depois. Essa assimetria é exatamente onde um otimizador pesado se paga.

O LLVM traz duas décadas de trabalho de middle-end: vetorização de loops, loop-invariant code motion, global value numbering, sparse conditional constant propagation, inlining agressivo, análise de alias. O trabalho do Perry é entregar a essa maquinaria um IR que ela realmente consiga otimizar — e é aí que entra a informação de tipos do TypeScript.

O pipeline de lowering

O código-fonte é analisado com SWC, depois rebaixado para um IR tipado de alto nível (HIR) onde as decisões interessantes acontecem antes mesmo do LLVM ver o código:

  • Monomorfização. Funções e classes genéricas são especializadas por instanciação concreta, a mesma estratégia que Rust e C++ usam. Stack<number> e Stack<string> se tornam duas funções independentes e totalmente tipadas — então o otimizador trabalha com tipos concretos em vez de um blob de dispatch genérico, e os generics não custam nada em tempo de execução.
  • Dispatch estático. Quando o tipo do receptor é conhecido em tempo de compilação, as chamadas de método compilam para chamadas diretas que o LLVM pode inlinar, não buscas em hash-table.
  • Acesso direto a campos. Os campos de objeto se resolvem para índices em tempo de compilação, então a leitura de uma propriedade é um load de deslocamento fixo — não uma busca em dicionário.

NaN-boxing e lowerings inline

Onde os valores são dinâmicos, o Perry usa NaN-boxing: todo valor é uma palavra de 64 bits. Doubles são armazenados diretamente; objetos, strings, booleans, null, e undefined são codificados nos padrões de bits não utilizados de um NaN silencioso IEEE 754. Números têm custo zero — sem boxing, sem alocação para aritmética.

O problema é que operações em valores que não são números precisam de sequências de bits de desempacotar-operar-reempacotar. Se essas sequências existirem como chamadas para um runtime compilado separadamente, o LLVM enxerga caixas-pretas opacas e não consegue otimizar através delas. Por isso o Perry emite operações frequentes — leituras de propriedade, dispatch de método, alocação de objetos — como LLVM IR inline que o otimizador pode fundir e simplificar. A alocação de objetos, por exemplo, compila para uma bump allocation inline thread-local:

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

Por que não Cranelift?

O primeiro backend do Perry era o Cranelift — o codegen por trás do wasmtime, construído para compilação rápida e previsível. Foi o ponto de partida certo, e continua sendo uma excelente escolha para JITs e runtimes sandboxed. Duas coisas forçaram a mudança:

  • O teto do otimizador. O Cranelift é deliberadamente um compilador rápido de camada única: “código decente rapidamente”, o que é a troca certa para um JIT e a errada para um compilador AOT cujo diferencial é o desempenho nativo máximo.
  • arm64_32. O Apple Watch usa uma ABI (instruções de 64 bits, ponteiros de 32 bits) que o Cranelift não suporta. Para o watchOS existir como alvo, o LLVM era necessário — e manter dois backends significava dois conjuntos de bugs, testes e baselines de desempenho.

A migração não saiu de graça: o primeiro release apenas-LLVM regrediu alguns benchmarks em até 70x porque as operações frequentes inicialmente passavam por chamadas opacas a helpers do runtime. A recuperação — lowerings inline, o bump allocator acima, melhores limites de inlining — levou o backend a superar os números do Cranelift, e quando as coisas se estabilizaram o Perry venceu o Node.js em todos os benchmarks da sua suíte, de 1,7x a 24,6x com dois empates (abril de 2026). O post-mortem completo vale a leitura: De Cranelift para LLVM.

Aprofundando

A página de detalhes internos do compilador cobre NaN-boxing, monomorfização e dispatch estático em mais detalhes. No blog, Otimizando tudo percorre o trabalho de otimização release por release, e GC geracional, JSON preguiçoso e benchmarks que aguentam escrutínio explica como funciona a metodologia de benchmark (RUNS=11, mediana + p95). Para o panorama geral, comece pela visão geral do compilador nativo de TypeScript.

Veja a saída você mesmo

perry compile main.ts — código de máquina nativo, sem motor anexado.