Artigos aprofundados sobre a tecnologia que molda o que vem a seguir.

Como compiladores JIT tornam linguagens dinâmicas rápidas

Veja como compiladores JIT otimizam Ruby, Python e JavaScript eliminando operações redundantes, com exemplos reais de YJIT e ZJIT.

Uma gem do Ruby entrando em uma máquina brilhante e saindo como um cometa de luz veloz

Ruby tem fama de lento. Python também. JavaScript também — ou pelo menos tinha, até o V8 deixá-lo rápido o suficiente para cargas de trabalho no servidor. A história de como linguagens dinâmicas ficam rápidas é a história da compilação JIT (Just-In-Time), e é uma das áreas mais fascinantes da ciência da computação prática. O capítulo mais recente: o ZJIT do Ruby está removendo carregamentos e armazenamentos redundantes de objetos na representação intermediária, na mesma classe de otimização que tornou o TurboFan do V8 tão eficaz.

Se você já se perguntou por que seu código Ruby roda a uma fração da velocidade de C, ou como o JavaScript ficou rápido o suficiente para rodar o VS Code, a resposta está em entender o que os compiladores JIT realmente fazem — e o que torna otimizar linguagens dinâmicas fundamentalmente mais difícil do que otimizar linguagens estáticas.

O Problema Fundamental das Linguagens Dinâmicas

Quando um compilador C vê a + b, ele conhece os tipos de a e b em tempo de compilação. Se ambos forem inteiros, emite uma única instrução ADD. Se forem floats, emite uma soma de ponto flutuante. A CPU executa essa instrução em um ciclo. Não há ambiguidade, nem decisão em tempo de execução.

Quando um interpretador Ruby vê a + b, ele sabe quase nada. a pode ser um inteiro, um float, uma string, um array ou qualquer objeto que defina um método +. O interpretador precisa: verificar o tipo de a, encontrar o método + para esse tipo, verificar o tipo de b, possivelmente converter tipos, tratar casos especiais (overflow, objetos congelados) e, por fim, executar a operação. Esse único + pode envolver dezenas de instruções, consultas à memória e decisões de desvio.

# What the programmer writes:
def sum(a, b)
a + b
end
# What the interpreter actually has to do (pseudocode):
def sum(a, b)
method = lookup_method(a.class, :+)      # Hash lookup
raise NoMethodError unless method         # Branch
if a.is_a?(Integer) && b.is_a?(Integer)   # Type checks
result = integer_add(a.value, b.value)  # Actual math
if overflow?(result)                     # Overflow check
result = promote_to_bignum(result)    # Bignum promotion
end
elsif a.is_a?(Float) || b.is_a?(Float)
result = float_add(to_float(a), to_float(b))
elsif a.is_a?(String)
result = string_concat(a, b.to_s)
else
result = call_method(method, a, [b])    # Generic dispatch
end
result
end

Esse overhead — verificação de tipo, busca de método, despacho — é o custo da dinamicidade. A flexibilidade de escrever a + b e fazer funcionar para inteiros, floats, strings e objetos customizados é cara em tempo de execução.

Como a Compilação JIT Revida

O trabalho de um compilador JIT é observar o que o programa realmente faz em tempo de execução e gerar código de máquina otimizado com base nessas observações. A ideia-chave: embora o código Ruby poderia operar com qualquer tipo, na prática, cada call site quase sempre recebe os mesmos tipos.

Se sum(a, b) já foi chamado 10.000 vezes e a e b sempre foram inteiros, o JIT pode gerar código de máquina especializado que assume que continuarão sendo inteiros. Ele emite uma única instrução de soma de inteiros com uma “guarda” — uma verificação rápida de tipo que volta para o caminho lento caso a suposição seja violada.

Interpreter execution of sum(a, b) where a and b are Integers:
1. Load a from stack
2. Check if a is an object reference
3. Load a's class pointer
4. Look up :+ in method table (hash lookup)
5. Check method visibility
6. Load b from stack
7. Check if b is an object reference
8. Check if b is compatible type
9. Unbox a's integer value
10. Unbox b's integer value
11. Perform addition
12. Check for overflow
13. Box result as new Integer object
14. Return result
JIT-compiled version (after profiling shows a,b are always Integers):
1. Guard: check a is Integer (bail if not)
2. Guard: check b is Integer (bail if not)
3. ADD instruction
4. Guard: check no overflow (bail if overflow)
5. Return result

