Como o JavaScript funciona: dentro da engine V8 + 5 dicas sobre como escrever código otimizado

Algumas semanas atrás nós começamos uma série com o objetivo de aprofundar como o Javascript atualmente funciona: pensamos que conhecendo melhor os blocos de construção do Javascript e como eles funcionam juntos, você vai estar habilitados a escrever melhores códigos e aplicativos.

O primeiro post dessa série focou em fornecer uma visão geral da engine, o runtime e a call stack. Esse segundo post vai mergulhar nas partes internas da engine Javascript V8 do Google. Também vamos fornecer algumas dicas rápidas de como código Javascript melhor — melhores práticas que nosso time de desenvolvimento no SessionStack segue quando está construindo o produto.

Abaixo estão os posts dessa série publicados até o momento

Visão geral

Uma engine Javascript é um programa ou um interpretador que executa código Javascript. Uma engine Javascript pode ser implementada como um interpretador padrão, ou apenas um compilador que na hora certa(Just-in-time) compila Javascript para bytecode de alguma forma.

Essa é uma lista de projetos populares que estão implementando uma engine Javascript:

  • V8 — open source, desenvolvido pelo Google, escrito em C++
  • Rhino — gerenciado pela Mozilla Foundation, open source, desenvolvido inteiramente em Java
  • SpiderMonkey — a primeira engine Javascript, que um dia empoderou o Netscape Navigator, e hoje empodera o Firefox
  • JavaScriptCore — open source, comercializado como Nitro desenvolvido pela Apple para o Safari
  • KJS — KDE’s engine originalmente desenvolvido por Harri Porten para o projeto KDE Konqueror web browser
  • Chakra (JScript9) — Internet Explorer
  • Chakra (JavaScript) — Microsoft Edge
  • Nashorn, open source como parte do OpenJDK, escrito pela Oracle Java Languages e Tool Group
  • JerryScript — é uma engine leve para a internet das coisas(IOT).

Porque a engine V8 foi criada ?

A engine V8 que é construída pelo Google é open source e escrito em C++. Essa engine é usada dentro do Google Chrome. Ao contrário do resto das engines, no entanto, a V8 é também usado pelo popular runtime do Node.js.

V8 foi primeiro projetado para aumentar a performance de execução do Javascript dentro de navegadores web. A fim de obter velocidade. Ele compila código Javascript em código de máquina ao invés de usar um interpretador. Ele compila o código Javascript em código de máquina na execução implementando um compilador JIT(Just-in-time) como várias engines Javascript modernas fazem, como SpiderMonkey ou Rhino(Mozila). A principal diferença aqui é que V8 não produz bytecode ou qualquer código intermediário.

V8 costumava ter dois compiladores

Antes da versão da 5.9 da V8 ser sair(foi lançada em 2017), a engine 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(Just-in-time) que produzia um código altamente otimizado.

A engine V8 também usa várias segmentos(threads) internamente:

  • A principal threads faz o que espera: busca seu código, compila; e então executa.
  • Também há uma thread separada para compilar, de modo que a thread principal pode se manter executando enquanto o código está sendo otimizado.
  • Um Profiler thread que vai contar ao runtime quais métodos nós gastamos mais tempo para que o Crankshaft possa otimizá-lo.
  • Algumas thread para lidar com varreduras de garbage collection(coleta de lixo)

Ao executar pela primeira vez o código Javascript, V8 aproveita o full-codegen que traduz diretamente o Javascript parseado em código de máquina sem qualquer transformação. Isso permite que ele comece a executar o código de máquina muito rápido. Note que a V8 não usa representação bytecode intermediário, dessa maneira removendo a necessidade por um interpretador.

Quando o seu código está executando por algum tempo, o profile thread reuni dados o suficiente para contar que método deveria ser otimizado.

Próximo, a otimização do CrankShaft começa em outra thread. Ele traduz a abstract syntax tree(árvore de sintaxe abstrata) Javascript para uma representação de atribuição estática única de alto nível(SSA em inglês) chamada Hydrogen e tenta otimizar esse gráfico Hydrogen. A maioria das otimizações são feitas nesse nível

Inlining

A primeira otimização é inserir o maior número possível de código com antecedência. Inlining é o processo de substituir uma call site(a linha de código onde a função é chamada) com o corpo da função chamada. Esse simples passo permite as otimizações a seguir serem mais significativas.

Classe oculta (Hidden class)

Javascript é uma linguagem baseada em protótipo(prototype-based language): não há classes e objetos são criados usando um processo de clonagem. Javascript é também um linguagem de programação dinâmica, o que significa que propriedades podem ser facilmente adicionadas ou removidas de um objeto depois de sua instanciação.

