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 "demandar mais rápido" - para analisar cuidadosamente as diferenças de desempenho entre C++ e JavaScript e escrever o código com consciência de como o JavaScript funciona. Um resumo dos pontos mais importantes da palestra de Daniel está capturado neste artigo. Ele também será atualizado conforme as orientações de desempenho mudarem.

O conselho mais importante

É importante contextualizar as dicas de desempenho. Os conselhos sobre desempenho são viciantes e, às vezes, dar conselhos profundos primeiro pode desviar a atenção dos problemas reais. Você precisa ter uma visão holística do desempenho de seu aplicativo da Web. Antes de focar nessas dicas de desempenho, você provavelmente deve analisar seu código com ferramentas como o 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 de ter (ou perceber) um problema
  • Em seguida, identifique e entenda o centro do seu problema
  • Por fim, corrija o que for importante

Para realizar essas etapas, pode ser importante entender como o V8 otimiza o JS para que você possa escrever códigos considerando o design do tempo de execução do JS. Também é importante aprender sobre as ferramentas disponíveis e como elas podem ajudar você. Daniel apresenta algumas explicações sobre como usar as ferramentas para desenvolvedores em sua palestra. este documento apenas captura alguns dos pontos mais importantes do projeto do motor V8.

Então, vamos para as dicas do V8!

Classes ocultas

O JavaScript tem informações de tipo limitadas em tempo de compilação: os tipos podem ser alterados em tempo de execução, por isso, é natural que seja caro analisar os tipos de JS no tempo de compilação. Isso pode levar você a questionar como o desempenho do JavaScript pode chegar perto do C++. No entanto, o V8 possui tipos ocultos criados internamente para objetos no 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 do objeto p2 tenha o membro adicional ".z" adicionado, p1 e p2 internamente têm a mesma classe oculta, então 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 divergirem, melhor será o desempenho.

Então

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

Numbers

O V8 usa tags para representar valores com eficiência quando os tipos podem mudar. O V8 infere a partir dos valores que você usa o tipo de número com que você está lidando. Depois que o V8 fizer essa inferência, ele usa a inclusão de tags para representar os valores com eficiência, já que 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 indicado 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```

Então

  • 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 de matriz internamente:

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

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

Então

  • Usar chaves contíguas começando em 0 para matrizes
  • Não pré-aloque matrizes grandes (por exemplo, elementos com mais de 64 K) ao tamanho máximo. Em vez disso, cresça conforme você avança
  • Não exclua elementos de 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, matrizes de "double" são mais rápidas: a classe oculta da matriz rastreia tipos de elemento, e as matrizes contendo apenas "double" são unboxing (o que causa uma mudança de classe oculta). No entanto, a manipulação descuidada das matrizes pode causar trabalho extra devido ao boxe e ao 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 executadas uma após a outra, e a atribuição de a[2] faz com que a matriz seja convertida em uma matriz de doubles sem caixa, mas a atribuição de a[3] faça com que ela seja reconvertida de volta em uma matriz que possa 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 antecipadamente.

  • Inicializar usando literais de matriz para matrizes pequenas de tamanho fixo
  • Pré-aloque 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 reconversão de matrizes pequenas caso a inicialização seja feita sem literais.

Compilação em JavaScript

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

  • A imagem compilador, que pode gerar um bom código para qualquer JavaScript
  • O Optimizing Compiler, 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 executar o código o mais rápido possível, gerando rapidamente códigos bons, mas não ótimos. Esse compilador não supõe quase nada sobre tipos no momento da compilação. Ele espera que os tipos de variáveis possam e mudem durante a execução. O código gerado pelo compilador completo usa caches em linha (ICs) para refinar o conhecimento sobre os tipos durante a execução do programa, melhorando a eficiência em tempo real.

O objetivo dos caches inline é lidar com os tipos de forma eficiente, armazenando em cache códigos dependentes do tipo para operações. quando o código for executado, ele validará as suposições de tipo primeiro e, em seguida, usará o cache inline para criar um atalho para a operação. No entanto, isso significa que as operações que aceitam vários tipos terão um desempenho menor.

Então

  • O uso monomórfico de operações é preferível em relação às 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, 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 os comandos "hot" (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 das ICs que acabamos de abordar.

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

Você pode registrar o que é otimizado usando o "d8" independente do mecanismo V8:

d8 --trace-opt primes.js

(isso registra nomes de funções otimizadas para 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 "livre"). Em particular, o compilador de otimização atualmente libera as funções com blocos try {} catch {}!

Então

  • Coloque o código sensível ao desempenho em uma função aninhada caso tenha tentado blocos {} capturar {}: ```js function perf_sensitive() { // Faça trabalhos sensíveis ao desempenho aqui }

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

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

d8 --trace-opt primes.js

Desotimização

Finalmente, a otimização realizada por esse compilador é especulativa. Às vezes, ela não funciona, e nós recuamos. O processo de "desotimização" descarta o código otimizado e retoma a execução no lugar certo em "full" código do compilador. A nova otimização pode ser acionada novamente mais tarde, mas, no curto prazo, a execução diminui. Em especial, essa desotimização ocorrerá se houver alterações nas classes ocultas de variáveis após a otimização das funções.

Então

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

Assim como em outras otimizações, é possível obter um registro das funções que o V8 precisou desotimizar com uma sinalização de registro:

d8 --trace-deopt primes.js

Outras ferramentas do V8

Aliás, você também pode transmitir as 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, também é possível usar o d8 para isso:

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

Esse código usa o criador de perfil de amostragem integrado, que seleciona uma amostra a cada milissegundo e grava v8.log.

Em resumo

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

  • Esteja preparado antes de ter (ou perceber) um problema
  • Em seguida, identifique e entenda o centro do seu problema
  • Por fim, corrija o que for importante

Isso significa que você deve garantir que o problema está no seu JavaScript usando primeiro outras ferramentas, como o PageSpeed. possivelmente reduzindo para JavaScript puro (sem DOM) antes de coletar métricas. Depois, use essas métricas para localizar gargalos e eliminar os mais importantes. Esperamos que a palestra de Daniel (e este artigo) ajude você a entender melhor como o V8 executa JavaScript, mas não deixe de focar na otimização de seus próprios algoritmos!

Referências