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>eStack<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:
%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 %slowPor 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.