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 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 contextualizar as dicas de desempenho. 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 obter um bom desempenho em aplicativos da Web é:
- Prepare-se antes de ter (ou notar) um problema
- Em seguida, identifique e entenda o problema.
- Por fim, corrija o que for importante
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 aprender sobre as ferramentas disponíveis e como elas podem ajudar você. 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.
Então, vamos para as dicas do V8!
Turmas 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 tem 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 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.
Então
- Inicialize todos os membros do objeto em funções construtoras para que as instâncias não mudem de tipo mais tarde.
- Sempre inicialize membros de objetos 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```
Então
- 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 do dicionário: armazenamento da tabela com hash. Caso contrário,
É 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.
- Inicializar usando literais de matrizes 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 a reconversão de matrizes pequenas se você inicializar sem literais.
Compilação do 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:
- O compilador "Full", 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 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 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 códigos dependentes do tipo para operações em cache. Quando o código é executado, ele primeiro valida as suposições de tipo e depois usa 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.
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 totalmente inline (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 libera as 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. Você pode examinar como o compilador de otimização está salvando as funções usando a opção "--trace-opt" com o d8, como mostrado acima, que fornece mais informações sobre quais funções foram resgatadas:
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 fará com que essa desotimização ocorra.
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 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
Isso usa o perfilador 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 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 o 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 JavaScript, mas não deixe de focar na otimização de seus próprios algoritmos!