Painel de desempenho 400% mais rápido com a perf-ception

Andrés Olivares
Andrés Olivares
Nancy Li
Nancy Li

Independentemente do tipo de aplicativo que você esteja desenvolvendo, otimizar o desempenho dele, garantir que ele seja carregado rapidamente e ofereça interações simples é fundamental para a experiência do usuário e o sucesso do aplicativo. Uma maneira de fazer isso é inspecionar a atividade de um aplicativo usando ferramentas de criação de perfil para ver o que está acontecendo em segundo plano durante uma janela de tempo. O painel Performance no DevTools é uma ótima ferramenta de criação de perfil para analisar e otimizar o desempenho de aplicativos da Web. Se o app estiver em execução no Chrome, ele vai mostrar uma visão geral visual detalhada do que o navegador está fazendo enquanto o aplicativo é executado. Entender essa atividade pode ajudar a identificar padrões, gargalos e pontos de desempenho que podem ser melhorados.

O exemplo a seguir mostra como usar o painel Performance.

Como configurar e recriar nosso cenário de criação de perfil

Recentemente, definimos uma meta para melhorar o desempenho do painel Performance. Em particular, queríamos que ele carregasse grandes volumes de dados de desempenho mais rapidamente. Esse é o caso, por exemplo, ao criar perfis de processos complexos ou de longa duração ou ao capturar dados de alta granularidade. Para isso, era necessário entender como o aplicativo estava funcionando e por que ele funcionava desse jeito primeiro, o que foi possível com uma ferramenta de criação de perfil.

Como você já deve saber, o DevTools é um aplicativo da Web. Por isso, é possível criar um perfil usando o painel Performance. Para criar um perfil desse painel, abra o DevTools e, em seguida, outra instância do DevTools anexada a ele. No Google, essa configuração é conhecida como DevTools-on-DevTools.

Com a configuração pronta, o cenário a ser criado precisa ser recriado e gravado. Para evitar confusão, a janela original do DevTools será chamada de "primeira instância do DevTools", e a janela que inspeciona a primeira instância será chamada de "segunda instância do DevTools".

Captura de tela de uma instância do DevTools inspecionando os elementos no próprio DevTools.
DevTools-on-DevTools: como inspecionar o DevTools com o DevTools.

Na segunda instância do DevTools, o painel Performance, que será chamado de painel de desempenho daqui em diante, observa a primeira instância do DevTools para recriar o cenário, que carrega um perfil.

Na segunda instância do DevTools, uma gravação ao vivo é iniciada, enquanto na primeira instância, um perfil é carregado de um arquivo no disco. Um arquivo grande é carregado para criar um perfil preciso do desempenho do processamento de entradas grandes. Quando as duas instâncias terminarem de carregar, os dados de perfil de desempenho, comumente chamados de trace, serão mostrados na segunda instância do DevTools do painel de desempenho que está carregando um perfil.

O estado inicial: identificar oportunidades de melhoria

Depois que o carregamento é concluído, o seguinte na segunda instância do painel de desempenho foi observado na próxima captura de tela. Concentre-se na atividade da linha de execução principal, que aparece abaixo da faixa chamada Main. É possível notar que há cinco grandes grupos de atividades no gráfico de chamas. Eles consistem nas tarefas em que o carregamento está demorando mais. O tempo total dessas tarefas foi de aproximadamente 10 segundos. Na captura de tela a seguir, o painel de desempenho é usado para se concentrar em cada um desses grupos de atividades para ver o que pode ser encontrado.

Uma captura de tela do painel de desempenho no DevTools inspecionando o carregamento de um rastro de desempenho no painel de desempenho de outra instância do DevTools. O perfil leva cerca de 10 segundos para carregar. Esse tempo é dividido em cinco grupos principais de atividades.

Primeiro grupo de atividades: trabalho desnecessário

Ficou claro que o primeiro grupo de atividades era código legado que ainda era executado, mas que não era realmente necessário. Basicamente, tudo sob o bloco verde processThreadEvents foi um desperdício de esforço. Essa foi uma vitória rápida. A remoção dessa chamada de função economizou cerca de 1,5 segundo. Legal!

Segundo grupo de atividades

No segundo grupo de atividades, a solução não foi tão simples quanto no primeiro. O buildProfileCalls levou cerca de 0,5 segundo, e essa tarefa não poderia ser evitada.

