Dicas de desempenho para JavaScript no V8

Chris Wilson
Chris Wilson

Introdução

Daniel Clifford deu uma excelente palestra no Google I/O sobre dicas e truques para melhorar o desempenho do JavaScript no V8. Daniel nos incentivou a "exigir mais rápido", para analisar cuidadosamente as diferenças de desempenho entre C++ e JavaScript, e escrever o código conscientemente de como o JavaScript funciona. Um resumo dos pontos mais importantes da palestra de Daniel está neste artigo, e nós o manteremos atualizado à medida que a orientação de desempenho mudar.

O conselho mais importante

É importante colocar todas as dicas sobre performance em contexto. Os conselhos de desempenho são viciantes, e, às vezes, focar em conselhos profundos pode ser uma distração para os problemas reais. Você precisa ter uma visão holística do desempenho de seu aplicativo da Web. Antes de se concentrar nestas dica de desempenho, você provavelmente deve analisar seu código com ferramentas como PageSpeed e aumentar sua pontuação. Isso ajudará a evitar a otimização prematura.

O melhor conselho básico para obter um bom desempenho em aplicativos da Web é:

  • Esteja preparado antes que tenha (ou perceba) um problema
  • Depois, identifique e entenda a raiz do problema
  • Por fim, corrija o que importa

Para realizar essas etapas, é importante entender como o V8 otimiza o JS. Assim, você pode escrever o código ciente do design do tempo de execução do JS. Também é importante aprender sobre as ferramentas disponíveis e como elas podem ajudar você. Daniel entra em mais algumas explicações sobre como usar as ferramentas do desenvolvedor em sua palestra. Este documento apenas captura alguns dos pontos mais importantes do projeto do mecanismo V8.

Então, vamos às dicas sobre o V8!

Classes ocultas

O JavaScript tem informações limitadas sobre o tipo em tempo de compilação: os tipos podem ser alterados em tempo de execução, portanto, é natural pensar que é caro entender os tipos de JS no momento da compilação. Isso pode levar você a questionar como o desempenho do JavaScript pode chegar perto do C++. No entanto, o V8 tem tipos ocultos criados internamente para objetos em tempo de execução. Objetos com a mesma classe oculta podem usar o mesmo código gerado otimizado.

Exemplo:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

var p1 = new Point(11, 22);
var p2 = new Point(33, 44);
// At this point, p1 and p2 have a shared hidden class
p2.z = 55;
// warning! p1 and p2 now have different hidden classes!```

Até que a instância de objeto p2 tenha o membro ".z" adicional, p1 e p2 internamente terão a mesma classe oculta, ou seja, o V8 pode gerar uma única versão de assembly otimizado para o código JavaScript que manipula p1 ou p2. Quanto mais você evitar fazer com que as classes ocultas divergem, melhor será o desempenho.

Portanto,

  • Inicializar todos os membros do objeto em funções construtoras (para que as instâncias não mudem de tipo depois)
  • Sempre inicialize os membros do objeto na mesma ordem

Números

O V8 usa tags para representar valores de forma eficiente quando os tipos podem mudar. O V8 infere a partir dos valores que você usa com qual tipo de número está lidando. Depois que o V8 faz essa inferência, ele usa a inclusão de tags para representar os valores com eficiência, porque esses tipos podem mudar dinamicamente. No entanto, às vezes há um custo para alterar essas tags de tipo, portanto, é melhor usar os tipos de números de forma consistente e, em geral, é mais ideal usar números inteiros assinados de 31 bits quando apropriado.

Exemplo:

var i = 42;  // this is a 31-bit signed integer
var j = 4.2;  // this is a double-precision floating point number```

Portanto,

  • Prefira valores numéricos que possam ser representados como números inteiros assinados de 31 bits.

Matrizes

Para lidar com matrizes grandes e esparsas, há dois tipos de armazenamento interno de matriz:

  • Elementos rápidos: armazenamento linear para conjuntos de chaves compactos
  • Elementos do dicionário: armazenamento da tabela de hash, caso contrário

É melhor não fazer com que o armazenamento da matriz mude de um tipo para outro.

Portanto,

  • Usar chaves contíguas a partir de 0 para matrizes
  • Não aloque matrizes grandes (por exemplo, com mais de 64 mil elementos) para o tamanho máximo. Em vez disso, cresça conforme o uso
  • Não exclua elementos em matrizes, especialmente matrizes numéricas.
  • Não carregue elementos não inicializados ou excluídos:
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // Oh no!
}
//vs.
a = new Array();
a[0] = 0;
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // Much better! 2x faster.
}

Além disso, as matrizes de doubles são mais rápidas. A classe oculta da matriz rastreia os tipos de elemento, e as matrizes que contêm apenas elementos duplos são desmarcadas (o que causa uma mudança de classe oculta). No entanto, a manipulação descuidada de matrizes pode causar trabalho extra devido a boxing e unboxing. Por exemplo,

var a = new Array();
a[0] = 77;   // Allocates
a[1] = 88;
a[2] = 0.5;   // Allocates, converts
a[3] = true; // Allocates, converts```

é menos eficiente do que:

var a = [77, 88, 0.5, true];

porque, no primeiro exemplo, as atribuições individuais são realizadas uma após a outra, e a atribuição de a[2] faz com que a matriz seja convertida em uma matriz de cópias não caixas, mas a atribuição de a[3] faz com que ela seja reconvertida em uma matriz que possa conter qualquer valor (números ou objetos). No segundo caso, o compilador conhece os tipos de todos os elementos do literal e a classe oculta pode ser determinada antecipadamente.

  • Inicializar usando literais de matriz para matrizes de tamanho fixo pequenas
  • Pré-alocar matrizes pequenas (menos de 64k) para corrigir o tamanho antes de usá-las
  • Não armazene valores não numéricos (objetos) em matrizes numéricas.
  • Tenha cuidado para não causar uma nova conversão de matrizes pequenas se você inicializar sem literais.

