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 nos bastidores 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 seu aplicativo estiver sendo executado no Google Chrome, ele fornecerá uma visão geral detalhada do que o navegador está fazendo durante a execução do aplicativo. Entender essa atividade pode ajudar você a identificar padrões, gargalos e pontos de acesso de desempenho em que é possível agir para melhorar o desempenho.

O exemplo a seguir mostra como usar o painel Desempenho.

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

Recentemente, definimos uma meta para melhorar o desempenho do painel Desempenho. Em particular, queríamos que ele carregasse grandes volumes de dados de desempenho mais rapidamente. Isso acontece, por exemplo, ao criar perfis de processos complexos ou de longa duração ou 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. Dessa forma, é possível criar o perfil usando o painel Performance. Para criar o perfil desse painel em si, abra o DevTools e abra outra instância do DevTools conectada a ele. No Google, essa configuração é conhecida como DevTools-on-DevTools.

Com a configuração pronta, é preciso recriar e gravar o cenário do cenário. Para evitar confusão, a janela original do DevTools será chamada de "primeira instância do DevTools", e a janela que inspecionar a primeira instância será chamada de "segunda instância do DevTools".

Uma 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 em tempo real é iniciada, e na primeira instância, um perfil é carregado de um arquivo no disco. Um arquivo grande é carregado para traçar o perfil preciso do desempenho do processamento de entradas grandes. Quando as duas instâncias terminam de carregar, os dados de criação de perfil de desempenho (também chamados de trace) são vistos na segunda instância do DevTools do painel de desempenho que carrega um perfil.

O estado inicial: identificar oportunidades de melhoria

Após a conclusão do carregamento, o resultado a seguir 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 observar que há cinco grandes grupos de atividades no diagrama 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 outra instância do DevTools. O perfil leva cerca de 10 segundos para carregar. Esse tempo é dividido principalmente 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. A 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 segundo.

Por curiosidade, ativamos a opção Memória no painel de desempenho para investigar mais a fundo e vimos que a atividade buildProfileCalls também estava usando muita memória. Aqui, é possível observar como o gráfico de linhas azuis salta 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 no painel de desempenho. O inspetor sugere que a função buildProfileCalls é responsável por um vazamento de memória.

Para dar continuidade a essa suspeita, usamos o painel Memory (outro painel no DevTools, diferente da gaveta Memory no painel perf) para investigar. No painel "Memória", a opção "Amostragem de alocação" tipo de criação de perfil selecionado, que registrou o snapshot de heap do painel de desempenho carregando o perfil de CPU.

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

A captura de tela a seguir mostra o instantâneo de alocação heap que foi coletado.

Uma captura de tela do Memory Profiler, com uma operação baseada em Set com uso intensivo de memória selecionada.

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

Os conjuntos são úteis para armazenar itens exclusivos e fornecer operações que usam a exclusividade de seu conteúdo, como eliminar a duplicação de conjuntos de dados e fornecer pesquisas mais eficientes. No entanto, esses recursos não eram necessários porque havia a garantia de que os dados armazenados eram exclusivos da origem. 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 alteração, outro snapshot de heap foi capturado e foi observada uma alocação de memória reduzida. 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 falhava com menos frequência.

Uma captura de tela do Memory Profiler. A operação baseada em conjunto que exigia muita memória foi alterada para usar uma matriz simples, o que reduziu significativamente o custo da memória.

Terceiro grupo de atividades: ponderar as compensações da estrutura de dados

A terceira seção é peculiar: no Flame Chart, ele consiste em colunas estreitas, porém 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.

Dentro da implementação da função appendEventAtLevel, um ponto 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 um problema, porque a quantidade de itens armazenados era muito grande. Os mapas são rápidos para pesquisas baseadas 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 mesmo relacionado à sobrecarga gerada pela adição de todos os dados ao mapa. O tempo que o grupo de atividades demorou foi de cerca de 1,4 segundo para cerca de 200 milissegundos.

Antes:

Captura de tela do painel de desempenho antes das otimizações na função anexeEventAtLevel. O tempo total de 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 anexeEventAtLevel. O tempo total de execução da função foi de 207,2 milissegundos.

Quarto grupo de atividades: adiar trabalhos não críticos e armazenar dados em cache para evitar trabalho duplicado

