Otimizando seu JavaScript

No Google I/O de 2012 Daniel Clifford deu uma palestra com muitas dicas de como quebrar as barreiras de velocidade do seu JavaScript no V8, a incrível engine open source do Google, que é utilizada tanto no Chrome como no Node.js. Revelando como já é possível comparações impensáveis antes, como a velocidade de execução de um código Javascript com um em C++.

Apesar da palestra já ter alguns anos e da velocidade de como as coisas evoluem em nossa área, o conteúdo dela continua sendo uma bela referência, tanto que ela ainda está na home do V8 nas páginas de desenvolvimento do Google.

Nesse artigo irei listar os principais pontos dessa palestra bem como algumas atualizações e informações extras de outros artigos.

Classes Ocultas

Sendo uma linguagem dinâmica, Javascript tem pouca informação sobre os tipos de sua aplicação, sendo impossível fugir do jargão: "com grandes poderes vem grandes responsabilidades". E apesar das grandes vantagens que isso trás, e esse é um dos principais motivos pelo qual Javascript nunca será mais rápido que as linguagens fortemente tipadas.

Porém, existem formas de minimizar o problema, e a maneira como o V8 lida com essa questão internamente é criando Classes Ocultas em tempo de execução, cada vez que um atributo for adicionado ou removido na instância de um objeto ou em seu protótipo, e otimizando a execução para as classes/tipos que são mais utilizadas.

Veja o exemplo abaixo:

function Point(x, y) {  
  this.X = x; // classe oculta A criada
  this.Y = y; // classe oculta B criada
}

var p1 = new Point(11, 22); // usando classe oculta B  
var p2 = new Point(33, 44); // usando classe oculta B

p1.Z = 55; // classe oculta C criada  
// p1 e p2 agora usam classes ocultas diferentes!

Tenha em mente que criar os mesmo atributos em ordens diferentes também gera classes ocultas distintas. Ou seja, quanto mais estáveis e previsíveis forem os seus objetos, menos classes ocultas serão criadas, mais elas serão reaproveitadas, e maiores serão as otimizações que o V8 fará no seu código, como veremos mais adiante.

Para tirar o máximo proveito das classes ocultas:

  • Inicialize todos os seus atributos em uma função construtora, mesmo aqueles que serão utilizados no futuro em condições específicas.
  • Inicialize todos seus atributos sempre na mesma ordem, evitando condicionais e loopings nesse momento.
  • Não delete atributos de seus objetos.

Números

O V8 demarca cada valor com o seu tipo correspondente que foi inferido com uma tag, guardada em uma estrutura de 32 bits, onde apenas 1 bit é utilizado. Ao utilizar números inteiros pequenos, de até 31 bits, a mesma estrutura de dados da tag é aproveitada, tornando o acesso ao dado muito simples e rápido.

Quando utilizamos inteiros grandes ou decimais, com ponto flutuante, ele será encapsulado na estrutura padrão utilizada por todos os outros tipos. Ou seja, dê preferência a números inteiros pequenos sempre que possível e tire o máximo de proveito deles.

Arrays

Internamente o V8 tem dois modos diferente de lidar com Arrays:

  • Elementos Rápidos: armazenamento linear para chaves compactas.
  • Elementos Dicionário: armazenamento em hash table.

A primeira opção é a mais rápida, permitindo um fácil acesso a cada elemento, enquanto a segunda tem a vantagem de ser mais enxuta, ocupando menos espaço de memória, e será utilizada sempre que a quantidade de dados for muito grande, ou não for utilizado um índice numérico contínuo partindo do zero.

Resumindo, para usar o tipo de array mais rápida siga as seguintes regras:

  • Use índices numéricos contínuos a partir de 0.
  • Não pré-aloque arrays muito grandes com mais de 64 mil itens.
  • Não delete itens da array diretamente, use Array.splice quando preciso.
  • Não tente acessar indices não iniciados ou deletados do array.

Para entender melhor o último item da lista acima, observe o código abaixo:

a = new Array();  
for (var b = 0; b < 10; b++) {  
  a[0] |= b;  // Funciona mesmo o índice 0 ainda não existindo
}
//vs.
a = new Array();  
a[0] = 0;  
for (var b = 0; b < 10; b++) {  
  a[0] |= b;  // Mesmo resultado, porém 2x mais rápido!
}

Arrays também usam o sistema de tipos interno do V8 de classes ocultas, existindo 3 principais, o mais rápido deles é para alocação apenas de inteiros, seguido pelo de decimais, que também pode armazenar inteiros, e por último o de objetos, o mais pesado de todos, que pode alocar qualquer coisa.

let a = new Array();  
// Sem dados nada foi alocado ainda.

a[0] = 77;   // Primeira alocação  
a[1] = 88;  
a[2] = 0.5;  // Realoca...  
a[3] = true; // Realoca...  

Cada vez que muda de um tipo mais leve para um mais pesado TODA a array será realocada, e quanto maior a quantidade de dados, mais custoso será o processo. Caso já saiba desde o início os dados a serem inseridos, utilize a seguinte sintaxe, tendo apenas uma alocação:

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