São 14 passos reduzidos a 5, em que os passos 1, 2 e 4 são instruções de comparação simples. A computação em si — o ADD — leva um ciclo de CPU. É assim que o JavaScript passou de “lento demais para qualquer coisa séria” para “rápido o suficiente para rodar uma IDE completa.”

A Jornada do JIT no Ruby: do MJIT ao YJIT e ao ZJIT

A história do Ruby com compilação JIT é um estudo de caso de quão difícil esse problema é. O Ruby passou por várias implementações de JIT, cada uma com uma abordagem diferente.

MJIT (Ruby 2.6, 2018) traduzia bytecode do Ruby para código C e depois chamava o GCC ou o Clang para compilá-lo. Isso produzia código bem otimizado, mas com um tempo de aquecimento terrível — compilar C leva segundos, não milissegundos. Quando o código compilado pelo JIT ficava pronto, o programa talvez já tivesse terminado de executar.

YJIT (Ruby 3.1, 2022) foi a contribuição do Shopify, escrito primeiro em C e depois reescrito em Rust. O YJIT usa uma técnica chamada “lazy basic block versioning” — compila o código um bloco básico por vez, apenas quando ele realmente é executado, e cria versões especializadas com base nos tipos que observa. Isso oferece um aquecimento rápido (milissegundos, não segundos) com boa performance de pico. O YJIT normalmente melhora a performance do Ruby em 15-30% em cargas de trabalho reais, como aplicações Rails.

ZJIT é a próxima evolução, e é aqui que as coisas ficam realmente interessantes. O ZJIT introduz uma representação intermediária (IR) — uma representação estruturada do programa entre o bytecode e o código de máquina. Essa IR permite otimizações clássicas de compiladores que a abordagem direta de bytecode para código de máquina do YJIT não conseguia fazer com facilidade.

Eliminando Carregamentos e Armazenamentos Redundantes

A otimização específica que o ZJIT acabou de incorporar — remover carregamentos e armazenamentos redundantes de objetos — parece obscura, mas tem um impacto enorme. Veja por quê.

Objetos Ruby armazenam suas variáveis de instância em uma tabela de propriedades. Toda vez que você lê @name, o interpretador carrega o valor da tabela de propriedades do objeto na memória. Toda vez que você escreve @name = value, ele armazena nessa tabela. Em um método que acessa a mesma variável de instância várias vezes, o interpretador a carrega da memória a cada vez — porque, no caso geral, algo pode ter mudado o valor entre as leituras.

class Rectangle
def area
@width * @height
end
def perimeter
# @width and @height are each loaded TWICE from memory
2 * (@width + @height)
end
def scale(factor)
@width = @width * factor    # Load @width, multiply, store @width
@height = @height * factor  # Load @height, multiply, store @height
self
end
end
# In a tight loop, these redundant loads add up:
rects.each { |r| r.scale(2).perimeter }

Com uma IR, o ZJIT pode realizar eliminação de carregamentos: se @width já foi carregada e nada a modificou desde então, reutiliza o valor de um registrador em vez de carregá-la da memória novamente. Ele também pode realizar eliminação de armazenamentos: se você escreve em @width duas vezes em sequência sem que ninguém a leia entre as escritas, o primeiro armazenamento pode ser eliminado.

Essas otimizações são itens básicos em compiladores de linguagens estáticas — GCC e LLVM fazem isso há décadas. Mas fazer isso em uma linguagem dinâmica é muito mais difícil por causa do aliasing. No Ruby, chamar qualquer método pode, potencialmente, modificar as variáveis de instância de qualquer objeto (via instance_variable_set, method_missing ou trace hooks). O JIT precisa provar que, entre duas leituras de @width, nada poderia tê-la alterado — o que exige analisar o que cada operação intermediária pode fazer.

A Vantagem da IR

