Como um motor JavaScript fica rápido?
Profiling, shapes, inline caches e JIT
Nosso motor já entende JavaScript, executa bytecode e mantém uma página viva. Mas ele ainda repete o caminho mais genérico mesmo quando uma função recebe objetos parecidos milhares de vezes. Neste capítulo construiremos o Janela JIT 0.5: um motor que observa o programa, encontra trabalho repetido, cria atalhos seguros e sabe abandoná-los quando suas suposições deixam de valer.

VOCABULÁRIO VISUAL
Antes de avançar, conheça as peças deste capítulo.
No artigo “Como o navegador entende e executa JavaScript”, construímos lexer, parser, bytecode e uma máquina virtual. No artigo “Como o runtime mantém uma página viva”, acrescentamos memória, tarefas, Promises e event loop. Agora observaremos uma função simples:
function somaPonto(ponto) {
return ponto.x + ponto.y;
}
for (let i = 0; i < 100_000; i++) {
somaPonto({ x: i, y: 2 });
}
A primeira chamada precisa descobrir o que é ponto, localizar x, localizar y, conferir os tipos e somar. A centésima milésima recebe objetos criados do mesmo jeito. Um motor rápido aproveita esse padrão sem alterar o significado do programa.
V8 é um motor JavaScript de código aberto usado no Chrome e em outros ambientes. Ele é uma implementação da linguagem, não a própria linguagem. Outros motores podem executar o mesmo programa usando arquiteturas internas diferentes.
“Nossa V8” é uma expressão didática. Não copiaremos milhões de linhas nem prometeremos desempenho de produção. Isolaremos ideias centrais de motores modernos para enxergar por que elas existem e como cooperam.
Um interpretador percorre instruções e executa uma rotina para cada uma. O bytecode é o formato compacto que criamos no artigo “Como o navegador entende e executa JavaScript”. Para ponto.x + ponto.y, usaremos:
LoadArgument
LoadField x
LoadArgument
LoadField y
Add
Return
Essa abordagem começa rápido porque não precisa preparar código nativo antes da primeira execução. O custo aparece quando a mesma sequência genérica é repetida muitas vezes.
Código nativo é composto por instruções da arquitetura real, como x86-64 ou ARM64. Já seguimos esse caminho em Do LLVM ao executável. Aqui não emitiremos bytes reais: criaremos uma representação Rust especializada que possui a mesma decisão importante — remover trabalho genérico quando as condições conhecidas continuam verdadeiras.
| Forma | Quem executa | Vantagem didática |
|---|---|---|
| JavaScript | motor | legível pelo autor |
| Bytecode | máquina virtual | compacto e portátil |
| Caminho especializado | nosso JIT em Rust | menos decisões repetidas |
| Código nativo real | CPU | máximo aproveitamento do hardware |
JIT, abreviação de just-in-time compiler, é um compilador que trabalha enquanto o programa está rodando. Em vez de compilar tudo antecipadamente, ele pode esperar para descobrir quais partes merecem investimento.
IR é a forma intermediária usada pelo compilador
IR vem de intermediate representation, ou representação intermediária. Ela fica entre o bytecode de entrada e o código especializado de saída. Em vez de otimizar diretamente uma sequência de bytes, o compilador monta operações e relações mais convenientes para análise.
Bytecode: LoadField x · LoadField y · Add
IR: GuardShape S2 · LoadSlot 0 · LoadSlot 1 · AddNumber
Saída: caminho especializado protegido
No Janela JIT, OptimizedFunction é uma IR mínima: registra o shape esperado e os slots já resolvidos. Não emitimos instruções executáveis reais; conservamos a decisão importante e observável do JIT.
O JIT precisa analisar instruções, escolher especializações e guardar o resultado. Se uma função roda uma única vez, esse esforço talvez custe mais do que interpretar. Por isso um motor não deveria otimizar cegamente tudo.
Gastamos um pouco de tempo agora para economizar mais tempo depois. A otimização só compensa quando o trecho continua sendo usado.
Profiling é a coleta de medidas sobre a execução: quantidade de chamadas, tipos vistos, caminhos percorridos e tempo consumido. Um profiler é o componente que registra essas medidas.
pub struct Profiler {
calls: HashMap<usize, u64>,
}
pub fn record_call(&mut self, function: usize) -> u64 {
let count = self.calls.entry(function).or_default();
*count += 1;
*count
}
O identificador function distingue funções. O contador diz quantas vezes cada uma começou. Não mede tudo que um profiler profissional mede, mas responde à primeira pergunta necessária.
Um hot path, ou caminho quente, é uma região do programa que concentra muita execução. Pode ser uma função inteira, um laço ou um ramo específico de uma condição.
No Janela JIT, a função fica quente ao atingir um limite:
let calls = profiler.record_call(function.id);
if calls >= hot_threshold {
try_compile(function.id, argument);
}
Usaremos um limite pequeno nos exemplos para observar a mudança. Motores reais ajustam decisões com muito mais sinais.
Warm-up, ou aquecimento, é o intervalo inicial em que o interpretador executa e reúne observações. Um benchmark que mede apenas a primeira chamada pode capturar compilação, inicialização e caches frios em vez do estado estável.
| Chamada | Caminho | O que o motor aprende |
|---|---|---|
| 1 | VM | shape e tipos vistos |
| 2 | VM | o padrão se repetiu |
| 3 | VM + compilação | função cruzou o limite |
| 4 | JIT | guard confirma o padrão |
const ponto = {};
ponto.x = 10;
ponto.y = 20;
ponto.cor = "laranja";
Campos podem ser acrescentados em tempo de execução. Se guardássemos tudo apenas num mapa de nomes, cada leitura precisaria procurar a string "x". Precisamos de uma forma de reconhecer objetos construídos pelo mesmo caminho.
Shape significa forma. É uma descrição interna da disposição dos campos, não o objeto nem seus valores. Dois pontos podem compartilhar o shape [x, y] mesmo contendo números diferentes. Na V8, a estrutura equivalente costuma ser chamada de Map ou HiddenClass; usamos “shape” porque descreve a ideia sem fingir que nossa estrutura possui todos os metadados da V8. A documentação oficial sobre propriedades rápidas na V8 mostra essa relação.
pub type ShapeId = usize;
pub struct Shape {
fields: Vec<String>,
}
pub struct Object {
shape: ShapeId,
slots: Vec<Value>,
}
Slot é uma posição numerada. No shape [x, y], x ocupa o slot 0 e y o slot 1. Localizar um número conhecido é mais direto que pesquisar uma string repetidamente.
Shape 0: []
+ x → Shape 1: [x]
+ y → Shape 2: [x, y]
Uma transição liga o shape atual ao shape resultante da adição de um campo. Se milhares de objetos recebem x e depois y, todos reutilizam os mesmos identificadores.
pub fn transition(&mut self, shape: ShapeId, name: &str) -> ShapeId {
if let Some(id) = self.transitions.get(&(shape, name.to_owned())) {
return *id;
}
// cria a nova forma apenas na primeira vez
}const a = {}; a.x = 1; a.y = 2; // [x, y]
const b = {}; b.y = 2; b.x = 1; // [y, x]
Ambos possuem x e y, mas seus slots não coincidem. Isso não muda o resultado do JavaScript; apenas influencia as oportunidades internas de reutilização.
Chamaremos de site o local de uma operação no bytecode. Cada LoadField recebe um número:
LoadField { site: 0, name: "x" }
LoadField { site: 1, name: "y" }
O site 0 aprende sobre objetos vistos ao ler x. O site 1 aprende independentemente ao ler y.
Cache é uma memória de resultados úteis para evitar trabalho repetido. Um inline cache fica associado a um site de operação e guarda algo como: “quando o shape é 2, o campo x está no slot 0”.
pub struct CacheEntry {
pub shape: ShapeId,
pub slot: usize,
}
O nome “inline” vem da maneira como essa verificação pode ser colocada perto da própria operação em implementações reais. Nosso projeto conserva as entradas numa tabela para tornar o estado fácil de inspecionar.
Um cache hit, ou acerto, acontece quando o shape recebido é o esperado. Um cache miss, ou falha, acontece quando não existe entrada ou o shape mudou.
if entry.shape == object.shape() {
self.hits += 1;
return object.slot(entry.slot);
}
self.misses += 1;
let slot = shapes.slot(object.shape(), name)?;
Miss não significa erro do programa. O motor segue o caminho geral, encontra a resposta correta e pode atualizar o cache.
| Estado | O que o site observou | Estratégia comum |
|---|---|---|
| Monomórfico | um shape recorrente | um teste e um slot |
| Polimórfico | poucos shapes | pequena lista de casos |
| Megamórfico | muitos shapes | caminho mais geral |
Mono significa um; poli, vários. “Megamórfico” indica variedade tão grande que acrescentar mais casos especializados pode deixar de compensar. Nosso cache é monomórfico para manter o mecanismo visível.
Especializar significa preparar código para um caso observado. Se somaPonto sempre recebe o shape [x, y] com números, podemos buscar diretamente os slots e somá-los.
struct OptimizedFunction {
expected_shape: ShapeId,
x_slot: usize,
y_slot: usize,
}
Essa estrutura é o nosso “código compilado”. Em um motor profissional, a saída poderia ser instrução de máquina numa região executável de memória.
Guard significa guarda ou verificação de proteção. Antes de usar os slots compilados, conferimos o shape:
if object.shape() != compiled.expected_shape {
return None;
}
Sem esse teste, um objeto [y, x] faria o motor confundir posições. Otimização não autoriza mudar o comportamento da linguagem.
let x = object.slot(compiled.x_slot)?.as_number()?;
let y = object.slot(compiled.y_slot)?.as_number()?;
Some(Value::Number(x + y))
Não percorremos seis instruções, não pesquisamos nomes e não despachamos a operação Add genérica. O trabalho economizado é pequeno numa chamada e significativo quando multiplicado por muitas repetições.
Desotimização, ou deoptimization, acontece quando o código especializado encontra algo que não previu. O guard falha e o motor retorna ao interpretador. Alguns textos chamam a saída rápida do caminho otimizado de bailout, “abandono do atalho”; neste artigo usaremos desotimização para nomear o processo completo.
match run_optimized(compiled, argument) {
Some(value) => value,
None => {
deoptimizations += 1;
optimized.remove(&function.id);
interpret(function, argument)?
}
}
Nosso projeto reinicia a função na VM. Motores profissionais conseguem reconstruir frames e continuar em pontos mais precisos, mas a regra central é a mesma: preservar o resultado correto.
// padrão que aqueceu a função
{ x: 3, y: 4 }
// novo caminho de construção
{ tag: 0, x: 3, y: 4 }
O segundo objeto continua válido e a soma deve continuar devolvendo 7. Seu shape possui outro arranjo. O guard detecta a diferença, registra uma desotimização e entrega o trabalho à VM.
| Peça | Pergunta |
|---|---|
| Profiler | o que se repete? |
| Shape | como este objeto está organizado? |
| Inline cache | posso reutilizar o slot anterior? |
| JIT | vale criar um caminho especializado? |
| Guard | a hipótese ainda vale? |
| Deopt | como volto à segurança? |
pub struct Engine {
pub shapes: ShapeTable,
profiler: Profiler,
caches: InlineCaches,
optimized: HashMap<usize, OptimizedFunction>,
hot_threshold: u64,
deoptimizations: u64,
}
ShapeTable compartilha formas, Profiler conta chamadas, InlineCaches lembram slots e optimized guarda funções já especializadas.
pub fn run(&mut self, function: &Function, argument: Value)
-> Result<ExecutionReport, String>
{
let calls = self.profiler.record_call(function.id);
// usa JIT quando disponível;
// desotimiza se o guard falhar;
// interpreta enquanto a função aquece.
}
O ExecutionReport expõe caminho, chamadas, hits, misses e desotimizações. Essa observabilidade permite aprender e testar o mecanismo sem cronômetros instáveis.
assert_eq!(first.path, "VM");
assert_eq!(second.path, "VM");
assert_eq!(third.path, "JIT");
assert_eq!(third.value.as_number(), Some(9.0));
O segundo teste aquece o JIT com [x, y], envia [tag, x, y] e espera:
assert_eq!(report.path, "DESOTIMIZAÇÃO → VM");
assert_eq!(report.value.as_number(), Some(7.0));
assert_eq!(report.deoptimizations, 1);Benchmark é um experimento que mede desempenho sob regras reproduzíveis. Para motores com JIT, precisamos decidir se queremos medir inicialização, warm-up, execução já otimizada ou desotimização.
- Repita o cenário e descarte ou relate o aquecimento.
- Evite misturar criação de dados com o trecho que deseja medir.
- Confira o resultado para impedir que trabalho inútil seja removido.
- Compare distribuições e não apenas um número isolado.
Nosso projeto mede contagens e caminhos, não nanossegundos. Isso torna a conclusão determinística: sabemos exatamente quando a política mudou.
janela-jit/
├── Cargo.toml
├── README.md
├── src/
│ ├── bytecode.rs # instruções da VM
│ ├── object.rs # shapes, transições e slots
│ ├── inline_cache.rs # hits, misses e entradas
│ ├── profiler.rs # contador de chamadas
│ ├── engine.rs # VM, JIT, guards e deopt
│ ├── value.rs # números e objetos
│ ├── lib.rs
│ └── main.rs
└── tests/engine.rs
Execute cargo run para observar a função migrando da VM ao JIT. Execute cargo test para validar tanto o caminho quente quanto a desotimização.
| Área | Janela JIT 0.5 | Motor profissional |
|---|---|---|
| Profiler | conta chamadas | tipos, ramos, laços e amostragem |
| Cache | monomórfico | mono, poli e megamórfico |
| JIT | função especializada em Rust | múltiplos níveis e código nativo |
| Otimizações | slots e soma numérica | inlining, escape analysis, folding e mais |
| Deopt | reinicia na VM | reconstrói estado e continua |
| Memória | objetos com Rc | GC geracional, incremental e concorrente |
Tier significa nível de execução. Um motor moderno pode começar no interpretador, passar por um compilador rápido e investir num compilador mais caro somente nos trechos mais importantes. Na V8 atual, Ignition interpreta bytecode; Sparkplug gera código rapidamente; Maglev ocupa um nível otimizador intermediário; TurboFan busca desempenho de pico. O artigo oficial sobre o compilador Maglev explica por que esses níveis equilibram início rápido e velocidade sustentada.
Inlining substitui uma chamada pelo corpo da função. Escape analysis procura valores que não escapam de um escopo. Constant folding calcula expressões constantes antecipadamente. Esses termos aparecem aqui apenas para dimensionar o campo, não como pré-requisitos escondidos.
| Termo | Significado |
|---|---|
| Interpretador | executa bytecode instrução por instrução |
| Profiling | medição do programa enquanto roda |
| Hot path | caminho executado muitas vezes |
| Warm-up | período inicial de aprendizagem |
| Shape | descrição da estrutura de um objeto |
| Slot | posição numerada de um campo |
| Inline cache | memória do shape e slot vistos num site |
| JIT | compilador que trabalha durante a execução |
| Guard | teste que protege uma hipótese otimizada |
| Desotimização | retorno ao caminho geral quando a hipótese falha |
Começamos com bytecode interpretado. O profiler encontrou repetição. Shapes deram identidade estrutural aos objetos. Inline caches transformaram nomes recorrentes em slots. O JIT criou um caminho especializado. Guards protegeram suas suposições. Quando o mundo mudou, a desotimização devolveu a execução à VM.
Partimos de uma janela desenhada em pixels, abrimos um site, entendemos JavaScript, mantivemos a página viva e, por fim, ensinamos nosso motor a reconhecer padrões de execução. Cada camada continuou ligada às anteriores — da interface ao código otimizado.
PROJETO COMPLETO · CÓDIGO DO CAPÍTULO
Execute e investigue o Janela JIT 0.5.
O pacote contém bytecode, interpretador, profiler, object shapes, transições, slots, inline caches com hits e misses, compilação de uma função quente, guards, desotimização, relatório observável e testes que comprovam correção nos três caminhos.