Como o JavaScript funciona: Dentro do motor V8 + 5 dicas sobre como escrever código otimizado

Parte 2 - Explorando as entranhas do JavaScript: uma série reveladora sobre o motor, tempo de execução e pilha de chamadas por debaixo dos panos.

Como o JavaScript funciona: Dentro do motor V8 + 5 dicas sobre como escrever código otimizado
Photo by GHAITH ALSIRAWAN / Unsplash

Há algum tempo, começamos uma série de artigos com o objetivo de aprofundar o conhecimento em JavaScript e entender como ele realmente funciona. Acreditamos que, ao conhecer os componentes fundamentais do JavaScript e como eles se integram, você será capaz de escrever melhores códigos e aplicativos.

O primeiro artigo da série focou em fornecer uma visão geral do motor, do tempo de execução e da pilha de chamadas. Este segundo artigo irá explorar as partes internas do motor JavaScript V8, desenvolvido pelo Google. Também forneceremos algumas dicas rápidas sobre como escrever um código JavaScript melhor otimizado.

Visão Geral

Um motor JavaScript é um programa ou interpretador que executa código JavaScript. Ele pode ser implementado como um interpretador padrão ou um compilador JIT (just-in-time). Aqui está uma lista de projetos populares que estão implementando um motor JavaScript:

  • V8 - O V8 é o motor de JavaScript e WebAssembly de alto desempenho de código aberto da Google, escrito em C++. Ele é usado no Chrome, Node.js e outros. Ele implementa o ECMAScript e o WebAssembly e pode ser executado no Windows 7 ou posterior, macOS 10.12+ e sistemas Linux que usam processadores x64, IA-32, ARM ou MIPS. O V8 pode ser executado de forma independente ou pode ser incorporado em qualquer aplicativo em C++.
V8 JavaScript engine
V8 is Google’s open source high-performance JavaScript and WebAssembly engine, written in C++.

  • Rhino - O Rhino é um motor JavaScript escrito completamente em Java e gerenciado pela Fundação Mozilla como software de código aberto. Ele converte scripts JavaScript em classes Java. O Rhino funciona tanto no modo compilado quanto no modo interpretado. Ele é projetado para ser usado em aplicativos de desktop ou lado do servidor, portanto, não há suporte integrado para os objetos do navegador da web que são comumente associados ao JavaScript.
GitHub - mozilla/rhino: Rhino is an open-source implementation of JavaScript written entirely in Java
Rhino is an open-source implementation of JavaScript written entirely in Java - GitHub - mozilla/rhino: Rhino is an open-source implementation of JavaScript written entirely in Java

  • SpiderMonkey - O SpiderMonkey é um motor JavaScript e WebAssembly de código aberto desenvolvido pela Fundação Mozilla. Ele é o primeiro motor JavaScript, escrito por Brendan Eich na Netscape Communications, e posteriormente lançado como código aberto e atualmente mantido pela Fundação Mozilla. É usado no navegador web Firefox. O SpiderMonkey foi lançado em 1995. Ele implementa a especificação ECMA-262 (ECMAScript) e é escrito em C/C++.
Home
SpiderMonkey is Mozilla’s JavaScript and WebAssembly Engine, used in Firefox, Servo and various other projects. It is written in C++ and Rust.

  • JavaScriptCore - O JavaScriptCore é o motor JavaScript incorporado ao WebKit da Apple. Atualmente, ele implementa o ECMAScript conforme a especificação ECMA-262. É frequentemente referido por diferentes nomes, como SquirrelFish e SquirrelFish Extreme. No contexto do Safari, também são comumente usados os termos de marketing Nitro e Nitro Extreme. No entanto, o nome do projeto e da biblioteca é sempre JavaScriptCore.
JavaScriptCore | Apple Developer Documentation
Evaluate JavaScript programs from within an app, and support JavaScript scripting of your app.

  • KJS - O KJS é o motor ECMAScript-JavaScript do KDE, que foi originalmente desenvolvido para o navegador web Konqueror do projeto KDE por Harri Porten em 2000.
KDE JavaScript/EcmaScript Engine
K Desktop Environment Homepage, KDE.org

  • Chakra - O Chakra é um mecanismo JScript proprietário desenvolvido pela Microsoft. Ele é usado no navegador da web Internet Explorer. Posteriormente, a Microsoft desenvolveu um novo mecanismo JavaScript para o seu navegador Microsoft Edge, que é confusamente também é chamado de Chakra. O Microsoft Edge mudou para o mecanismo JavaScript V8 em 2020.
