Optimizing Everything: One Week, 68 Releases, and a 547x JSON Speedup
O último artigo do blog foi publicado com o Perry na v0.5.12. Hoje estamos na v0.5.80. Isso são 68 releases de patch em sete dias, quase inteiramente focadas numa coisa: transformar cada slow path restante num fast path.
A migração para o LLVM na v0.5.0 recuperou a paridade com o Cranelift na v0.5.12. Esse foi o fim de uma história e o início de outra. O LLVM vê tudo agora. A pergunta deixou de ser “porque é que isto está lento?” e passou a ser “porque é que isto ainda não está rápido?” — uma pergunta muito mais tratável.
Este artigo é um passeio pela semana. JSON ganhou um speedup de 547x. mimalloc tornou-se o alocador global. O acesso a propriedades ganhou um inline cache monomórfico. Buffers ganharam slots de ponteiros tipados com metadados noalias. Servidores Fastify e WebSocket deixaram de crashar após um minuto. E os benchmarks moveram-se novamente.
1. JSON: fechando uma lacuna de 547x
Na v0.5.29, o JSON.parse do Perry num array de 20 registos era 547x mais lento que o Node. Na v0.5.46 era 1,3x. Esse número é o maior delta da semana, e vale a pena percorrê-lo porque cada outra otimização neste artigo é uma variação do mesmo tema: não faça trabalho que não precisa de fazer.
O parser original alocava um Vec por propriedade, um Vec de chaves por objeto, e um thread-local protegido por RefCell para o cache de chaves. Copiava cada string. Fazia re-hash de cada nome de campo. Construiía uma shape de objeto totalmente nova para cada registo, mesmo quando todos os 20 registos tinham exatamente os mesmos campos na mesma ordem. O parser do Node lida com isto detetando o padrão e partilhando uma única shape entre todos os registos. O do Perry não.
A correção chegou em quatro passos:
- Interning de chaves via um
PARSE_KEY_CACHEthread-local (v0.5.45). O primeiro registo aloca N strings de chave; os registos 2 a 20 alocam zero. Chaves repetidas resolvem para o mesmo ponteiro, o que as torna utilizáveis como chaves de lookup no cache de shapes sem um strcmp. - Partilha de shapes através do cache de transições (v0.5.45). Objetos construídos por
js_object_set_field_by_namepercorrem o mesmo grafo de transições. Quando o schema se repete, o ponteirokeys_arrayé partilhado, e isso é o que um inline cache polimórfico precisa para acertar. - Parsing de strings zero-copy + construção incremental de objetos (v0.5.46).
parse_string_bytesagora retornaParsedStr::Borrowed(&[u8])quando não há escapes com backslash — que é o caso comum para cada chave e para a maioria dos valores.parse_objectescreve campos diretamente em vez de coletar num Vec primeiro. - Supressão do GC durante o parse (v0.5.60, fecha #59). Fazer parse de um array grande aloca milhares de pequenos objetos num loop apertado. Cada um estava a despoletar a verificação de threshold do GC. Definir uma flag de “parsing em progresso” adia a coleta até o parse retornar — mesmo tamanho efetivo de heap, muito menos branches de bookkeeping.
Depois o stringify. JSON.stringify em arrays homogéneos — a mesma shape, milhões de vezes — estava a fazer iteração completa de propriedades por objeto, o que para um array com shape estável é puro desperdício. Uma correção em cinco passos fechou a maior parte dessa lacuna também:
- v0.5.62: fast paths itoa / ryu para números, verificação de referência circular baseada em profundidade em vez de um HashSet.
- v0.5.63: guarda de
toJSON+ cache de chaves persistente + dispatch inline (os três custos por chamada que se acumulavam). - v0.5.65: template de stringify para shape homogénea + fast path de escape ASCII. Quando cada elemento tem a mesma shape, a estrutura de chave/dois-pontos/vírgula é pré-calculada uma vez.
- v0.5.70, v0.5.72, v0.5.75: cache de shape-template por chamada, fechar a lacuna do GC residual do parse, eliminar o overhead fixo por chamada restante.
- v0.5.79: o caminho de valores pequenos. Números, booleanos e strings curtas passam por um caminho direto que não configura nenhuma da maquinaria de objetos.
O resultado cumulativo: um pipeline de JSON que estava 547x atrás do Node no início da semana está agora aproximadamente 1,3x atrás no parse e competitivo no stringify, em workloads realistas.
2. A história do alocador
O Perry aloca muito. Cada objeto literal, cada array literal, cada concatenação de string, cada closure. O alocador é quente, e durante a maior parte da v0.5 foi o alocador de sistema padrão do Rust mais uma arena thread-local para valores de curta duração.
A v0.5.67 substituiu o alocador global por mimalloc. Esta é uma mudança de uma linha no Cargo.toml que se paga imediatamente em qualquer workload que faça muitas pequenas alocações — que é cada programa TypeScript. A v0.5.66 precedeu-a consolidando todo o estado thread-local de gc_malloc num único acesso TLS por chamada, para que o caminho para o mimalloc fosse o mais barato possível.
A v0.5.68 levou isto mais longe com strings alocadas em arena. Strings de curta duração (resultados intermediários de concat, pedaços de split(), scratch do parser) saltam o alocador global inteiramente e aterram numa bump arena por thread que reseta em fronteiras naturais. Para parsing de JSON isto foi uma vitória de percentagem de dois dígitos por si só.
E as duas otimizações que não alocam nada:
- Substituição escalar de objetos que não escapam (v0.5.17, depois objetos literais na v0.5.76). Se um objeto nunca sai da sua função englobante, não precisa de existir. Os seus campos tornam-se locais simples. O LLVM lida com isto out of the box assim que se deixa de esconder o objeto atrás de uma chamada opaca ao alocador.
- Substituição escalar de arrays que não escapam (v0.5.73). Mesma ideia — se o array não escapa, os seus elementos tornam-se valores SSA e toda a alocação desaparece.
Para o caminho do array literal especificamente, a v0.5.69 adicionou um fast path de tamanho exato (saltar a maquinaria de crescimento de capacidade quando o tamanho é conhecido em tempo de compilação), e a v0.5.74 colocou inline o IR do bump allocator para pequenos array literais para que o LLVM possa ver a alocação, dobrá-la, elevá-la ou eliminá-la. Benchmarks array-heavy moveram-se mais um passo.
Para arredondar, a v0.5.25 corrigiu um bug mais silencioso: gc_malloc não estava a despoletar coleta no seu próprio caminho, então workloads malloc-heavy podiam fazer o heap crescer ilimitadamente antes de qualquer coisa verificar. A v0.5.61 adicionou dimensionamento de passo adaptativo ao threshold, que é o que realmente se quer: verificar de forma barata quando o heap é pequeno, menos frequentemente quando é grande.
3. O acesso a propriedades ganhou um inline cache real
Todos os motores JavaScript modernos têm um inline cache polimórfico (PIC) no acesso a propriedades. Durante a maior parte da série v0.5 do Perry, PropertyGet passava por um lookup em tabela de shapes com um hash thread-local. Isso é bom para código frio. Não é bom quando 95% das leituras de propriedade num dado call site veem a mesma shape, o que é quase sempre.
A v0.5.44 entregou um inline cache monomórfico para PropertyGet. Cada site de PropertyGet recebe uma entrada de cache por callsite: um ponteiro de shape esperada e um offset de campo. O caminho de hit é uma única comparação mais um load indexado. O caminho de miss cai para um helper lento que atualiza o cache.
; Monomorphic IC fast path for obj.foo
%shape_ptr = load ptr, ptr %obj_shape_slot
%expected = load ptr, ptr @ic_expected_12
%hit = icmp eq ptr %shape_ptr, %expected
br i1 %hit, label %ic_hit, label %ic_miss
ic_hit:
%off = load i32, ptr @ic_offset_12
%addr = getelementptr i8, ptr %obj, i32 %off
%val = load i64, ptr %addr
; ... use val
br label %cont
A v0.5.51 adicionou um cache de transições de shape baseado em hash de conteúdo para escritas de propriedades dinâmicas. Dois objetos que crescem os mesmos campos na mesma ordem fazem hash para a mesma transição, então acabam por partilhar a mesma shape — e isso significa que o lado de leitura do PIC realmente acerta.
A v0.5.55 removeu o último acesso TLS do cache de transições. A v0.5.46 corrigiu um bug no miss-handler do PIC onde objetos com >8 campos estavam a ler para além dos slots inline para memória não inicializada (fecha #55). A v0.5.78 adicionou uma guarda para impedir que o PIC do PropertyGet indexasse em receivers não-ponteiro como números brutos — o que podia acontecer em refinamento de tipos excessivamente otimista e era um dos últimos problemas de estabilidade no IC.
Efeito líquido: código property-heavy — que na prática significa a maior parte do TypeScript — é aproximadamente 2-3x mais rápido do que era há uma semana, apenas com o IC sozinho.
4. Inteiros, bitwise, e o padrão | 0
NaN-boxing torna cada número um f64. Programadores TypeScript escrevem x | 0 para forçar semântica de inteiros. O V8 passou quinze anos a tornar isso barato. O Perry passou esta semana a recuperar.
A pilha de mudanças, por ordem:
- v0.5.48:
sdivpara(int / const) | 0. O LLVM dobra parasmulh + asr, que são ~2 ciclos vs ~10 parafdiv. - v0.5.48:
@llvm.assumeem limites de Uint8ArrayGet. Substitui o diamante branch+phi de verificação de limites por um único bloco básico sobre o qual o vetorizador pode raciocinar. - v0.5.49: corrigir operações bitwise com NaN/Infinity para produzir 0 conforme a especificação ToInt32. Correção em primeiro lugar.
- v0.5.50:
toint32_fastque salta a guarda NaN/Inf de 5 instruções quando o valor é conhecido-finito. Maisalwaysinlineem helpers pequenos e deteção de clamp. - v0.5.52: alvo funções de clamp diretamente com intrínsecos
smin/smax. Clamp é o padrão inteiro mais comum depois do incremento. - v0.5.53:
x | 0ex >>> 0num valor conhecido-finito tornam-se um noop — apenasfptosi + sitofp, sem qualquer guarda. - v0.5.56: ops bitwise i32-nativas; índice e valor i32 em Uint8ArrayGet/Set.
- v0.5.58, v0.5.60:
Math.imulbaixa para o multiply i32 nativo em vez do caminho polyfill. A deteção de polyfill reconhece shimsMath.imulescritos pelo utilizador e substitui-os. - v0.5.59: inlining de init de função pura + seeding de inteiro local. A análise de inteiros ao nível da função pode ver para além das fronteiras de chamada quando o callee é pequeno e puro.
- v0.5.37-v0.5.40: fast path de aritmética de inteiros para padrão acumulador. O clássico loop
for (...) acc += f(i)permanece em i32 de ponta a ponta quando os tipos o permitem.
A v0.5.41 é a subtil. Quando o codegen vê uma const K: number[][] = [[...], ...] ao nível de módulo, baixa a coisa toda para uma constante [N x i32] flat em .rodata. K[y][x] torna-se um único getelementptr + load i32. Combinado com a ponte de análise de inteiros na v0.5.43, isto é o que deu ao image_conv (um blur Gaussiano 5×5 sobre um frame RGB 4K) um speedup de 3x numa única release.
5. Buffers e Uint8Array
Workloads binários — crypto, processamento de imagens, parsing, redes — vivem em Buffer e Uint8Array. A v0.5.64 deu-lhes slots de ponteiros tipados mais metadados noalias. Onde um Buffer costumava ser um double NaN-boxed num alloca double, agora é um ponteiro i64 cru num alloca i64, com anotações LLVM a dizer ao otimizador “este ponteiro não faz alias com outros ponteiros no escopo.” Isso desbloqueia reordenação de load/store, vetorização e alocação de registos que o otimizador de outra forma recusaria fazer.
A v0.5.80 fechou a questão final de correção aqui: um contador de alias-scope de buffer ao nível do módulo que estava a ser resetado por função, o que podia em casos raros deixar o LLVM raciocinar através de escopos que não deviam partilhar um ID de escopo. Agora o contador é ao nível do módulo e a história do noalias é hermética.
A v0.5.53 tornou Uint8ArraySet sem branches — um store mascarado em vez de um if/else que escrevia 0 fora dos limites. A v0.5.54 adicionou um indexOf Two-Way para padrões mais longos e um split alocado em arena, que juntos fecharam a maior parte da lacuna no parsing de Buffer com strings pesadas.
6. Strings: ASCII é o fast path
Strings JavaScript são UTF-16, mas a maioria das strings do mundo real (chaves, identificadores, cabeçalhos HTTP, estrutura JSON) são ASCII. A v0.5.71 adicionou um charCodeAt e codePointAt O(1) para strings ASCII — sem scan UTF-16, apenas um load de byte. A v0.5.20 já fazia com que indexOf, slice e charAt ignorassem o scan UTF-16 em ASCII.
Uma nota de correção dentro dessa mesma release: String.length agora retorna unidades de código UTF-16 (especificação ECMAScript) em vez da contagem de bytes. Isso era um bug latente onde "café".length retornava 5 em vez de 4.
7. Os servidores agora realmente mantêm-se de pé
O trabalho menos glamoroso da semana foi também o mais visível para o utilizador: fazer com que servidores longos estilo Node — Fastify, ws, http, net — não crashassem após alguns minutos.
Os crashes partilhavam todos uma causa raiz: o GC não sabia sobre closures de listeners. Quando se escreve wss.on('message', handler), a closure captura variáveis, que vivem como campos dentro de uma célula alocada pelo GC. Se o scanner de roots do GC não sabe que deve visitar essas células, as suas capturas são reclamadas e o próximo evento de mensagem dereferencia memória libertada.
- v0.5.26: root-scan de closures de event listener de
net.Socket(fecha #35). - v0.5.27: estender a
ws,http,events,fastify. - v0.5.28: registar globais ao nível de módulo como roots do GC (fecha #36). Bug de lifetime uma camada acima.
- v0.5.21: segurança de
gc()dentro de handlers de requisição Fastify/WebSocket — a chamada explícita ao GC estava a correr enquanto os handlers de requisição mantinham ponteiros para a arena (fecha #31).
Junto com o trabalho do GC, a v0.5.20 entregou um main event loop — um real, não um placeholder — que mantém servidores WebSocket e baseados em timer vivos em vez de saírem depois da última chamada síncrona retornar (refs #28). Esta foi a única correção de maior impacto para quem quer que tentasse correr o Perry como um servidor HTTP em produção. Fastify agora mantém-se de pé. Servidores WebSocket agora mantêm-se de pé.
A v0.5.19 corrigiu a incompatibilidade da ABI SysV AMD64 para args/returns de JSValue FFI — um problema em Linux onde chamadas FFI nativas podiam corromper argumentos silenciosamente. A v0.5.18 adicionou dispatch nativo para axios (get/post/put/delete/patch), incluindo response.status e response.data. A v0.5.30 corrigiu o dispatch de fastify request.header() e request.headers[], que vinha a retornar undefined para lookups case-insensitive.
8. @perry/postgres: o driver que tornou tudo isto necessário
Muito do trabalho desta semana foi impulsionado por um workload: fazer um driver Postgres totalmente compatível com Node funcionar em Perry-native. O driver tem suporte a TLS, tem um registo de codecs cross-module, suporta cancel/close/notify, e agora faz benchmarks contra pg, postgres.js, e tokio-postgres.
O trabalho de perf do lado do driver foi em paralelo com o do lado do compilador:
- Hoist de codec por coluna e eliminar cópias de Buffer por célula. BigInt(string) para int8 para evitar alocações intermediárias.
- Construtor de Row dinâmico por shape para rows em forma de objeto. Se a sua query sempre retorna as mesmas colunas, o driver constrói um construtor de row especializado em shape na primeira vez e reutiliza-o — o que, em combinação com o PIC do compilador, torna o acesso a campos em rows tão rápido como o acesso a campos em qualquer outro objeto.
- Opt-out
parseTypes: 'minimal'para chamadores que querem strings brutas para int8/numeric/date.
Este é o loop de feedback positivo que o compilador sempre foi destinado a permitir. Um driver real revela gargalos reais. O gargalo recebe um reprodutor de uma linha registado como issue no GitHub. Uma semana de correções de compilador depois, o driver é mais rápido e o compilador é mais rápido para todos os outros também. Esse é o plano todo, comprimido em sete dias.
9. Correções de correção dignas de menção
O trabalho de performance revela problemas de correção da mesma forma que dragar um rio revela carrinhos de supermercado. Uma lista parcial:
- Promise.race estava a ler
.valueem rejeição em vez de.reason, então rejeições eram engolidas silenciosamente (v0.5.13-v0.5.14). - Promise.any agora lança um
AggregateErrorapropriado quando todas as promises de entrada rejeitam. AdicionouPromise.withResolverse corrigiu a ordenação dequeueMicrotask. [..."hello"]agora produz um array de caracteres em vez de um objeto partido (fecha #16).- Aritmética BigInt e coerção
BigInt()(fecha #33). O fast path i64 bigint (v0.5.29) torna o caso comum barato. - Buffer.indexOf / Buffer.includes com um argumento numérico de byte estavam a comparar contra ponteiros de buffer em vez de valores de byte (fecha #56).
- Operações bitwise com NaN/Infinity produzem 0 conforme a especificação ToInt32 (fecha #57).
- Windows x86_64: cinco correções específicas da plataforma —
localtime, descoberta declang, e uma mão-cheia de ajustes de codegen — trouxeram o Windows x86_64 de volta ao verde (v0.5.72).
10. Os números
O benchmark de destaque do último artigo foi factorial a 24,6x mais rápido que o Node. Esse número não mudou. O que se moveu esta semana é tudo ao redor:
Workload
v0.5.12
v0.5.80
Delta
JSON.parse (schema de 20 registos)
547x mais lento que Node
1,3x mais lento que Node
~420x
image_conv (blur 4K 5×5)
1.980ms
457ms
4,3x
Código property-heavy (hit do PIC)
baseline
2-3x
2-3x
Fibonacci(40)
401ms
309ms
1,3x
Uptime do Fastify sob carga
~60s antes do crash
indefinido
∞
A suite completa de 15 benchmarks contra o Node ainda é 14 vitórias e 1 empate — a mesma tabela do último artigo, com números ligeiramente melhores em toda a linha. O movimento real desta semana é em workloads que não estavam nessa suite: JSON, processamento de imagens, servidores de longa duração. Era onde as lacunas viviam, e é isso que foi fechado.
11. O que vem a seguir
O único benchmark que ainda estamos a perseguir é image_conv vs Zig. O Perry está a 457ms; o Zig está a 246ms. Essa lacuna é arquitetónica, não ao nível de pass de otimização, e vive em três lugares:
- Locais de buffer tipados. A maior parte do trabalho de Buffer chegou esta semana, mas parâmetros e locais de função com tipo buffer ainda fazem unbox a cada acesso. A abordagem de slot
i64que usamos para contadores de loop precisa de se estender a buffers. - Divisão de loop interior/borda. O loop de blur faz clamp em cada pixel, incluindo os 99,9% de pixels que não precisam. Dividir em regiões de borda (com clamp) e interior (sem clamp) permite ao LLVM vetorizar o interior com NEON
ld3/st3. - Hash FNV-1a de ABI dupla. O helper de hash é chamado através da ABI NaN-box. Especializá-lo para i64 bruto in/out em hot paths é algumas horas de trabalho que se vão pagar em cada workload hash-heavy.
Esses estão rastreados em PERF_ROADMAP.md. Espere vê-los no próximo ciclo.
Fechando
O padrão desta semana — 68 releases de patch, quase todas de performance, uma lacuna de JSON a ir de 547x para 1,3x — é o que acontece quando se passa para o lado bom da colina da migração para o LLVM. O otimizador é agora um aliado em vez de uma parede, e a maior parte do que resta é trabalho pequeno, específico e mensurável: encontrar um slow path, descobrir porque é que o otimizador não consegue ver através dele, expor a estrutura, medir novamente. Nenhum destes commits é exótico. São apenas aplicados onde são precisos.
Se quiser experimentar qualquer disto:
brew install perryts/perry/perry
perry init my-app && cd my-app
perry compile src/main.ts -o my-app && ./my-app
Código-fonte: github.com/PerryTS/perry — Docs: docs.perryts.com — Changelog: CHANGELOG.md
Issues, reprodutores, e benchmarks que não são rápidos o suficiente: continuem a mandá-los. Este ritmo só funciona porque os relatórios de bugs são específicos o suficiente para se tornarem reprodutores de uma linha. Cada commit neste artigo tem um #N anexado por uma razão.
— Ralph