Uma captura de tela do painel de desempenho no DevTools inspecionando outra instância do painel de desempenho. Uma tarefa associada à função buildProfileCalls leva cerca de 0,5 segundos.

Por curiosidade, ativamos a opção Memória no painel de desempenho para investigar mais e vimos que a atividade buildProfileCalls também estava usando muita memória. Aqui, você pode ver como o gráfico de linhas azul pula repentinamente no momento em que buildProfileCalls é executado, o que sugere um possível vazamento de memória.

Uma captura de tela do Memory Profiler no DevTools avaliando o consumo de memória do painel de desempenho. O inspetor sugere que a função buildProfileCalls é responsável por um vazamento de memória.

Para investigar essa suspeita, usamos o painel "Memory" (outro painel no DevTools, diferente da gaveta "Memory" no painel de desempenho) para investigar. No painel "Memória", o tipo de criação de perfil "Amostras de alocação" foi selecionado, o que registrou o instantâneo do heap para o painel de desempenho que carrega o perfil da CPU.

Uma captura de tela do estado inicial do Memory Profiler. A opção "amostragem de alocação" é destacada com uma caixa vermelha, o que indica que essa é a melhor opção para a criação de perfil de memória em JavaScript.

A captura de tela a seguir mostra o snapshot de heap que foi coletado.

Captura de tela do Memory Profiler com uma operação baseada em conjuntos que consome muita memória selecionada.

Com esse snapshot do heap, foi observado que a classe Set estava consumindo muita memória. Ao verificar os pontos de chamada, descobrimos que estávamos atribuindo propriedades do tipo Set a objetos criados em grandes volumes. Esse custo foi aumentando e muita memória foi consumida, a ponto de ser comum o aplicativo falhar em entradas grandes.

Os conjuntos são úteis para armazenar itens únicos e oferecem operações que usam a exclusividade do conteúdo, como a eliminação de duplicação de conjuntos de dados e a disponibilização de pesquisas mais eficientes. No entanto, esses recursos não eram necessários, já que os dados armazenados eram garantidos como exclusivos da fonte. Assim, inicialmente não eram necessários cenários. Para melhorar a alocação de memória, o tipo de propriedade foi alterado de Set para uma matriz simples. Depois de aplicar essa mudança, outro instantâneo de heap foi feito e a alocação de memória reduzida foi observada. Apesar de não ter alcançado melhorias de velocidade consideráveis com essa mudança, o benefício secundário foi que o aplicativo falhou com menos frequência.

Captura de tela do perfilador de memória. A operação baseada em conjuntos, que antes exigia muita memória, foi alterada para usar uma matriz simples, o que reduziu significativamente o custo de memória.

Terceiro grupo de atividades: ponderar as vantagens e desvantagens da estrutura de dados

A terceira seção é peculiar: é possível ver no Flame Chart que ele consiste em colunas estreitas, mas altas, que denotam chamadas de função profundas, e recursões profundas, neste caso. No total, essa seção durou cerca de 1,4 segundo. Ao final desta seção, ficou claro que a largura dessas colunas foi determinada pela duração de uma função: appendEventAtLevel, o que sugeriu que poderia ser um gargalo.

Na implementação da função appendEventAtLevel, uma coisa se destacou. Para cada entrada de dados na entrada (que é conhecida no código como "evento"), um item era adicionado a um mapa que acompanhava a posição vertical das entradas da linha do tempo. Isso era problemático porque a quantidade de itens armazenados era muito grande. O Maps é rápido para pesquisas com base em chaves, mas essa vantagem não é sem custo financeiro. À medida que um mapa fica maior, a adição de dados a ele pode, por exemplo, ficar cara devido ao rehash. Esse custo é perceptível quando uma grande quantidade de itens é adicionada sucessivamente ao mapa.

/**
 * Adds an event to the flame chart data at a defined vertical level.
 */
function appendEventAtLevel (event, level) {
  // ...

  const index = data.length;
  data.push(event);
  this.indexForEventMap.set(event, index);

  // ...
}

Testamos outra abordagem que não exigia a adição de um item em um mapa para cada entrada no Flame Chart. A melhoria foi significativa, confirmando que o gargalo estava relacionado à sobrecarga gerada pela adição de todos os dados ao mapa. O tempo que o grupo de atividades levou encolheu de cerca de 1,4 segundos para cerca de 200 milissegundos.