GitHub - chakra-core/ChakraCore: ChakraCore is an open source Javascript engine with a C API.
ChakraCore is an open source Javascript engine with a C API. - GitHub - chakra-core/ChakraCore: ChakraCore is an open source Javascript engine with a C API.

  • Nashorn - O Nashorn é um mecanismo JavaScript desenvolvido na linguagem de programação Java, originalmente pela Oracle e posteriormente pela comunidade OpenJDK. Ele depende do suporte a linguagens com tipagem dinâmica na Plataforma Java (JSR 292). O Nashorn foi incluído no Java 8 até o JDK 14.
Menu
Oracle - Artigos Técnicos: Nashorn prático, Parte 2: O Java em JavaScript

  • JerryScript - é um motor leve para a Internet das Coisas (IoT).
JavaScript engine for Internet of Things
JerryScript is a very lightweight JavaScript engine with capability to run on microcontrollers with less than 8KB of RAM.

Por que o Motor V8 foi criado?

O motor V8, desenvolvido pelo Google, é de código aberto e escrito em C++. Esse motor é usado dentro do Google Chrome. No entanto, ao contrário dos outros mecanismos, o V8 também é usado para o popular ambiente de execução Node.js.

O V8 foi inicialmente projetado para aumentar o desempenho da execução do JavaScript dentro de navegadores web. A fim de obter velocidade, o V8 compila o código JavaScript em código de máquina ao invés de usar um interpretador, isso é muito mais eficiente. Ele compila o código JavaScript em código de máquina durante a execução, implementando um compilador JIT (Just-In-Time), assim como muitos motores JavaScript modernos fazem, como o SpiderMonkey ou o Rhino (Mozilla). A principal diferença aqui é que o V8 não produz bytecode ou qualquer código intermediário.

V8 costumava ter dois compiladores

Antes do lançamento da versão 5.9 do V8 (início de 2017), o motor costumava ter dois compiladores:

  • Full-codepen — um compilador simples e muito rápido que produzia código de máquina simples e relativamente lento.
  • CrankShaft — um compilador mais complexo JIT (just-in-time) que produzia um código altamente otimizado.

O motor V8 também utiliza vários threads internamente:

  • O thread principal faz o que se espera: busca o código, o compila e, em seguida, o executa.
  • Também há um thread separado para compilação, de modo que o thread principal possa continuar executando enquanto o primeiro otimiza o código.
  • Um thread de perfilador informa ao tempo de execução quais métodos estão consumindo muito tempo, para que o Crankshaft possa otimizá-los.
  • Alguns threads são dedicados à varredura do coletor de lixo (Garbage Collector).

Ao executar o código JavaScript pela primeira vez, o V8 utiliza o full-codegen, que traduz diretamente o JavaScript analisado para código de máquina, sem nenhuma transformação. Isso permite que ele comece a executar código de máquina muito rapidamente. Vale ressaltar que o V8 não utiliza representação intermediária de bytecode, eliminando a necessidade de um interpretador.

Quando o código é executado por algum tempo, o thread de perfilador coleta dados suficientes para determinar quais métodos devem ser otimizados.

Em seguida, as otimizações do Crankshaft começam em outro thread. Ele traduz a árvore de sintaxe abstrata JavaScript para uma representação de atribuição estática (SSA) de alto nível chamada Hydrogen e tenta otimizar esse grafo do Hydrogen. A maioria das otimizações é feita nesse nível.

Inlining

A primeira otimização é fazer o inline de todo o código possível antecipadamente. Inlining é o processo de incorporar o código de uma função chamada diretamente na função chamadora. É uma técnica de otimização usada pelo mecanismo V8 e outros compiladores. Quando uma função é marcada para inline, seu código é inserido no local da chamada em vez de fazer uma chamada de função separada. Isso elimina a sobrecarga das instruções de chamada de função e permite uma execução mais eficiente. Esse passo simples permite que as otimizações subsequentes sejam mais significativas.

Classe Oculta (Hidden Class)

