WebAssembly, a jornada — Compiladores JIT

Willian Martins
Training Center
Published in
5 min readJan 12, 2018

Essa é a parte 2 da série WebAssembly, a jornada. Se você está iniciando por esse artigo, talvez seja melhor iniciar por aqui.

No último artigo, foi explicada a nossa motivação e o nosso PoC para medir a performance do WebAssembly junto com a explicação da implementação em Vanilla JS (JS Puro). Para continuar nessa jornada do entendimento do por que WebAssembly em teoria é mais rápido que JavaScript, precisamos explicar um pouco da história do JavaScript e o que o fez ser tão rápido hoje em dia.

Um Pouco de história

JavaScript foi criado por Brendan Eich em 1995, com o objetivo de ser uma linguagem que facilitasse a implementação de interfaces dinâmicas por parte dos designers, ou seja, não foi criado para ser rápido, foi criado para facilmente adicionar uma camada de comportamento em páginas HTML de maneira simples e fácil.

Quando o JavaScript foi introduzido, era assim que a internet se parecia.

Inicialmente o JavaScript era uma linguagem interpretada, fazendo sua inicialização de forma rápida, já que o interpretador só precisa ler a primeira instrução, traduzir ela em byte code e executa-la logo em seguida. Para as necessidades daquela internet dos anos 90, o JavaScript cumpria bem o seu papel. O problema acontece quando as aplicações começam a se tornar mais complexas.

Na década de 2000, tecnologias como Ajax fizeram com que as aplicações web ficassem mais complexas, Gmail em 2004 e Google Maps em 2005 foram grandes casos de uso dessa tecnologia. Esse novo “jeito” de fazer aplicações web fez com que houvesse mais lógica escrita no cliente. É nesse momento que o JavaScript precisou dar mais um salto na sua performance, que aconteceu em 2008 com o surgimento do Google e o seu motor V8 que compilava todo o código JavaScript em byte code logo na inicialização. Mas como compiladores JIT (just in time) funcionam?

Como JIT do JavaScript funciona?

Em linha gerais, depois que o código JavaScript é carregado, o código fonte é transformado numa representação em árvore chamado Abstract Syntax Tree ou AST. Depois, dependendo do motor/ sistema operacional / plataforma, ou é compilada uma versão baseline desse código ou é criada um byte code que será interpretado.

Uma outra entidade a ser observada é o Profiler, que monitora e coleta dados da execução do código. Vou descrever em linhas gerais como isso ocorre, levando em consideração que existem diferenças entre os motores dos browsers.

No primeiro momento, todo o código é rodado pelo interpretador, isso garante que o código execute mais rapidamente depois que o AST é gerado. Quando um pedaço do código é executado múltiplas vezes, como a nossa função getNextState(), o interpretador perde a sua performance já que ele precisa interpretar o mesmo pedaço de código toda vez, quando isso acontece, o Profiler marca esse pedaço de código como morno. Quando um pedaço de código é marcado como morno, entra em ação o baseline compiler.

Baseline Compiler

Para ilustrar melhor o funcionamento do JIT, de agora em diante nós vamos usar o seguinte snippet como exemplo.

function sum (x, y) {
return x + y;
}
[1, 2, 3, 4, 5, '6', 7, 8, 9, 10].reduce(
(prev, curr) => sum(prev, curr),
0
);

Quando uma parte do código é marcada como morno, o JIT manda esse código para o baseline compiler, que cria um stub para esse pedaço de código enquanto o profiler se mantém coletando dados sobre a incidência e o tipo de dados enviado para ele. Quando esse pedaço de código é chamado (nesse exemplo hipotético return x + y;), o JIT só precisa usar essa parte compilada de novo. Quando uma parte do código morno é chamada muitas vezes e com os mesmos tipos de dados ela é marcada como hot.

Optimizer Compiler

Quando uma parte do código é marcada como hot, o profiler envia ela para o optimizer compiler que gera uma versão ainda mais rápida dela. Isto só é possível por conta de algumas premissas que o optimizer compiler faz como o tipo das variáveis ou formato dos objetos. No nosso exemplo, podemos afirmar que um código hot de return x + y; assumirá que tanto x quanto y são tipo number.

O problema é quando esse código é chamado com algo que não é esperado pelo optimized compiler, no nosso caso a chamada de sum(15, '6'), já que y será uma string. Quando isso acontece, o profiler assume que as suas premissas estão erradas, joga tudo fora retornando para a versão baseline ou (interpretada) do código. Essa fase é chamada deoptimization. As vezes isso ocorre tão frequentemente que a performance desse código se torna inferior à versão baseline.

Alguns motores tem um limite de quantas vezes esse código pode tentar ser otimizado, deixando de tentar otimizar quando esse limite é atingido. Outros como o V8, tem heurísticas que impedem a tentativa de otimização, quando já se sabe que esse código muito provavelmente será desoptimizado isso é chamado de bailing out.

Em resumo, as fases do compilador JIT podem ser descritas como:

  • Parse
  • Compilação
  • Optimização/desoptimização
  • Execução
  • Garbage Colector
Exemplo das fases do JIT no V8 por Addy Osmani

Todos esses melhoramentos trazidos pelo JIT compiler fazem com que o JS seja muito mais rápido do que em 2008 antes do seu advento no Google Chrome, hoje as aplicações estão muito mais robustas e complexas graças a rapidez encontrada nos motores JavaScript, mas o que fará com que tenhamos o próximo salto de performance? Discutiremos isso no próximo artigo, quando iremos abordar WebAssembly e o que torna ele potencialmente mais rápido que o JavaScript.

Links

--

--

Willian Martins
Training Center

JS formatter/CSS tweaker @eBay. From São Paulo Brazil, but lives in Berlin. Sim racer gamer and Soccer fan.