Ampliando essa janela, podemos notar que há dois blocos quase idênticos de chamadas de função. Observando o nome das funções chamadas, é possível inferir que esses blocos consistem em códigos que criam árvores (por exemplo, com nomes como refreshTree ou buildChildren). Na verdade, o código relacionado é aquele que cria as visualizações em árvore na gaveta inferior do painel. O interessante é que essas visualizações em á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 exibidas. Além disso, como é possível notar pela captura de tela, o processo de construção da árvore foi executado duas vezes.

Uma captura de tela do painel de performance 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, e não antecipadamente.

Identificamos dois problemas com esta 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 dos dados não terem mudado.

Começamos com o adiamento do cálculo de árvore para quando o usuário abriu manualmente a visualização em árvore. Só então vale a pena pagar o preço de criar essas árvores. O tempo total de execução duas vezes foi de cerca de 3,4 segundos.Portanto, adiá-lo 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 quando 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 6 vezes em locais diferentes no diagrama de chamas, e a duração total da 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 rastreamento, cada uma 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 superior do painel). Não ficou claro por que isso estava acontecendo várias vezes, mas com certeza não precisava acontecer seis vezes. Na verdade, a saída do código deve permanecer atual se nenhum outro perfil for carregado. Teoricamente, o código só deveria ser executado uma vez.

Após investigação, descobrimos que o código relacionado foi chamado como consequência de várias partes no pipeline de carregamento chamando direta ou indiretamente a função que calcula o minimapa. Isso ocorre porque a complexidade do gráfico de chamadas do programa evoluiu ao longo do tempo e mais dependências a esse código foram adicionadas sem saber. Não há uma solução rápida para esse problema. A solução para 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 evitar a execução do código se os dados de entrada permanecessem inalterados. Depois de implementar isso, temos esta perspectiva do cronograma:

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

Observe que a execução da renderização do minimapa ocorre duas vezes, não uma vez. Isso ocorre porque há dois minimapas sendo desenhados para cada perfil: um para a visão geral na parte superior do painel e outro para o menu suspenso que seleciona o perfil visível no histórico (cada item nesse 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 desse esforço, 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 aqui e ali), a alteração da linha do tempo de carregamento do perfil ficou assim:

Antes:

Captura de tela do painel de desempenho mostrando o carregamento de rastros antes das otimizações. O processo levou aproximadamente dez segundos.

Depois:

Captura de tela do painel de desempenho mostrando o carregamento de rastros após as otimizações. O processo 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 relativamente baixo esforço, já que a maior parte do que foi feito consistia em correções rápidas. É claro que identificar corretamente o que fazer inicialmente era fundamental, e o painel de desempenho era a ferramenta certa para isso.

Também é importante destacar que esses números são específicos de um perfil usado como objeto do estudo. O perfil foi interessante para nós por ser 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 seu aplicativo:

1. Usar ferramentas de criação de perfil para identificar padrões de desempenho no momento da 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, porque é a ferramenta nativa de criação de perfis da Web no navegador, que é ativamente mantido para estar sempre atualizado com os recursos mais recentes da plataforma. Além disso, o processo ficou significativamente mais rápido. 😉

Use amostras que possam ser usadas como cargas de trabalho representativas e veja o que é possível encontrar.

2. Evitar hierarquias de chamada complexas

Sempre que possível, evite complicar seu gráfico de chamadas. 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 envelhecidas contenham códigos que não são mais necessários. No nosso caso, o código legado e desnecessário estava ocupando uma parte significativa do tempo total de carregamento. Retirá-la era a fruta mais fácil de conseguir.

4. Usar estruturas de dados adequadamente

Use estruturas de dados para otimizar o desempenho, mas também entenda os custos e as desvantagens de cada tipo de estrutura de dados 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. Armazene os resultados em cache para evitar trabalho duplicado em operações complexas ou repetitivas

Se a execução da operação for cara, 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 feita muitas vezes, mesmo que cada tempo individual não seja particularmente dispendioso.

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, algoritmos de complexidade de tempo ideal se tornam cruciais. Não analisamos essa categoria neste exemplo, mas sua importância dificilmente pode ser superestimada.

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. Dessa forma, você identifica regressões proativamente e melhora a confiabilidade geral, preparando você para o sucesso a longo prazo.