JavaScript é uma linguagem baseada em protótipos: não existem classes e objetos são criados usando um processo de clonagem. JavaScript também é uma linguagem de programação dinâmica, o que significa que propriedades podem ser facilmente adicionadas ou removidas de um objeto após sua instanciação.

A maioria dos interpretadores JavaScript usa estruturas semelhantes a dicionários (baseadas em funções hash) para armazenar a localização dos valores das propriedades do objeto na memória. Essa estrutura torna a recuperação do valor de uma propriedade em JavaScript mais computacionalmente cara do que seria em uma linguagem de programação não dinâmica, como Java ou C#. Em Java, todas as propriedades do objeto são determinadas por uma estrutura de objeto fixa antes da compilação e não podem ser adicionadas ou removidas dinamicamente em tempo de execução (bem, o C# possui o tipo dynamic, que é outro tópico). Como resultado, os valores das propriedades (ou ponteiros para essas propriedades) podem ser armazenados como um buffer contínuo na memória, com um deslocamento fixo entre cada um deles. O comprimento de um deslocamento pode ser facilmente determinado com base no tipo da propriedade, enquanto isso não é possível em JavaScript, onde o tipo de uma propriedade pode mudar durante a execução.

Como usar dicionários para encontrar a localização das propriedades de um objeto na memória é muito ineficiente, o V8 utiliza um método diferente: classes ocultas. As classes ocultas funcionam de forma semelhante às estruturas de objeto fixas (classes) usadas em linguagens como Java, exceto que elas são criadas em tempo de execução. Agora, vamos ver como elas realmente funcionam.

function Point(x, y) {
    this.x = x;
    this.y = y;
}
var p1 = new Point(1, 2);

Uma vez que a invocação do “new Point(1,2)” acontece, V8 vai criar uma classe oculta chamada “C0”.

Nenhuma propriedade foi definida para Point ainda, então "C0" está vazio.

Assim que a primeira declaração "this.x = x" for executada (dentro da função "Point"), o V8 criará uma segunda classe oculta chamada "C1" que é baseada em "C0". "C1" descreve a localização na memória (relativa ao ponteiro do objeto) onde a propriedade "X" pode ser encontrada. Neste caso, "X" é armazenado no deslocamento 0, o que significa que ao visualizar um objeto point na memória como um buffer contínuo, o primeiro deslocamento corresponderá à propriedade "X". O V8 também atualizará "C0" com uma "transição de classe" que indica que se uma propriedade "X" for adicionada a um objeto point, a classe oculta deve mudar de "C0" para "C1". A classe oculta para o objeto point abaixo é agora "C1".

Cada vez que uma nova propriedade é adicionada para um objeto, a antiga classe oculta é atualizada com um caminho de transição para a nova classe oculta. Transições de classe o oculta são importantes porque eles permitem que as classes ocultas sejam compartilhadas entre os objetos que são criados da mesma maneira. Se dois objetos compartilham a mesma classe oculta e a mesma propriedade é adicionada para ambos, transições vão assegurar que ambos os objetos recebem a mesma nova classe oculta e todo o código otimizado que vem junto com eles.

Esse processo se repete quando a declaração "this.y = y" é executada (novamente, dentro da função Point, após a declaração "this.x = x").

Uma nova classe oculta chamada "C2" é criada, uma transição de classe é adicionada a "C1", indicando que se uma propriedade "Y" for adicionada a um objeto Point (que já contém a propriedade "X"), a classe oculta deve ser alterada para "C2", e a classe oculta do objeto point é atualizada para "C2".

As transições de classe oculta dependem da ordem em que as propriedades são adicionadas a um objeto. Dê uma olhada no trecho de código abaixo:

function Point(x, y) {
    this.x = x;
    this.y = y;
}
var p1 = new Point(1, 2);
p1.a = 5;
p1.b = 6;
var p2 = new Point(3, 4);
p2.b = 7;
p2.a = 8;

Agora, você pode supor que tanto "p1" quanto "p2" usariam as mesmas classes ocultas e transições. Bem, na verdade, não é exatamente assim. Para "p1", primeiro a propriedade "a" será adicionada e depois a propriedade "b". Já para "p2", primeiro "b" é atribuído, seguido por "a". Assim, "p1" e "p2" acabam com classes ocultas diferentes como resultado dos caminhos de transição diferentes. Em casos como esse, é muito melhor inicializar as propriedades dinâmicas na mesma ordem, para que as classes ocultas possam ser reutilizadas.