Antes:

Uma captura de tela do painel de desempenho antes das otimizações na função bindEventAtLevel. O tempo total para a execução da função foi de 1.372,51 milissegundos.

Depois:

Uma captura de tela do painel de desempenho depois que as otimizações foram feitas na função appendEventAtLevel. O tempo total para a execução da função foi de 207,2 milissegundos.

Quarto grupo de atividades: adiar o trabalho não crítico e os dados de cache para evitar trabalho duplicado

Ao ampliar essa janela, é possível ver que há dois blocos quase idênticos de chamadas de função. Analisando o nome das funções chamadas, é possível inferir que esses blocos consistem em código que cria árvores (por exemplo, com nomes como refreshTree ou buildChildren). Na verdade, o código relacionado é o que cria as visualizações em árvore na gaveta inferior do painel. O interessante é que essas visualizações de árvore não são mostradas logo após o carregamento. Em vez disso, o usuário precisa selecionar uma visualização em árvore (as guias "Bottom-up", "Call Tree" e "Event Log" na gaveta) para que as árvores sejam mostradas. Além disso, como você pode ver na captura de tela, o processo de criação da árvore foi executado duas vezes.

Uma captura de tela do painel de desempenho mostrando várias tarefas repetitivas que são executadas mesmo que não sejam necessárias. Essas tarefas podem ser adiadas para execução sob demanda, em vez de serem executadas com antecedência.

Identificamos dois problemas com essa imagem:

  1. Uma tarefa não crítica estava dificultando o desempenho do tempo de carregamento. Os usuários nem sempre precisam da saída. Dessa forma, a tarefa não é essencial para o carregamento do perfil.
  2. O resultado dessas tarefas não foi armazenado em cache. É por isso que as árvores foram calculadas duas vezes, apesar de os dados não terem mudado.

Começamos adiando o cálculo da árvore para quando o usuário abria manualmente a visualização em árvore. Só então vale a pena pagar o preço da criação dessas árvores. O tempo total de execução dessa ação duas vezes foi de cerca de 3,4 segundos.Portanto, o adiamento fez uma diferença significativa no tempo de carregamento. Ainda estamos tentando armazenar em cache esses tipos de tarefas também.

Quinto grupo de atividades: evite hierarquias de chamadas complexas sempre que possível

Analisando de perto esse grupo, ficou claro que uma cadeia de chamadas específica estava sendo invocada repetidamente. O mesmo padrão apareceu seis vezes em lugares diferentes no gráfico de chamas, e a duração total dessa janela foi de cerca de 2,4 segundos.

Uma captura de tela do painel de desempenho mostrando seis chamadas de função separadas para gerar o mesmo minimapa de rastro, cada um com pilhas de chamadas profundas.

O código relacionado que é chamado várias vezes é a parte que processa os dados a serem renderizados no "minimapa" (a visão geral da atividade da linha do tempo na parte de cima do painel). Não estava claro por que isso estava acontecendo várias vezes, mas certamente não precisava acontecer seis vezes. Na verdade, a saída do código vai permanecer atual se nenhum outro perfil for carregado. Em teoria, o código só precisa ser executado uma vez.

Após a investigação, foi descoberto que o código relacionado foi chamado como consequência de várias partes no pipeline de carregamento chamando diretamente ou indiretamente a função que calcula o minimap. Isso ocorre porque a complexidade do gráfico de chamadas do programa evoluiu com o tempo, e mais dependências desse código foram adicionadas sem saber. Não há uma solução rápida para esse problema. A forma de resolver isso depende da arquitetura da base de código em questão. No nosso caso, tivemos que reduzir um pouco a complexidade da hierarquia de chamadas e adicionar uma verificação para impedir a execução do código se os dados de entrada permanecerem inalterados. Depois de implementar isso, tivemos esta perspectiva da linha do tempo:

Captura de tela do painel de desempenho mostrando as seis chamadas de função separadas para gerar o mesmo minimapa de rastreamento reduzido apenas duas vezes.

A execução da renderização do minimap ocorre duas vezes, não uma. Isso acontece porque há dois minimapas sendo desenhados para cada perfil: um para a visão geral na parte de cima do painel e outro para o menu suspenso que seleciona o perfil atualmente visível no histórico. Todos os itens desse menu contêm uma visão geral do perfil selecionado. No entanto, os dois têm exatamente o mesmo conteúdo, então um pode ser reutilizado no outro.