A maioria dos interpretadores Javascript usam dicionários como estruturas para armazenar a localização de valores de propriedades de objetos em memória. Essa estrutura faz a recuperação do valor de uma propriedade mais cara computacionalmente do que isso seria em linguagens de programação não dinâmicas como Java ou C#. Em Java, todos as propriedades de objetos são determinadas por um layout de objeto fio antes da compilação e não pode ser adicionado ou removido em tempo de execução (bem, C# tem o dynamic type que é outro tópico). Como um resultado, os valores de 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 tamanho de um deslocamento pode ser facilmente determinado baseado no tipo de propriedade, enquanto que isso não é possível em Javascript onde um tipo de propriedade pode mudar durante o tempo de execução.

Desde que usando dicionários para encontrar a localização de propriedades de objetos na memória é muito ineficiente, V8 ao invés disso, usa um método diferente: Classe oculta (Hidden class). Classes ocultas funcionam similarmente a layouts de objetos (classes) fixos usados em linguagens como Java, exceto que eles são criados em tempo de execução. Agora vamos ver como eles 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 ainda para o Point, então “Co”está vazia.

Uma vez que a primeira declaração “this.x = x” é executada (dentro da função “Point”), V8 vai criar uma segunda classe oculta chamada “C1” que é baseada sobre “C0”. “C1” descreve a localização na memória(relativa ao objeto point) onde a propriedade X pode ser encontrada. Neste caso, “x” é armazenado no deslocamento 0, o que significa que ao visualizar objeto point na memória como um buffer contínuo, o primeiro deslocamento vai corresponder para a propriedade “x”. V8 vai também atualizar “C0” com uma “classe de transição” que declara se uma propriedade “x’ é adicionada para o objeto point, a classe oculta deveria trocar de “C0”para “C1”. A classe oculta para o objeto pint 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 é repetido quando a declaração “this.y = y”é executado (de novo, dentro da função Point depois da declaração “this.x = x”).

Uma nova classe oculta chamada “C2” é criada, uma classe de transição é adicionada para “C1” declarando que se uma propriedade ÿ” é adicionada para o objeto Point (que já contém a propriedade “x”) então a classe oculta deveria mudar para “C2”, e as classes ocultas dos objetos Point são atualizadas para “C2”.

Transições de classe oculta são dependentes da ordem nas quais as propriedades são adicionadas para 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 vamos assumir que para ambos p1 e p2 aa mesmas classes oculta e de transição seriam utilizadas. Bem, na verdade não. Para “p1”, primeiro a propriedade ä”vai ser adicionada e então a propriedade “b”. Para “p2”, no entanto, primeiro “b”está sendo atribuído, seguido por “a”. Portanto, “p1”e “p2”terminam com diferentes classes ocultas como um resultado de diferentes caminhos de transições. Em tais casos, é muito melhor inicializar propriedades dinâmicas na mesma ordem para que então as classes ocultas possam ser reutilizadas.

Inline Caching

V8 toma vantagem de outra técnica para otimizar dinamicamente linguagens tipadas chamada inline caching. Inline caching depende de que repetidas chamadas para o mesmo método tendem a ocorrer para o mesmo tipo de objeto. Uma explicação em profundidade de Inline Caching pode ser encontrada aqui.

Nós vamos abordar de forma geral o conceito inline cache (neste caso você não ter tempo de ir para a explicação aprofundada).

Então como isso funciona ? V8 mantém um cache do tipo de objetos que são passados como parâmetros em chamadas recentes de métodos e usa essa informação para fazer uma suposição sobre o tipo de objeto que vai ser passada como parâmetro no futuro. Se V8 está habilitado a fazer uma boa suposição sobre o tipo de objeto que vai ser passado para um método, ele pode ignorar o processo de como acessar as propriedades do objeto , e ao invés disso, usar a informação armazenada de pesquisas anteriores para a classe oculta do objeto.

Então como os conceitos de classe oculta e inline cache estão relacionados? Qualquer método que é chamado sobre um objeto específico, a engine V8 tem que realizar uma pesquisa para a classe oculta desse objeto a fim de determinar o deslocamento para acessar uma propriedade específica. Depois de chamadas bem sucedidas do mesmo método para a mesma classe oculta, V8 omite a pesquisa de classe oculta e simplesmente adiciona o deslocamento da propriedade do próprio objeto Point. Para todas as futuras chamadas desse método, a engine V8 supões que a classe oculta não mudou, e pula diretamente para o endereço de memória para uma específica propriedade usando o deslocamento armazenado das pesquisas anteriores. Isso aumenta muito a velocidade de execução.

Inline caching é também a razão porque é tão importante que objetos do mesmo tipo compartilhem classes ocultas. Se você cria dois objetos do mesmo tipo com diferentes classes ocultas (como nós vimos no exemplo anterior), V8 não vai estar habilitado a usar o inline caching porque mesmo os objetos sendo do mesmo tipo, suas correspondentes classes ocultas atribuem deslocamentos diferentes para suas propriedades.

Os dois objetos são basicamente os mesmos, mas as propriedades “a” e “b” são criadas em diferentes ordem.

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

Uma vez que o gráfico Hyfrogen está otimizado, Crankshaft baixa ele para uma representação baixo nível chamada Lithium. A maior parte da implementação do Lithium é arquitetura específica. Alocação de registros acontece nesse nível.