O motivo pelo qual o ZJIT introduziu uma IR (e por que o TurboFan do V8, o DFG/FTL do JavaScriptCore e o C2 do HotSpot também usam IRs) é que ela torna essas otimizações combináveis. Uma IR é, essencialmente, um grafo de operações em que otimizações podem ser aplicadas como transformações de grafo.

  • Dobramento de constantes: se ambos os operandos de uma soma são constantes conhecidas, substitui a operação pelo seu resultado. 2 + 3 vira 5 em tempo de compilação.
  • Eliminação de código morto: se o resultado de uma operação nunca é usado, remove-a por completo.
  • Eliminação de subexpressões comuns: se a mesma computação aparece duas vezes, calcula-a uma vez e reutiliza o resultado.
  • Eliminação de carregamentos/armazenamentos: remove operações de memória redundantes, como descrito acima.
  • Análise de escape: se um objeto é criado e nunca sai do método atual, aloca-o na pilha em vez de no heap (ou elimina a alocação por completo).
  • Inlining: substitui uma chamada de método pelo corpo do método, expondo mais oportunidades para as outras otimizações.

Essas otimizações se compõem. Fazer inlining de um método expõe suas operações ao contexto do chamador, o que pode revelar valores constantes, que habilitam o dobramento de constantes, que torna código morto, que é eliminado. Uma única decisão de inlining pode desencadear a remoção de dezenas de operações.

A Rede de Segurança da Desotimização

Tudo o que um compilador JIT faz é especulativo. Ele assume que os tipos não vão mudar, que os métodos não serão redefinidos e que monkey-patching não vai invalidar o código otimizado. Quando essas suposições quebram, o JIT precisa “desotimizar” — descartar o código otimizado e voltar para o interpretador.

A desotimização é uma das partes mais difíceis do design de JIT. O código otimizado pode ter eliminado variáveis locais, reordenado operações ou feito inlining de chamadas profundamente aninhadas. Para voltar ao interpretador, o JIT precisa reconstruir o estado do interpretador — todas as variáveis locais, a pilha de chamadas, o program counter — a partir do que o código otimizado tem disponível. Isso exige manter metadados (chamados de “on-stack replacement” ou mapas OSR) que mapeiam estados do código otimizado de volta para estados do interpretador.

Quando a desotimização acontece com frequência — uma condição chamada “deopt thrashing” — a performance pode ser pior do que a interpretação pura. O JIT gasta tempo compilando código otimizado, executando-o brevemente, desotimizando e repetindo o ciclo. O V8 lida com isso contando as desotimizações e, eventualmente, desistindo de otimizar uma função específica. O YJIT adota uma abordagem mais simples: gera várias versões de cada caminho de código para diferentes combinações de tipos, o que reduz a necessidade de desotimização ao custo de mais código gerado.

Por Que Isso Importa Além do Ruby

A jornada do JIT do Ruby espelha o que está acontecendo em linguagens dinâmicas no geral. O JIT de copy-and-patch do Python (incluído no CPython 3.13) é o primeiro passo rumo a uma compilação JIT de verdade para Python. O LuaJIT é notavelmente rápido há anos graças a um JIT de tracing agressivo. O JIT do PHP 8.0+ usa LLVM para suas otimizações baseadas em IR.

O padrão é consistente: começar com um interpretador, adicionar profiling para entender o comportamento em tempo de execução, compilar os caminhos quentes com especialização de tipos, introduzir uma IR para otimizações clássicas e refinar. Cada linguagem enfrenta os mesmos desafios — despacho dinâmico, objetos mutáveis, eval, monkey-patching — e chega a soluções parecidas.

Para quem usa essas linguagens, a conclusão prática é que a diferença de performance entre linguagens dinâmicas e estáticas está diminuindo. Ela nunca vai desaparecer por completo — as verificações de tipo e as guardas ainda têm custo, e a desotimização é um overhead inerente. Mas um JIT bem otimizado pode ficar a 2-5x do código C equivalente em cargas computacionais, o que é “rápido o suficiente” para a grande maioria das aplicações.

A conclusão mais sutil: escreva código direto. Compiladores JIT otimizam padrões previsíveis. Call sites monomórficos (em que um método sempre recebe os mesmos tipos) otimizam bem. Call sites polimórficos (em que os tipos variam) são mais difíceis. Call sites megamórficos (dezenas de tipos) podem nunca ser otimizados. Código que é simples para um humano entender geralmente também é simples para um JIT otimizar — o que é um bom alinhamento de incentivos.