Como esses minimapas são imagens desenhadas em uma tela, era uma questão de usar o utilitário de tela drawImage e, em seguida, executar o código apenas uma vez para economizar tempo. Como resultado, a duração do grupo foi reduzida de 2,4 segundos para 140 milissegundos.

Conclusão

Depois de aplicar todas essas correções (e algumas outras menores), a mudança na linha do tempo de carregamento do perfil ficou assim:

Antes:

Uma captura de tela do painel de desempenho mostrando o carregamento do trace antes das otimizações. O processo levou aproximadamente 10 segundos.

Depois:

Captura de tela do painel de desempenho mostrando o carregamento de rastros após as otimizações. O processo agora leva aproximadamente dois segundos.

O tempo de carregamento após as melhorias foi de 2 segundos, o que significa que uma melhoria de cerca de 80% foi alcançada com um esforço relativamente baixo, já que a maioria do que foi feito consistiu em correções rápidas. É claro que identificar corretamente o que fazer inicialmente foi fundamental, e o painel de desempenho foi a ferramenta certa para isso.

Também é importante destacar que esses números são específicos de um perfil usado como objeto de estudo. O perfil era interessante para nós porque era particularmente grande. No entanto, como o pipeline de processamento é o mesmo para todos os perfis, a melhoria significativa alcançada se aplica a todos os perfis carregados no painel de desempenho.

Aprendizados

Há algumas lições a serem aprendidas com esses resultados em termos de otimização de desempenho do aplicativo:

1. Usar ferramentas de criação de perfil para identificar padrões de desempenho no ambiente de execução

As ferramentas de criação de perfil são muito úteis para entender o que está acontecendo no aplicativo durante a execução, principalmente para identificar oportunidades de melhorar o desempenho. O painel "Performance" no Chrome DevTools é uma ótima opção para aplicativos da Web, já que é a ferramenta de criação de perfil da Web nativa do navegador e é mantida ativamente para estar sempre atualizada com os recursos mais recentes da plataforma da Web. Além disso, o processo ficou significativamente mais rápido. 😉

Use amostras que podem ser usadas como cargas de trabalho representativas e veja o que você consegue encontrar.

2. Evite hierarquias de chamadas complexas

Sempre que possível, evite deixar o gráfico de chamadas muito complicado. Com hierarquias de chamada complexas, é fácil introduzir regressões de desempenho e difícil de entender por que seu código está sendo executado da maneira que está, o que dificulta a implementação de melhorias.

3. Identificar trabalhos desnecessários

É comum que bases de código antigas contenham código que não é mais necessário. No nosso caso, o código legado e desnecessário estava ocupando uma parte significativa do tempo total de carregamento. Remover o problema foi a solução mais fácil.

4. Usar estruturas de dados de maneira adequada

Use estruturas de dados para otimizar a performance, mas também entenda os custos e as compensações que cada tipo de estrutura de dados traz ao decidir quais usar. Essa não é apenas a complexidade de espaço da estrutura de dados em si, mas também a complexidade de tempo das operações aplicáveis.

5. Armazenar em cache os resultados para evitar trabalhos duplicados em operações complexas ou repetitivas

Se a operação for cara de executar, faz sentido armazenar os resultados para a próxima vez que for necessário. Também faz sentido fazer isso se a operação for realizada muitas vezes, mesmo que cada vez não seja particularmente custosa.

6. Adiar trabalhos não críticos

Se a saída de uma tarefa não for necessária imediatamente e a execução da tarefa estiver estendendo o caminho crítico, considere adiá-la chamando-a lentamente quando a saída for realmente necessária.

7. Usar algoritmos eficientes em entradas grandes

Para entradas grandes, os algoritmos de complexidade de tempo ideais se tornam cruciais. Não analisamos essa categoria neste exemplo, mas a importância dela é inegável.

8. Bônus: compare seus pipelines

Para garantir que seu código em evolução permaneça rápido, é recomendável monitorar o comportamento e compará-lo com os padrões. Assim, você identifica proativamente as regressões e melhora a confiabilidade geral, preparando-se para o sucesso a longo prazo.