Compilação em JavaScript

Embora o JavaScript seja uma linguagem muito dinâmica e suas implementações originais sejam intérpretes, os mecanismos modernos de tempo de execução do JavaScript usam a compilação. O V8 (JavaScript do Chrome) tem dois compiladores Just-In-Time (JIT) diferentes, na verdade:

  • O compilador "completo", que pode gerar um bom código para qualquer código
  • O compilador Optimizing, que produz um ótimo código para a maioria do JavaScript, mas leva mais tempo para compilar.

O compilador completo

No V8, o compilador completo é executado em todo o código e começa a executá-lo o mais rápido possível, gerando rapidamente códigos bons, mas não ótimos. Esse compilador não pressupõe quase nada sobre tipos no momento da compilação. Ele espera que os tipos de variáveis possam e mudem no tempo de execução. O código gerado pelo compilador completo usa caches inline (ICs) para refinar o conhecimento sobre tipos durante a execução do programa, melhorando a eficiência em tempo real.

O objetivo dos caches em linha é lidar com os tipos com eficiência, armazenando em cache o código dependente do tipo para operações. Quando o código é executado, ele valida as suposições de tipo primeiro e, em seguida, usa o cache em linha para abreviar a operação. No entanto, isso significa que as operações que aceitam vários tipos terão um desempenho menor.

Portanto,

  • O uso monomórfico de operações tem preferência sobre as operações polimórficas

As operações são monomórficas quando as classes ocultas de entradas são sempre as mesmas. Caso contrário, são polimórficas, o que significa que alguns dos argumentos podem mudar de tipo em diferentes chamadas para a operação. Por exemplo, a segunda chamada add() neste exemplo causa polimorfismo:

function add(x, y) {
  return x + y;
}

add(1, 2);      // + in add is monomorphic
add("a", "b");  // + in add becomes polymorphic```

O compilador de otimização

Em paralelo com o compilador completo, o V8 recompila funções "quentes" (ou seja, funções que são executadas muitas vezes) com um compilador de otimização. Esse compilador usa feedback de tipo para tornar o código compilado mais rápido. Na verdade, ele usa os tipos extraídos de ICs sobre os quais acabamos de falar.

No compilador de otimização, as operações são inline especulativamente (colocadas diretamente onde são chamadas). Isso acelera a execução (ao custo de consumo de memória), mas também permite outras otimizações. Funções e construtores monomórficos podem ser totalmente embutidas (esse é outro motivo pelo qual o monomorfismo é uma boa ideia no V8).

Você pode registrar o que é otimizado usando a versão autônoma "d8" do mecanismo V8:

d8 --trace-opt primes.js

(isso registra nomes de funções otimizadas no stdout).

No entanto, nem todas as funções podem ser otimizadas. Alguns recursos impedem que o compilador de otimização seja executado em uma determinada função (um "bail-out"). Em particular, o compilador de otimização atualmente resgata funções com blocos try {} catch {}!

Portanto,

  • Insira o código sensível ao desempenho em uma função aninhada se tiver tentado {} blocos de captura {}: ```js function perf_sensitive() { // Faça trabalhos sensíveis ao desempenho aqui }

try { perf_sensitive() } catch (e) { // Gerenciar exceções aqui } ```

Essa orientação provavelmente mudará no futuro, já que habilitamos blocos try/catch no compilador de otimização. Você pode examinar como o compilador de otimização está usando a opção "--trace-opt" com o d8, conforme mostrado acima, que fornece mais informações sobre quais funções foram baixadas:

d8 --trace-opt primes.js

Desotimização

Por fim, a otimização realizada por esse compilador é especulativa. Às vezes, ela não funciona, e desistimos. O processo de "desotimização" descarta o código otimizado e retoma a execução no local certo no código do compilador "completo". A reotimização pode ser acionada novamente mais tarde, mas, por um curto prazo, a execução fica mais lenta. Em particular, causar alterações nas classes ocultas de variáveis após a otimização das funções fará com que ocorra essa desotimização.

Portanto,

  • Evitar mudanças de classe ocultas nas funções após a otimização delas

Assim como em outras otimizações, é possível conseguir um registro das funções que o V8 precisou desotimizar usando uma flag de geração de registros:

d8 --trace-deopt primes.js

Outras ferramentas do V8

A propósito, você também pode transmitir opções de rastreamento do V8 para o Chrome na inicialização:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```

Além de usar a criação de perfil das ferramentas para desenvolvedores, você também pode usar o d8:

% out/ia32.release/d8 primes.js --prof

Ele usa o criador de perfil de amostragem integrado, que coleta uma amostra a cada milissegundo e grava v8.log.

Em resumo

É importante identificar e entender como o mecanismo V8 funciona com seu código para se preparar para criar um JavaScript de alto desempenho. Mais uma vez, o conselho básico é:

  • Esteja preparado antes que tenha (ou perceba) um problema
  • Depois, identifique e entenda a raiz do problema
  • Por fim, corrija o que importa

Isso significa que você deve garantir que o problema esteja no JavaScript, usando primeiro outras ferramentas, como o PageSpeed, possivelmente reduzindo para JavaScript puro (sem DOM) antes de coletar métricas e, em seguida, usar essas métricas para localizar gargalos e eliminar os importantes. Com sorte, a palestra de Daniel (e este artigo) ajudará você a entender melhor como o V8 executa JavaScript, mas não deixe de se concentrar também na otimização dos seus próprios algoritmos!

Referências