Inline Caching

O V8 aproveita outra técnica para otimizar linguagens com tipos dinâmicos, chamada de cacheamento em linha (inline caching). O cacheamento em linha se baseia na observação de que chamadas repetidas ao mesmo método tendem a ocorrer no mesmo tipo de objeto. Uma explicação mais detalhada sobre o cacheamento em linha pode ser encontrada aqui.

Vamos abordar brevemente o conceito geral do cacheamento em linha (caso você não tenha tempo para ler a explicação detalhada acima).

Então, como isso funciona? O V8 mantém um cache do tipo de objetos que foram passados como parâmetro em chamadas recentes de método e utiliza essa informação para fazer uma suposição sobre o tipo de objeto que será passado como parâmetro no futuro. Se o V8 conseguir fazer uma boa suposição sobre o tipo de objeto que será passado para um método, ele pode pular o processo de descobrir como acessar as propriedades do objeto e, em vez disso, usar as informações armazenadas das pesquisas anteriores à classe oculta do objeto.

Mas como os conceitos de classes ocultas e cacheamento em linha estão relacionados? Sempre que um método é chamado em um objeto específico, o mecanismo V8 precisa realizar uma pesquisa na classe oculta desse objeto para determinar o deslocamento para acessar uma propriedade específica. Após duas chamadas bem-sucedidas do mesmo método para a mesma classe oculta, o V8 omite a pesquisa da classe oculta e simplesmente adiciona o deslocamento da propriedade ao próprio ponteiro do objeto. Para todas as chamadas futuras desse método, o mecanismo V8 assume que a classe oculta não foi alterada e salta diretamente para o endereço de memória de uma propriedade específica usando os deslocamentos armazenados das pesquisas anteriores. Isso aumenta significativamente a velocidade de execução.

O cacheamento em linha também é a razão pela qual é tão importante que objetos do mesmo tipo compartilhem classes ocultas. Se você criar dois objetos do mesmo tipo e com classes ocultas diferentes (como fizemos no exemplo anterior), o V8 não poderá usar o cacheamento em linha, pois, mesmo que os dois objetos sejam do mesmo tipo, suas classes ocultas correspondentes atribuem deslocamentos diferentes às suas propriedades.

Os dois objetos são basicamente iguais, mas as propriedades "a" e "b" foram criadas em ordem diferente.

Compilação para o código de máquina

Uma vez que o grafo Hydrogen é otimizado, o Crankshaft o converte para uma representação de baixo nível chamada Lithium. A maior parte da implementação do Lithium é específica para a arquitetura. A alocação de registradores ocorre nesse nível.

No final, o Lithium é compilado para código de máquina. Em seguida, ocorre algo chamado OSR (On-Stack Replacement): antes de começarmos a compilar e otimizar um método obviamente longo, é provável que já o estejamos executando. O V8 não vai esquecer o que acabou de executar lentamente para começar novamente com a versão otimizada. Em vez disso, ele transformará todo o contexto que temos (pilha, registradores) para que possamos alternar para a versão otimizada no meio da execução. Isso é uma tarefa muito complexa, levando em consideração que, entre outras otimizações, o V8 inicialmente inseriu o código. O V8 não é o único motor capaz de fazer isso.

Existem salvaguardas chamadas de desotimização, que realizam a transformação oposta e revertam para o código não otimizado, caso uma suposição feita pelo motor deixe de ser verdadeira.

Garbage collection (Coleta de Lixo)

Para coleta de lixo (garbage collection), o V8 utiliza uma abordagem geracional tradicional de marcação e varredura (mark-and-sweep) para limpar a geração antiga. A fase de marcação é projetada para interromper a execução do JavaScript. Para controlar os custos da coleta de lixo e tornar a execução mais estável, o V8 utiliza marcação incremental: em vez de percorrer todo o heap, tentando marcar todos os objetos possíveis, ele percorre apenas parte do heap e, em seguida, retoma a execução normal. A próxima pausa da coleta de lixo continuará de onde a varredura anterior do heap parou. Isso permite pausas muito curtas durante a execução normal. Como mencionado anteriormente, a fase de varredura é tratada por threads separadas.

Ignition e TurboFan

