Dicas de desempenho para JavaScript no V8

Chris Wilson
Chris Wilson

Introdução

Daniel Clifford deu uma excelente palestra no Google I/O com dicas e truques para melhorar o desempenho do JavaScript no V8. Daniel nos incentivou a "exigir mais rapidez", ou seja, analisar cuidadosamente as diferenças de desempenho entre C++ e JavaScript e escrever código com atenção ao funcionamento do JavaScript. Este artigo contém um resumo dos pontos mais importantes da palestra de Daniel. Também vamos atualizar este artigo à medida que as orientações de performance forem alteradas.

O conselho mais importante

É importante colocar os conselhos de performance em contexto. Os conselhos de performance são viciantes, e às vezes se concentrar em conselhos detalhados primeiro pode distrair bastante das questões reais. Você precisa ter uma visão holística do desempenho do seu aplicativo da Web. Antes de se concentrar nesta dica de desempenho, é recomendável analisar o código com ferramentas como o PageSpeed e aumentar sua pontuação. Isso ajuda a evitar a otimização prematura.

O melhor conselho básico para ter uma boa performance em aplicativos da Web é:

  • Prepare-se antes de ter (ou notar) um problema
  • Em seguida, identifique e entenda a essência do problema.
  • Por fim, corrija o que importa

Para realizar essas etapas, é importante entender como o V8 otimiza o JS para que você possa escrever código considerando o design do ambiente de execução do JS. Também é importante saber quais ferramentas estão disponíveis e como elas podem ajudar. Daniel explica melhor como usar as ferramentas para desenvolvedores na palestra. Este documento captura apenas alguns dos pontos mais importantes do design do mecanismo V8.

Vamos às dicas do V8.

Turmas ocultas

O JavaScript tem informações limitadas sobre o tipo no momento da compilação: os tipos podem ser alterados no momento da execução. Portanto, é natural esperar que seja caro argumentar sobre os tipos de JS no momento da compilação. Isso pode levar você a questionar como a performance do JavaScript pode se aproximar do C++. No entanto, o V8 tem tipos ocultos criados internamente para objetos no momento da execução. Os 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 um membro adicional ".z" adicionado, p1 e p2 têm internamente a mesma classe oculta. Assim, o V8 pode gerar uma única versão de assembly otimizado para código JavaScript que manipula p1 ou p2. Quanto mais você evitar que as classes ocultas se dividam, melhor será o desempenho.

Portanto

  • Inicialize todos os membros do objeto em funções construtoras para que as instâncias não mudem de tipo mais tarde.
  • Sempre inicialize os membros do objeto na mesma ordem

Numbers

O V8 usa a inclusão de tags para representar valores de maneira eficiente quando os tipos podem mudar. O V8 infere o tipo de número com que você está lidando com base nos valores que você usa. Depois que o V8 faz essa inferência, ele usa a inclusão de tags para representar valores de maneira eficiente, porque esses tipos podem mudar dinamicamente. No entanto, às vezes, há um custo para alterar essas tags de tipo. Por isso, é melhor usar tipos de números de forma consistente. 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 processar matrizes grandes e esparsas, há dois tipos de armazenamento de matrizes internamente:

  • Elementos rápidos: armazenamento linear para conjuntos de chaves compactos
  • Elementos de dicionário: armazenamento de tabelas de hash

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

Portanto

  • Usar chaves contíguas começando em 0 para matrizes
  • Não pré-aloque matrizes grandes (por exemplo, > 64K elementos) no tamanho máximo. Em vez disso, cresça à medida que for necessário
  • 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 números reais são mais rápidas. A classe oculta da matriz rastreia os tipos de elementos, e as matrizes que contêm apenas números reais são desempacotadas (o que causa uma mudança de classe oculta). No entanto, a manipulação descuidada de matrizes pode causar trabalho extra devido ao empacotamento e ao desempacotamento, 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 duplas não empacotadas, mas a atribuição de a[3] faz com que ela seja convertida novamente em uma matriz que pode conter qualquer valor (números ou objetos). No segundo caso, o compilador conhece os tipos de todos os elementos no literal, e a classe oculta pode ser determinada com antecedência.

  • Inicialização usando literais de matrizes para matrizes pequenas de tamanho fixo
  • Pré-alocar matrizes pequenas (<64k) para o tamanho correto antes de usá-las
  • Não armazene valores não numéricos (objetos) em matrizes numéricas
  • Tenha cuidado para não causar a reconversão de matrizes pequenas se você inicializar sem literais.

