Introdução
Daniel Clifford deu uma excelente palestra no Google I/O sobre dicas e sugestões para melhorar o desempenho do JavaScript no V8. Daniel nos incentivou a "exigir mais velocidade", ou seja, analisar cuidadosamente as diferenças de desempenho entre C++ e JavaScript e escrever códigos considerando o funcionamento do JavaScript. Este artigo resume os pontos mais importantes da palestra de Daniel e será atualizado conforme as orientações de performance mudarem.
O conselho mais importante
É importante contextualizar qualquer conselho de desempenho. As dicas de desempenho são viciantes, e, às vezes, focar em dicas profundas primeiro pode ser uma distração dos problemas reais. É preciso ter uma visão holística do desempenho do seu aplicativo da Web. Antes de se concentrar nessas dicas de desempenho, analise seu código com ferramentas como o PageSpeed e aumente sua pontuação. Isso ajuda a evitar otimizações prematuras.
O melhor conselho básico para ter um bom desempenho em aplicativos da Web é:
- Prepare-se antes de ter (ou perceber) um problema
- Em seguida, identifique e entenda o cerne 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ódigos considerando o design do ambiente de execução do JS. Também é importante conhecer as ferramentas disponíveis e como elas podem ajudar você. Daniel explica melhor como usar as ferramentas para desenvolvedores na palestra dele. Este documento apenas captura alguns dos pontos mais importantes do design do mecanismo V8.
Então, vamos às dicas do V8!
Turmas ocultas
O JavaScript tem informações limitadas sobre o tipo de tempo de compilação: os tipos podem ser alterados no ambiente de execução, então é natural esperar que seja caro raciocinar sobre os tipos de JS no tempo de compilação. Isso pode levar você a questionar como o desempenho do JavaScript poderia chegar perto do C++. No entanto, o V8 tem tipos ocultos criados internamente para objetos no ambiente de 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 o membro extra ".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 o código JavaScript que manipula p1 ou p2. Quanto mais você evitar que as classes ocultas divirjam, melhor será o desempenho.
Portanto,
- Inicialize todos os membros do objeto em funções de construtor (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 maneira eficiente quando os tipos podem mudar. O V8 infere dos valores que você usa o tipo de número com que está lidando. Depois que o V8 faz essa inferência, ele usa tags para representar os valores de maneira eficiente, porque esses tipos podem mudar dinamicamente. No entanto, às vezes há um custo para mudar essas tags de tipo. Por isso, é melhor usar tipos de número de forma consistente e, em geral, é mais adequado 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,
- Dê preferência a 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:
- Elementos rápidos: armazenamento linear para conjuntos de chaves compactos
- Elementos do dicionário: armazenamento de tabelas de hash
É melhor não fazer com que o armazenamento da matriz mude de um tipo para outro.
Portanto,
- Usar chaves contíguas que começam em 0 para matrizes
- Não pré-aloque matrizes grandes (por exemplo, > 64 mil elementos) para o tamanho máximo.Em vez disso, aumente conforme 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 doubles são mais rápidas: a classe oculta da matriz rastreia os tipos de elementos, e as matrizes que contêm apenas doubles são unboxed (o que causa uma mudança de classe oculta). No entanto, a manipulação descuidada de matrizes pode causar trabalho extra devido ao 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 doubles sem conversão, 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 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
- Tome cuidado para não causar a reconversão de matrizes pequenas se você inicializar sem literais.
Compilação de JavaScript
Embora o JavaScript seja uma linguagem muito dinâmica e as implementações originais dele fossem interpretadores, os mecanismos de execução modernos do JavaScript usam compilação. O V8 (JavaScript do Chrome) tem dois compiladores JIT (Just-In-Time) diferentes:
- O compilador "Full", que pode gerar um bom código para qualquer JavaScript.
- O compilador de otimização, que produz um ótimo código para a maioria dos JavaScripts, mas leva mais tempo para compilar.
O compilador completo
No V8, o compilador completo é executado em todo o código e começa a execução o mais rápido possível, gerando rapidamente um código bom, mas não excelente. Esse compilador não assume quase nada sobre os tipos no tempo de compilação. Ele espera que os tipos de variáveis possam mudar e mudem no tempo de execução. O código gerado pelo compilador completo usa caches inline (ICs) para refinar o conhecimento sobre os 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, em seguida, usa o cache inline para atalhar 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 é preferível 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 "hot" (ou seja, funções que são executadas muitas vezes) com um compilador de otimização. Esse compilador usa o feedback de tipo para acelerar o código compilado. Na verdade, ele usa os tipos extraídos dos ICs que acabamos de mencionar.
No compilador de otimização, as operações são inseridas de forma especulativa (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 (esse é outro motivo pelo qual o monomorfismo é uma boa ideia no V8).
É possível registrar o que é otimizado usando a versão independente "d8" 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 (uma "saída"). Em especial, o compilador de otimização atualmente não funciona em 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() { // Do performance-sensitive work here }
try { perf_sensitive() } catch (e) { // Handle exceptions here } ```
Essa orientação provavelmente vai mudar no futuro, quando ativarmos os blocos try/catch no compilador de otimização. É possível examinar como o compilador de otimização está saindo das funções usando a opção "--trace-opt" com d8, como acima, o que fornece mais informações sobre quais funções foram abandonadas:
d8 --trace-opt primes.js
Desotimização
Por fim, 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 no código do compilador "completo". A reotimização pode ser acionada novamente mais tarde, mas, no curto prazo, a execução fica mais lenta. Em particular, causar mudanças nas classes ocultas de variáveis depois que as funções forem otimizadas vai causar essa desotimização.
Portanto,
- Evite mudanças de classe ocultas em funções depois que elas forem otimizadas.
Assim como em outras otimizações, é possível receber um registro de funções que o V8 precisou desotimizar com uma flag de registro:
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 para fazer isso:
% out/ia32.release/d8 primes.js --prof
Isso 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 é:
- Prepare-se antes de ter (ou perceber) um problema
- Em seguida, identifique e entenda o cerne 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, primeiro. Se possível, reduza para JavaScript puro (sem DOM) antes de coletar métricas e 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 o JavaScript, mas não se esqueça de focar também na otimização dos seus próprios algoritmos.