Em nosso artigo mais recente, discutimos alocações de variáveis, classes ocultas e como o V8 lida com nosso código JavaScript. Agora, vamos nos aprofundar um pouco mais no pipeline de compilação e nos componentes dos quais o V8 é composto.
Antes do lançamento da V8.5.9 em 2017, o V8 tinha um pipeline de execução antigo composto pelo compilador full-codegen e um compilador JIT chamado Crankshaft, que tinha dois subcomponentes chamados Hydrogen e Lithium. Esta imagem do Mathias Bynens ilustra bem nosso antigo pipeline:
Vamos falar um pouco sobre eles.
O compilador full-codegen
O compilador full-codegen é um compilador simples e muito rápido que produzia código de máquina simples e relativamente lento (não otimizado). O principal objetivo deste compilador é ser absolutamente rápido, mas escrever um código extremamente ruim. Portanto, ele converte JS em código de máquina na velocidade da luz, no entanto, o código não é otimizado e pode ser muito lento. Além disso, ele lida com o feedback de tipos que coleta informações sobre tipos de dados e uso de nossas funções à medida que nosso programa é executado.
Primeiro, ele pega nossa AST, percorre todos os nós e emite chamadas para um macro-assembler diretamente. O resultado: código nativo genérico. É isso aí! O full-codegen cumpriu seu objetivo. Todos os casos complexos são tratados emitindo chamadas para procedimentos do runtime e todas as variáveis locais são armazenadas no heap, o padrão. A mágica começa quando o V8 percebe funções quentes e frias!
Uma função quente é uma função chamada várias vezes durante a execução do nosso programa, portanto ela precisa ser otimizada mais que as outras. Uma função fria é exatamente o oposto. É quando o Crankshaft entra em cena.
Crankshaft
O Crankshaft costumava ser o compilador JIT padrão que tratava de todas as partes de otimização do JS.
Depois de receber as informações de tipo e informações de chamada do runtime que o full-codegen criou, ele analisa os dados e vê quais funções ficaram quentes. Então o Crankshaft pode percorrer a AST, gerando código otimizado para essas funções específicas. Posteriormente, a função otimizada substituirá a não otimizada usando o que é chamado de substituição em pilha (OSR).
Porém, essa função otimizada não cobre todos os casos, pois é otimizada apenas para trabalhar com os tipos definidos que estávamos passando durante a execução. Vamos imaginar nossa função readFile
. Nas primeiras linhas, temos o seguinte:
const readFileAsync = (filePath) => { /* ... */ }
Vamos supor que esta função esteja quente, filePath
é uma string, então o Crankshaft a otimiza para trabalhar com uma string. Mas agora, vamos imaginar que o filePath
sejanull
, ou talvez um número (vai que...). A função otimizada não seria adequada para este caso. Portanto, o Crankshaft desotimiza a função, substituindo-a pela função original.
Para explicar como toda essa mágica funciona, precisamos entender algumas partes internas do Crankshaft.
Hydrogen
O compilador Hydrogen pega a AST com informações de feedback de tipo como entrada. Com base nessas informações, ele gera o que é chamado de representação intermediária de alto nível (HIR), que possui um gráfico de fluxo de controle (CFG) na forma uma de atribuição estática-única (SSA), que é algo como isso aqui:
Para esta função dada:
function clamp (x, lower, upper) {
if (x < lower) x = lower
else if (x > upper) x = upper
return x
}
Uma conversão para SSA teria algo como isso de resultado:
entry:
x0, lower0, upper0 = args;
goto b0;
b0:
t0 = x0 < lower0;
goto t0 ? b1 : b2;
b1:
x1 = lower0;
goto exit;
b2:
t1 = x0 > upper0;
goto t1 ? b3 : exit;
b3:
x2 = upper0;
goto exit;
exit:
x4 = phi(x0, x1, x2);
return x4;
No SSA, as variáveis nunca são atribuídas novamente; elas são vinculadas uma vez ao seu valor e é isso aí. Esse modelo divide qualquer procedimento em vários blocos básicos de computação que terminam com uma ramificação para outro bloco, independentemente de essa ramificação ser condicional ou não. Como você pode ver, as variáveis são vinculadas a nomes únicos em cada atribuição e, no final, a função phi
pega todos os x
s e os une, retornando aquele que tem um valor.
Quando o HIR está sendo gerado, o Hydrogen aplica várias otimizações ao código, como constant folding, method inlining e outras coisas que veremos no final deste guia - há uma seção inteira só pra isso.
O resultado que o Hydrogen gera é um CFG otimizado que o próximo compilador, Lithium, usa como entrada para gerar o código otimizado real.
Lithium
Como dissemos, o Lithium é um compilador que pega o HIR e traduz em uma representação intermediária de baixo nível (LIR) específica da máquina. O que é conceitualmente semelhante ao que um código de máquina deve ser, mas também independente de plataformas.
Enquanto esse LIR está sendo gerado, novas otimizações de código são aplicadas, mas desta vez são otimizações de baixo nível.
No final, esse LIR é lido e o CrankShaft gera uma sequência de instruções nativas para cada instrução do Lithium, o OSR é aplicado e o código é executado...
Conclusão
Esta é a primeira de duas partes quando falamos sobre os pipelines de compilação V8. Portanto, fique atento ao próximo artigo desta série!
Não deixe de acompanhar mais do meu conteúdo no meu blog e se inscreva na newsletter para receber notícias semanais!
Se você encontrou algum erro de digitação, erro ou qualquer coisa errada com este artigo, me mande seus comentários! Todos os feedbacks são bem vindos e me ajudam a melhorar minha qualidade de conteúdo! <3