Compilação do JavaScript

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

  • O compilador "Full", que pode gerar um bom código para qualquer JavaScript
  • O compilador de otimização, que produz um código excelente para a maioria dos JavaScript, mas demora mais para compilar.

O compilador completo

No V8, o compilador completo é executado em todo o código e começa a ser executado assim que possível, gerando rapidamente um código bom, mas não ótimo. Esse compilador não assume quase nada sobre tipos no momento da compilação. Ele espera que os tipos de variáveis possam e vão mudar no momento da execução. O código gerado pelo compilador completo usa caches inline (ICs) para refinar o conhecimento sobre tipos enquanto o programa é executado, melhorando a eficiência em tempo real.

O objetivo dos caches inline é processar tipos de maneira eficiente, armazenando em cache o código dependente de tipo para operações. Quando o código é executado, ele valida as suposições de tipo primeiro e depois usa o cache inline para encurtar 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 é preferido em relação às operações polimórficas

As operações são monomórficas se as classes ocultas de entradas forem sempre as mesmas. Caso contrário, elas 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 executadas muitas vezes) com um compilador de otimização. Esse compilador usa o feedback de tipo para tornar o código compilado mais rápido. Na verdade, ele usa os tipos retirados dos ICs que acabamos de mencionar.

No compilador de otimização, as operações são inline de forma especulativa (colocadas diretamente onde são chamadas). Isso acelera a execução (à custa da pegada de memória), mas também permite outras otimizações. Funções e construtores monomórficos podem ser inline totalmente. Essa é outra razão pela qual o monomorfismo é uma boa ideia no V8.

É possível registrar o que é otimizado usando a versão "d8" independente do mecanismo V8:

d8 --trace-opt primes.js

Isso registra os nomes das 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 "salvamento"). Em particular, o compilador de otimização atualmente sai de funções com blocos try {} catch {}.

Portanto

  • Coloque o código sensível ao desempenho em uma função aninhada se você tiver blocos try {} catch {}: ```js function perf_sensitive() { // Faça o trabalho sensível ao desempenho aqui }

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

Essa orientação provavelmente vai mudar no futuro, à medida que ativamos blocos try/catch no compilador de otimização. É possível examinar como o compilador de otimização está saindo de funções usando a opção "--trace-opt" com o d8, como acima, que fornece mais informações sobre quais funções foram salvas:

d8 --trace-opt primes.js

Desotimização

Por fim, a otimização realizada por esse compilador é especulativa. Às vezes, ela não funciona e precisamos voltar atrás. O processo de "desotimização" descarta o código otimizado e retoma a execução no lugar certo no código "completo" do compilador. A reotimização pode ser acionada novamente mais tarde, mas, a curto prazo, a execução fica mais lenta. Em particular, causar mudanças nas classes ocultas de variáveis depois que as funções foram otimizadas vai causar essa desotimização.

Portanto

  • Evitar mudanças de classe ocultas em funções depois que elas são otimizadas

Assim como em outras otimizações, é possível acessar um registro de funções que o V8 teve que desotimizar com uma flag de registro:

d8 --trace-deopt primes.js

Outras ferramentas do V8

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 o perfil das ferramentas para desenvolvedores, você também pode usar o d8 para fazer o perfil:

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

Isso usa o perfilador de amostragem integrado, que coleta uma amostra a cada milissegundo e grava v8.log.

Resumo

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

  • Prepare-se antes de ter (ou notar) um problema
  • Em seguida, identifique e entenda a essência do problema.
  • Por fim, corrija o que importa

Isso significa que você precisa garantir que o problema esteja no JavaScript usando outras ferramentas, como o PageSpeed. Talvez seja possível reduzir para JavaScript puro (sem DOM) antes de coletar métricas e, em seguida, usar essas métricas para localizar gargalos e eliminar os importantes. Esperamos que a palestra de Daniel (e este artigo) ajude você a entender melhor como o V8 executa o JavaScript, mas não se esqueça de otimizar seus próprios algoritmos também.

Referências