No final, Lithium é compilado em código de máquina. Então alguma coisa mais acontece chamada OSR: on-stack replacement. Antes de começarmos a compilar e otimizar um método obviamente de longa duração, nós provavelmente estávamos executando ele. V8 não vai esquecer o que ele executou lentamente para começar de novo com a versão otimizada. Ao invés disso, ele vai transformar todo o contexto que nós temos (stack, registers) para que possamos trocar para a versão otimizada no meio da execução. Essa é uma tarefa muito complexa, tendo em mente que entre outras otimizações, V8 tem embutido o código inicialmente. V8 não é a única engine capaz de fazer isso.

Há garantias chamadas deoptimization para fazer a transformação oposta e reverter para o código não otimizado caso uma suposição da engine não seja mais verdadeira.

Garbage collection (Coleta de lixo)

Para a garbage collection, V8 usa uma abordagem geracional tradicional de marca e varredura para limpar a velha geração. A fase de marcação deve interromper a execução do Javascript. A fim de controlar os custos de GC (Garbage Collection) e fazer a execução mais estável, V8 usa marcação incremental: ao invés de percorrer todo o heap, tentando marcar cada possível objeto, ele apenas marca parte do heap, e então volta para a execução normal. A próxima parada do GC ai continuar de onde o passo anterior do Heap parou. isso permite muitas pausas curtas durante a execução normal. Como mencionado antes, a fase de varredura é tratada por threads separadas.

Ignition e TurboFan

Com a release da V8 5.9 no início de 2017, um novo pipeline de execução foi introduzido. Esse novo pipeline consegue até mais melhorias de performance e significativas economias de memória no mundo real das aplicações Javascript.

O novo pipeline de execução é construído sobre o Ignition, um interpretador da V8, e TurboFan, o mais novo compilador de otimização da V8.

Você pode verificar o post do time da V8 sobre esse tópico aqui.

Desde que a versão 5.9 da V8 saiu, full-codegen e Crankshaft ( as tecnologias que tem servido V8 desde 2010) não tem sido mais utilizadas por V8 para a execução do Javascript pois o time da V8 tem lutado para acompanhar as novas funcionalidades da linguagem Javascript e as otimizações necessárias dessas funcionalidades.

Isso significa que de forma geral, a V8 vai ter uma arquitetura muito mais simples e de fácil manutenção no futuro.

Essas melhorias são apenas o começo. Os novos pipelines ignition e TuboFan abrem o caminho para futuras otimizações que vão impulsionar a performance do Javascript e reduzirão o impacto do V8 no Chrome e no Node.js nos próximos anos.

Finalmente, aqui estão algumas dicas de como escrever código bem otimizado, Javascript melhor. Você pode facilmente derivar elas do conteúdo acima, no entanto, aqui está um resumo para sua conveniência:

Como escrever Javascript otimizado

  1. Ordem das propriedades de objetos: sempre instancie suas propriedades de objetos na mesma ordem para que as classes ocultas e o subsequente código otimizado possa ser compartilhado.
  2. Propriedades dinâmicas: adicionando propriedades para um objeto depois da instanciação vai forçar uma classe oculta a mudar e desacelerar qualquer método que estava otimizado pela classe oculta anterior. Ao invés disso, atribua todas as propriedades de um objeto no seu construtor.
  3. Métodos: código que executa o mesmo método várias vezes vai executar mais rápido do que código que executa muitos métodos diferentes uma única vez (devido ao inline caching).
  4. Arrays: evite array esparsas onde as chaves não são números incrementais. Arrays esparsas que não possuem todos os elementos dentro delas são um hash.
  5. Valores marcados: V8 representa objetos e números com 32 bits. Ele usa um bit para saber se é um objeto (flag = 1) ou um inteiro (flag=0) chamado SMI (Small Integer) por causa de seus 31 bits. Então se valor numérico é maior que 31 bits, V8 vai marcar o número, transformando ele em um double e criando um novo objeto para colocar o número dentro. Tente usar números assinados de 31 bit sempre que possível para evitar marcações de alto custo dentro de um objeto JS.

Nós na SessionStack tentamos seguir essas melhores práticas escrevendo código Javascript altamente otimizado. A razão é que uma vez que você integra SessionStack em sua web app de produção, ele começa a registrar todas as coisas: todas as mudanças no DOM, interações do usuário, exceptions Javascript, stack traces, requisições de rede que falharam e mensagens de debug.

Com SessionStack, você pode reproduzir problemas em suas web apps como videos e ver tudo que acontece para seu usuário. E tudo isso acontece sem nenhum impacto de performance para sua web app.

Há um plano grátis que permite que você comece de graça.

Referências

Este é um artigo traduzido com a autorização do autor. O artigo original pode ser lido em https://blog.sessionstack.com/how-javascript-works-inside-the-v8-engine-5-tips-on-how-to-write-optimized-code-ac089e62b12e

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