Resumindo:

  • Inicialize arrays pequenas de modo literal sempre que possível.
  • Pré-aloque arrays pequenas (< 64k) com seu tamanho correto caso ele seja conhecido.
  • Evite realocações armazenando objetos em arrays numéricos.

Duplamente Compilado

Apesar de Javascript ser uma linguagem muito dinâmica e tradicionalmente suas engines interpretarem o código linha a linha, todos as engines modernas compilam o código gerando algum bytecode interno através do qual é feita a execução. No V8 esse processo é feito em duas partes:

  • O compilador "Completo": o primeiro compilador que gera um bom código genérico para executar qualquer JavaScript.
  • O compilador Otimizador: que produz um código ainda melhor, mas que demora mais para compilar.

O Compilador Completo

Esse compilador começa o mais rápido possível e passa por todo o código, inicialmente assumindo quase nada sobre os tipos de dados, esperando que eles podem e irão mudar durante a execução, gerando novas classes ocultas sempre que preciso.

No entanto, esse código inicial gerado já tem inteligência para identificar padrões de execução e tipos de dados constantes, gerando de caches de execução rápida, chamado em inglês de Inline Caches ou ICs, capazes de otimizar a execução do código em algumas centenas de vezes, em tempo de execução.

Inline Caches já começam a presumir o tipo de dados existentes em cada operação e variável, porém ele ainda valida cada presunção e depois utiliza o melhor cache disponível para aquele bloco. Mudanças constantes de tipos impedem que as otimizações realizadas sejam utilizadas quando uma presunção é invalidada, bem como que novas sejam criadas.

Ao falarmos sobre variações de tipo, não se limite apenas a declaração de variáveis dentro de um bloco ou função, mas também na passagem de argumentos:

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

add(1, 2);      // Operação Monomórfica  
add("a", "b");  // Operação Polimórfica  

Quando uma mesma operação é chamada com tipos de dados diferentes se diz que ela é polimórfica, e nunca será colocada inline, ou seja, em um único comando de cache, como as monomórficas podem. Operações monomórficas sempre serão mais performáticas.

O Compilador Otimizador

Paralelamente ao primeiro compilador, o V8 recompila as funções "quentes", aquelas que rodam com mais constância, só que dessa com o compilador otimizado, tomando proveito do resultado dos Inline Caches, só que dessa vez sem fazer as checagens iniciais, especulando que os tipos de dados identificados não irão mais mudar, e as atuais operações monomórficas não se tornarão polimórfica.

A partir disso várias outras pequenas otimizações são possíveis, funções monomórficas podem ser otimizadas ao máximo e executadas em uma única chamada. O código gerado por esse segundo compilador pode chegar a ser 2x mais rápido que o anterior.

É possível visualizar essas otimizações sendo criadas em tempo de execução, através de flags passadas para o V8. No caso do NodeJS, use a flag abaixo:

node --trace-opt program.js  

Infelizmente nem todas as funções podem ser otimizadas, sendo que algumas vezes features da própria linguagem podem impedir isso de acontecer, como sintaxes novas do ES2015 que já foram implementadas, mas ainda não foram bem otimizadas.

Às vezes, mesmo algumas features antigas do Javascript podem impedir funções inteiras de serem completamente otimizados, como por exemplo blocos de try / catch e comandos de debugger. Uma maneira de impedir que esses pontos frágeis te sabotem é isolar eles em funções diferentes:

function codigoPerformatico() {  
  // coloque aqui o código que pode ser otimizado
}

try {  
  codigoPerformatico()
} catch (e) { }

Para examinar as funções impedidas de serem otimizadas use a seguinte flag:

node --trace-bailout program.js  

Desotimização

Finalmente, tenha em mente que compilador otimizado trabalha com especulações, e elas podem se mostrar falsas, forçando uma "desotimização" do bytecode gerado para a versão anterior do compilador "completo", processo que por si só pode tomar algum tempo e provocar uma lentidão extra. O compilador otimizado pode ser ativado novamente nesses trechos em um próximo ciclo, utilizando as novas classes ocultadas geradas.

Tire o máximo de proveito das características dinâmicas da linguagem, mas seja explicito e previsível ao fazê-lo, e evite mudar as classes ocultas internas do V8 aleatoriamente, para assim aproveitar ao máximo sua performance.

Para rastrear as desotimizações:

node --trace-deopt program.js  

Outras formas de rastrear o V8

Para ver todas as opções do V8 que podem ser passadas pelo NodeJS:

node --v8-options  

Também é possível abrir o Chrome com as flags de profile:

"/Applications/.../Google Chrome" --js-flags="--prof"

O conselho mais importante

Sabendo como V8 funciona internamente, você será capaz de identificar e corrigir os principais problemas de performance, bem como evitar desde o início os principais problemas. Mas antes de tudo precisamos colocar qualquer problema de performance em contexto.

Podemos ficar viciados em dicas técnicas de performance e nos distrair dos problemas reais do dia a dia, deixando de olhar o sistema de uma maneira holística, atento a TODOS os possíveis gargalos do sistema, como manipulação de DOM, tempos de requisição, parse de dados e etc.

Mas além de todas as questões técnicas, um entendimento profundo do domínio que estamos trabalhando, evitando looping e tratamentos desnecessários, pode otimizar nosso código mais do que todo o resto.

William Grasel

Read more posts by this author.