⚙️

Javascript Engines

As vezes escrever código usando Javascript parece magia, com algumas linhas de código conseguimos exibir imagens, palavras e manusear eventos dentro do navegador. Entender a tecnologia por trás de tudo isso nos ajuda a refinar nossas habilidades como programador.

Virtual Machines

Uma engine Javascript pode ser definida como um tipo de máquina virtual. Existem muitos tipos de máquinas virtuais que são classificadas baseado em sua habilidade de emular ou substituir uma máquina física.
  • System Virtual Machine: fornece uma emulação completa de uma plataforma onde um sistema operacional é executado. O Mac tem o Parallels, por exemplo.
  • Process Virtual Machine: é menos funcional e só executa um processo ou programa. O Linux possui o Wine para executar apps Windows em uma máquina Linux, mas não fornece o sistema operacional completo.

Engines

Uma engine Javascript é um tipo de process virtual machine designada especificamente para interpretar e executar código JS. Seu trabalho é converter e otimizar o código que o desenvolvedor escreve, para ser interpretado pelo browser.
A primeira engine, foi chamada de SpiderMonkey e construída para o browser Netscape. Era simplesmente um interpretador que lia e executava o código fonte.
Existem algumas engines disponíveis, dentre as mais famosas: V8 (Google Chrome); SpiderMonkey (Mozilla Firefox); JavascriptCore (Safari)

Engine Pipeline

Tudo se inicia através do código que escrevemos enquanto desenvolvedores. A engine converte o código fonte para uma AST (Abstract Syntax Tree). AST é um objeto contendo uma representação em forma de árvore do código fonte. Através dessa AST, o interpretador inicia seu trabalho e produz byte code.
Bytecode é uma abstração da linguagem de máquina que facilita a execução do código pelo interpretador. É um meio termo entre o código que escrevemos e o que a máquina entende.
notion image
A partir desse ponto a engine já está executando o código.
notion image
Para que o código possa ser executado ainda mais rápido, o bytecode pode ser enviado para o compilador junto com dados analíticos, para então produzir código de máquina otimizado.

Interpreter/Compiler Pipelines

De forma geral, existe um pipeline com um interpretador e um compilador. O interpretador consegue gerar bytecode não otimizado bem rápido, e o compilador demora um pouco mais, porém produz um código de máquina altamente otimizado.
A ilustração abaixo é exatamente como a engine V8, usada no Chrome e Node.js, funciona:
notion image
O interpretador usado pela V8 é chamado Ignition, e é responsável por executar bytecode. É o mais rápido de todas as engines. Enquanto executa, o interpretador coleta dados analíticos que pode ser usado para acelerar a execução em um momento futuro.
O Sparkplug foi introduzido em 2021 e é um compilador não otimizado. Seu trabalho é converter o bytecode gerado pelo Ignition para um código de máquina. É como se fosse um switch dentro de um for, onde para cada instrução de bytecode existe uma tradução para código de máquina.
Nem todas as engines seguem esse mesmo setup, o SpiderMonkey da Mozilla, por exemplo, tem dois compiladores. O JavascriptCore, da Apple, tem três.
Por que seguem modelos diferentes? Bom, é tudo sobre trade-offs. Um interpretador produz bytecode rápido, mas geralmente não é muito eficiente. Um compilador por outro lado demora um pouco mais, mas produz código de máquina, o que é muito mais eficiente. Algumas engines escolhem adicionar múltiplos compiladores com diferentes tempos e eficiências.
notion image
Apesar dessas diferenças, em um alto nível todas as engines tem a mesma arquitetura: um parser e algum tipo de interpretador/compilador.

Object Property Access and Inline Caching

Provavelmente uma das operações mais comuns em programas Javascript é acesso à propriedades de objetos, por esse motivo a engine tem um cuidado especial para tornar essa operação performática.
Inline Caching, ou IC é uma técnica de otimização utilizada em engines. O interpretador faz uma busca para conseguir acessar a propriedade do objeto. A engine então, associa cada objeto a um type que é gerado em tempo de execução, a V8 chama esses types de shape, mas a nomenclatura pode mudar entre as engines. Também são conhecidos como hidden classes.
Para que um objeto compartilhe o mesmo shape, ambos devem ter as mesmas chaves de propriedade, na mesma ordem. Logo, { a: 1, b: 2 } é diferente de { b: 2, a: 1 }
Com a ajuda dos shapes a engine sabe onde cada propriedade está armazenada em memória, e esse endereço fica hard-coded nas funções que acessam essa propriedades.
IC funciona melhor quando os objetos tem o mesmo shape, nesse caso é chamado de mnonomorphic IC.
console.time('test') let obj1 = { a: 1, b: 2, c: 10, d: 44 } let obj2 = { a: 3, b: 3, c: 11, d: 34 } let obj3 = { a: 5, b: 1, c: 12, d: 24 } let obj4 = { a: 6, b: 7, c: 13, d: 14 } const getProp = (obj) => obj.a // 1 bilhão de ciclos for (let i = 0; i < 1000 * 1000 * 1000; i++) { const first = getProp(obj1) const second = getProp(obj2) const third = getProp(obj3) const fourth = getProp(obj4) } console.timeEnd('test') // test: ~500ms
Se tivermos até quatro shapes diferentes, então é chamado de polymorphic IC. Diferente do monomorphic, o código de máquina gerado sabe todos os endereços, porém é preciso checar qual dos quatro possíveis shapes o argumento pertence. Com isso a performance é afetada.
console.time('test') let obj1 = { a: 1, b: 2, c: 10, d: 44 } let obj4 = { a: 6, b: 7, c: 13, d: 14 } // Different Shape let obj2 = { d: 3, a: 3, b: 11, c: 34 } // Different Shape let obj3 = { b: 5, d: 1, a: 12, c: 24 } const getProp = (obj) => obj.a // 1 bilhão de ciclos for (let i = 0; i < 1000 * 1000 * 1000; i++) { const first = getProp(obj1) const second = getProp(obj2) const third = getProp(obj3) const fourth = getProp(obj4) } console.timeEnd('test') // test: ~1900ms
Acima de quatro shapes, é ainda pior. Chamado de megamorphic IC, nesse estado não existe cache dos endereços de memória. É preciso pesquisar no cache global. O que prejudica extremamente a performance.
console.time('test') let obj1 = { a: 1, b: 2, c: 10, d: 44 } let obj4 = { d: 6, c: 7, b: 13, a: 14 } let obj2 = { b: 3, a: 3, d: 11, c: 34 } let obj3 = { c: 5, d: 1, a: 12, b: 24 } let obj5 = { z: 5, f: 1, g: 12, h: 24 } const getProp = (obj) => obj.a // 1 bilhão de ciclos for (let i = 0; i < 1000 * 1000 * 1000; i++) { const first = getProp(obj1) const second = getProp(obj2) const third = getProp(obj3) const fourth = getProp(obj4) const fifth = getProp(obj5) } console.timeEnd('test') // test: ~11000ms
Ou seja, do primeiro exemplo, onde existia apenas um shape, para o último, com cinco, houve uma diferença de mais de 10 segundos. Se todos os objetos tivessem o mesmo shape a execução demoraria os mesmos ~500ms.

How to fix it?

No exemplo acima temos 5 objetos com 5 shapes diferentes. Como podemos garantir que a engine marque todos os objetos com o mesmo shape?
Uma possível resposta é utilizar classes, e garantir que as propriedades não informadas sejam nulas no construtor.

Referências