Com o lançamento do V8 5.9 no início de 2017, foi introduzida uma nova estrutura de execução. Essa nova estrutura oferece melhorias de desempenho ainda maiores e economia significativa de memória em aplicações JavaScript do mundo real.

A nova estrutura de execução é construída em cima do Ignition, o interpretador do V8, e do TurboFan, o mais recente compilador otimizador do V8.

Ignition e TurboFan são dois componentes principais do mecanismo V8 que trabalham em conjunto para otimizar a execução do JavaScript.

Ignition é um interpretador projetado para proporcionar uma inicialização rápida e uma execução imediata do código JavaScript. Ele é responsável por traduzir o código JavaScript em bytecode portátil e executá-lo de forma eficiente. O Ignition é especialmente eficaz em códigos de inicialização, loops curtos e funções pequenas.

Por outro lado, o TurboFan é um otimizador de código just-in-time (JIT) que se concentra em melhorar o desempenho do código JavaScript por meio de compilação de alto nível para código de máquina altamente otimizado. Ele realiza análises detalhadas do código JavaScript, identifica oportunidades de otimização e gera código altamente otimizado, específico para a arquitetura do processador em que está sendo executado.

Em conjunto, o Ignition e o TurboFan trabalham para fornecer um desempenho otimizado no mecanismo V8, garantindo uma inicialização rápida do código JavaScript e uma execução eficiente e otimizada ao longo do tempo.

Você pode conferir o post do blog da equipe V8 sobre o assunto aqui.

Desde a versão 5.9 do V8, o full-codegen e o Crankshaft (as tecnologias que serviam o V8 desde 2010) não são mais utilizados pelo V8 para a execução do JavaScript, uma vez que a equipe do V8 tem enfrentado dificuldades para acompanhar os novos recursos da linguagem JavaScript e as otimizações necessárias para esses recursos.

Isso significa que, de maneira geral, o V8 terá uma arquitetura mais simples e de fácil manutenção daqui para frente.

Melhorias nos benchmarks da Web e do Node.js

Essas melhorias são apenas o começo. O novo pipeline Ignition e TurboFan abrem caminho para otimizações adicionais que impulsionarão o desempenho do JavaScript e reduzirão a pegada do V8 tanto no Chrome quanto no Node.js nos próximos anos.

Por fim, aqui estão algumas dicas sobre como escrever JavaScript otimizado. Você pode derivar facilmente essas dicas do conteúdo acima, mas aqui está um resumo para sua conveniência:

Como escrever JavaScript otimizado

  • Ordem das propriedades do objeto: sempre instancie as propriedades do objeto na mesma ordem, para que as classes ocultas e, consequentemente, o código otimizado possam ser compartilhados.
  • Propriedades dinâmicas: adicionar propriedades a um objeto após a instância forçará uma mudança na classe oculta e desacelerará os métodos que foram otimizados para a classe oculta anterior. Em vez disso, atribua todas as propriedades de um objeto no construtor.
  • Métodos: código que executa o mesmo método repetidamente será executado mais rápido do que código que executa muitos métodos diferentes apenas uma vez (devido ao inline caching).
  • Arrays: evite arrays esparsos, nos quais as chaves não são números incrementais. Arrays esparsos, que não possuem todos os elementos dentro deles, são uma tabela hash. Elementos nesses arrays são mais caros de acessar. Além disso, tente evitar a pré-alocação de arrays grandes. É melhor crescer conforme necessário. Por fim, não exclua elementos de arrays, pois isso torna as chaves esparsas.
  • Valores etiquetados: o V8 representa objetos e números com 32 bits. Ele usa um bit para saber se é um objeto (flag = 1) ou um número inteiro (flag = 0), chamado de SMI (SMall Integer), devido aos seus 31 bits. Portanto, se um valor numérico for maior que 31 bits, o V8 irá encapsular o número, transformando-o em um número de ponto flutuante e criando um novo objeto para colocar o número dentro. Tente usar números assinados de 31 bits sempre que possível para evitar a operação custosa de encapsulamento em um objeto JS.

Referências

Este é um artigo traduzido. O artigo original pode ser lido no link abaixo.

How JavaScript works: inside the V8 engine + 5 tips on how to write optimized code
Couple of weeks ago we started a series aimed at digging deeper into JavaScript and how it actually works: we thought that by knowing the…

Autor do post original — Alexander Zlatkov— Co-founder & CEO @SessionStack