Introdução
Nos últimos anos, os aplicativos da Web foram acelerados consideravelmente. Muitos aplicativos agora são executados com rapidez suficiente para que alguns desenvolvedores se perguntem: "A Web é rápida o suficiente?". Para alguns aplicativos, pode ser, mas, para os desenvolvedores que trabalham com aplicativos de alto desempenho, sabemos que não é rápido o suficiente. Apesar dos avanços incríveis na tecnologia de máquina virtual JavaScript, um estudo recente mostrou que os aplicativos do Google passam entre 50% e 70% do tempo no V8. Seu aplicativo tem um tempo finito, então reduzir os ciclos de um sistema significa que outro sistema pode fazer mais. Lembre-se de que os aplicativos executados a 60 fps têm apenas 16 ms por frame. Caso contrário, ocorre instabilidade. Continue lendo para saber como otimizar o JavaScript e criar perfis de aplicativos JavaScript em uma história de detetives de desempenho da equipe do V8 que rastreiam um problema de desempenho obscuro em Find Your Way to Oz.
Sessão do Google I/O 2013
Apresentei este material no Google I/O 2013. Confira o vídeo abaixo:
Por que a performance é importante?
Os ciclos de CPU são um jogo de soma zero. Se uma parte do sistema usar menos, você poderá usar mais em outra ou ter um desempenho geral mais suave. Executar mais rápido e fazer mais são metas que costumam competir entre si. Os usuários exigem novos recursos, mas também esperam que o aplicativo funcione de maneira mais fluida. As máquinas virtuais JavaScript estão cada vez mais rápidas, mas isso não é motivo para ignorar problemas de desempenho que podem ser corrigidos hoje, como muitos desenvolvedores que lidam com problemas de desempenho em seus aplicativos da web já sabem. Em aplicativos em tempo real com alta taxa de frames, a pressão para que não haja instabilidade é enorme. A Insomniac Games produziu um estudo (em inglês) que mostra que uma taxa de frames sólida e sustentada é importante para o sucesso de um jogo: "Uma taxa de frames sólida ainda é um sinal de um produto profissional e bem feito". Desenvolvedores Web, prestem atenção.
Como resolver problemas de desempenho
Resolver um problema de desempenho é como solucionar um crime. É preciso examinar as evidências com cuidado, verificar as causas suspeitas e testar diferentes soluções. Durante todo o processo, você precisa documentar as medições para garantir que o problema foi realmente corrigido. Há pouca diferença entre esse método e a forma como os detetives criminais resolvem um caso. Os detetives examinam as evidências, interrogam os suspeitos e fazem experimentos na esperança de encontrar a prova definitiva.
CSI V8: Oz
Os incríveis magos que criaram Find Your Way to Oz procuraram a equipe do V8 com um problema de desempenho que não conseguiam resolver sozinhos. Às vezes, o Oz travava, causando instabilidade. Os desenvolvedores do Oz fizeram uma investigação inicial usando o painel Linha do tempo no Chrome DevTools. Ao analisar o uso da memória, eles encontraram o temido gráfico de dente de serra. Uma vez por segundo, o coletor de lixo coletava 10 MB de lixo, e as pausas da coleta de lixo correspondiam à instabilidade. Semelhante à captura de tela abaixo da linha do tempo no Chrome DevTools:
Os detetives do V8, Jakob e Yang, assumiram o caso. O que aconteceu foi uma longa troca de mensagens entre Jakob e Yang, da equipe do V8, e a equipe do Oz. Resumi essa conversa para os eventos importantes que ajudaram a identificar o problema.
Evidência
A primeira etapa é coletar e estudar as evidências iniciais.
Que tipo de aplicativo estamos analisando?
A demonstração do Oz é um aplicativo 3D interativo. Por isso, ele é muito sensível a pausas causadas por coletas de lixo. Lembre-se de que um aplicativo interativo executado a 60 fps tem 16 ms para realizar todo o trabalho do JavaScript e precisa deixar parte desse tempo para o Chrome processar as chamadas de gráficos e desenhar a tela.
O Oz realiza muitos cálculos aritméticos em valores duplos e faz chamadas frequentes para WebAudio e WebGL.
Que tipo de problema de desempenho estamos enfrentando?
Estamos vendo pausas, também conhecidas como quedas de frames ou instabilidade. Essas pausas estão correlacionadas com as execuções da coleta de lixo.
Os desenvolvedores estão seguindo as práticas recomendadas?
Sim, os desenvolvedores do Oz são especialistas em técnicas de desempenho e otimização de VMs de JavaScript. Vale ressaltar que os desenvolvedores do Oz usavam o CoffeeScript como linguagem de origem e produziam código JavaScript com o compilador do CoffeeScript. Isso dificultou um pouco a investigação devido à desconexão entre o código escrito pelos desenvolvedores do Oz e o código consumido pelo V8. O Chrome DevTools agora é compatível com mapas de origem, o que facilitaria esse processo.
Por que o coletor de lixo é executado?
A memória em JavaScript é gerenciada automaticamente para o desenvolvedor pela VM. O V8 usa um sistema comum de coleta de lixo em que a memória é dividida em duas (ou mais) gerações. A geração jovem contém objetos que foram alocados recentemente. Se um objeto sobreviver por tempo suficiente, ele será movido para a geração antiga.
A geração jovem é coletada com uma frequência muito maior do que a geração antiga. Isso é intencional, já que a coleta de geração jovem é muito mais barata. Geralmente, é seguro presumir que as pausas frequentes de GC são causadas pela coleta de geração jovem.
No V8, o espaço de memória jovem é dividido em dois blocos de memória contíguos de tamanho igual. Apenas um desses dois blocos de memória é usado em um determinado momento, e ele é chamado de espaço de destino. Enquanto houver memória restante no espaço de destino, a alocação de um novo objeto será barata. Um cursor no espaço de destino é movido para frente o número de bytes necessários para o novo objeto. Isso continua até que o espaço de destino seja esgotado. Nesse momento, o programa é interrompido e a coleta começa.
Nesse ponto, os espaços de origem e de destino são trocados. O que era o espaço de destino e agora é o espaço de origem é verificado do início ao fim, e todos os objetos que ainda estão ativos são copiados para o espaço de destino ou promovidos para o heap de geração antiga. Se quiser mais detalhes, leia sobre o algoritmo de Cheney (link em inglês).
Intuitivamente, você deve entender que, sempre que um objeto é alocado de forma implícita ou explícita (por uma chamada para new, [] ou {}), seu aplicativo se aproxima cada vez mais de uma coleta de lixo e da temida pausa do aplicativo.
É esperado que esse aplicativo gere 10 MB/s de lixo?
Em resumo, não. O desenvolvedor não está fazendo nada para esperar 10 MB/s de lixo.
Suspeitos
A próxima fase da investigação é determinar os possíveis suspeitos e, em seguida, reduzir as opções.
Suspeito 1
Chamar new durante o frame. Lembre-se de que cada objeto alocado aproxima você de uma pausa de GC. Os aplicativos executados em frame rates altos, em particular, precisam se esforçar para não ter alocações por frame. Normalmente, isso requer um sistema de reciclagem de objetos cuidadosamente pensado e específico para o aplicativo. Os detetives do V8 verificaram com a equipe do Oz, e eles não estavam chamando o new. Na verdade, a equipe do Oz já estava ciente desse requisito e disse: "Isso seria constrangedor". Risque esse da lista.
Suspeito 2
Modificar a "forma" de um objeto fora do construtor. Isso acontece sempre que uma nova propriedade é adicionada a um objeto fora do construtor. Isso cria uma nova classe oculta para o objeto. Quando o código otimizado encontra essa nova classe oculta, uma desotimização é acionada, e o código não otimizado é executado até ser classificado como "hot" e otimizado novamente. Essa desotimização e reotimização resultam em instabilidade,mas não estão estritamente correlacionadas com a criação excessiva de lixo. Depois de uma auditoria cuidadosa do código, foi confirmado que as formas dos objetos eram estáticas, então o suspeito nº 2 foi descartado.
Suspeito 3
Aritmética em código não otimizado. Em código não otimizado, toda computação resulta na alocação de objetos reais. Por exemplo, este snippet:
var a = p * d;
var b = c + 3;
var c = 3.3 * dt;
point.x = a * b * c;
Resulta na criação de cinco objetos HeapNumber. Os três primeiros são para as variáveis a, b e c. A quarta é para o valor anônimo (a * b) e a quinta é de #4 * c. A quinta é atribuída a point.x.
Oz faz milhares dessas operações por frame. Se algum desses cálculos ocorrer em funções que nunca são otimizadas, eles podem ser a causa do lixo. Porque os cálculos em consultas não otimizadas alocam memória até para resultados temporários.
Suspeito nº 4
Armazenar um número de precisão dupla em uma propriedade. Um objeto HeapNumber precisa ser criado para armazenar o número, e a propriedade precisa ser alterada para apontar para esse novo objeto. Alterar a propriedade para apontar para o HeapNumber não vai produzir lixo. No entanto, é possível que muitos números de precisão dupla estejam sendo armazenados como propriedades de objeto. O código está cheio de instruções como esta:
sprite.position.x += 0.5 * (dt);
No código otimizado, sempre que x recebe um valor recém-calculado, uma instrução aparentemente inofensiva, um novo objeto HeapNumber é alocado implicitamente, o que nos aproxima de uma pausa na coleta de lixo.
Ao usar uma matriz tipada (ou uma matriz comum que só tenha doubles), você pode evitar esse problema específico, já que o armazenamento do número de precisão dupla é alocado apenas uma vez, e mudar o valor repetidamente não exige a alocação de um novo armazenamento.
O suspeito nº 4 é uma possibilidade.
Análise forense
Neste ponto, os detetives têm dois possíveis suspeitos: o armazenamento de números de heap como propriedades de objeto e o cálculo aritmético que acontece dentro de funções não otimizadas. Era hora de ir ao laboratório e determinar definitivamente qual suspeito era culpado. OBSERVAÇÃO: nesta seção, vou usar uma reprodução do problema encontrado no código-fonte real do Oz. Essa reprodução é muito menor do que o código original, o que facilita a compreensão.
Experimento 1
Verificando o suspeito nº 3 (cálculo aritmético dentro de funções não otimizadas). O mecanismo JavaScript V8 tem um sistema de geração de registros integrado que pode fornecer informações valiosas sobre o que está acontecendo nos bastidores.
Começando com o Chrome sem estar em execução, inicie o Chrome com as flags:
--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"
e, em seguida, sair totalmente do Chrome, um arquivo de registro v8.log será gerado no diretório atual.
Para interpretar o conteúdo de v8.log, baixe a mesma versão do v8 que o Chrome está usando (verifique about:version) e crie-a.
Depois de criar o v8, processe o registro usando o processador de ticks:
$ tools/linux-tick-processor /path/to/v8.log
(Substitua mac ou windows por linux, dependendo da sua plataforma.) Essa ferramenta precisa ser executada no diretório de origem de nível superior no v8.
O processador de marcações mostra uma tabela baseada em texto das funções JavaScript que tiveram mais marcações:
[JavaScript]:
ticks total nonlib name
167 61.2% 61.2% LazyCompile: *opt demo.js:12
40 14.7% 14.7% LazyCompile: unopt demo.js:20
15 5.5% 5.5% Stub: KeyedLoadElementStub
13 4.8% 4.8% Stub: BinaryOpStub_MUL_Alloc_Number+Smi
6 2.2% 2.2% Stub: BinaryOpStub_ADD_OverwriteRight_Number+Number
4 1.5% 1.5% Stub: KeyedStoreElementStub
4 1.5% 1.5% KeyedLoadIC: {12}
2 0.7% 0.7% KeyedStoreIC: {13}
1 0.4% 0.4% LazyCompile: ~main demo.js:30
O demo.js tinha três funções: opt, unopt e main. As funções otimizadas têm um asterisco (*) ao lado do nome. Observe que a função opt é otimizada e unopt não é.
Outra ferramenta importante no kit do detetive do V8 é o plot-timer-event. Ele pode ser executado assim:
$ tools/plot-timer-event /path/to/v8.log
Depois de executado, um arquivo png chamado timer-events.png é criado no diretório atual. Ao abrir, você verá algo parecido com isto:
Além do gráfico na parte de baixo, os dados são exibidos em linhas. O eixo X é o tempo (ms). O lado esquerdo inclui rótulos para cada linha:
A linha V8.Execute tem uma linha vertical preta desenhada em cada marcação de perfil em que o V8 estava executando o código JavaScript. O V8.GCScavenger tem uma linha vertical azul desenhada em cada marcação de perfil em que o V8 estava realizando uma coleta de nova geração. O mesmo vale para o restante dos estados do V8.
Uma das linhas mais importantes é "code kind being executed". Ele vai ficar verde sempre que um código otimizado estiver em execução e uma mistura de vermelho e azul quando um código não otimizado estiver sendo executado. A captura de tela a seguir mostra a transição do código otimizado para o não otimizado e, em seguida, de volta para o otimizado:
O ideal é que essa linha seja verde sólida, mas isso nunca acontece imediatamente. Isso significa que seu programa passou para um estado estável otimizado. O código não otimizado sempre será executado mais lentamente do que o código otimizado.
Se você chegou até aqui, vale a pena notar que é possível trabalhar muito mais rápido refatorando seu aplicativo para que ele possa ser executado no shell de depuração v8: d8. O uso do d8 oferece tempos de iteração mais rápidos com as ferramentas tick-processor e plot-timer-event. Outro efeito colateral do uso do d8 é que fica mais fácil isolar o problema real, reduzindo a quantidade de ruído presente nos dados.
Ao analisar o gráfico de eventos do timer no código-fonte do Oz, foi possível observar uma transição de código otimizado para não otimizado. Durante a execução do código não otimizado, muitas coletas de nova geração foram acionadas, como mostra a captura de tela a seguir (o tempo foi removido no meio):
Se você olhar com atenção, vai notar que as linhas pretas que indicam quando o V8 está executando o código JavaScript estão ausentes exatamente nos mesmos tempos de tick do perfil que as coletas de nova geração (linhas azuis). Isso demonstra claramente que, enquanto o lixo está sendo coletado, o script é pausado.
Analisando a saída do processador de ticks do código-fonte do Oz, a função principal (updateSprites) não foi otimizada. Em outras palavras, a função em que o programa passou mais tempo também não foi otimizada. Isso indica fortemente que o suspeito nº 3 é o culpado. A origem de updateSprites continha loops como estes:
function updateSprites(dt) {
for (var sprite in sprites) {
sprite.position.x += 0.5 * dt;
// 20 more lines of arithmetic computation.
}
}
Como conhecem o V8 muito bem, eles reconheceram imediatamente que a construção do loop for-i-in às vezes não é otimizada pelo V8. Em outras palavras, se uma função tiver uma construção de loop for-i-in, ela não poderá ser otimizada. Esse é um caso especial hoje e provavelmente vai mudar no futuro, ou seja, o V8 pode um dia otimizar essa construção de loop. Como não somos detetives do V8 e não conhecemos o V8 como a palma da nossa mão, como podemos determinar por que updateSprites não foi otimizado?
Experimento nº 2
Executar o Chrome com esta flag:
--js-flags="--trace-deopt --trace-opt-verbose"
mostra um registro detalhado dos dados de otimização e desotimização. Ao pesquisar os dados de updateSprites, encontramos:
[disabled optimization for updateSprites, reason: ForInStatement is not fast case]
Assim como os detetives suspeitavam, o motivo era o constructo de loop for-i-in.
Caso encerrado
Depois de descobrir o motivo pelo qual updateSprites não foi otimizada, a correção foi simples: basta mover o cálculo para a própria função, ou seja:
function updateSprite(sprite, dt) {
sprite.position.x += 0.5 * dt;
// 20 more lines of arithmetic computation.
}
function updateSprites(dt) {
for (var sprite in sprites) {
updateSprite(sprite, dt);
}
}
updateSprite será otimizado, resultando em muito menos objetos HeapNumber e, consequentemente, em pausas de GC menos frequentes. É fácil confirmar isso realizando os mesmos experimentos com o novo código. O leitor atento vai notar que os números duplos ainda estão sendo armazenados como propriedades. Se a criação de perfil indicar que vale a pena, mudar a posição para uma matriz de doubles ou uma matriz de dados tipados reduziria ainda mais o número de objetos criados.
Epílogo
Os desenvolvedores do Oz não pararam por aí. Com as ferramentas e técnicas compartilhadas pelos detetives do V8, eles conseguiram encontrar algumas outras funções que estavam presas no inferno da desotimização e fatoraram o código de computação em funções de folha que foram otimizadas, resultando em um desempenho ainda melhor.
Vá lá e comece a resolver alguns crimes de desempenho!