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

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

Seja qual for o tipo de aplicativo que você está desenvolvendo, otimizar o desempenho e garantir que ele carregue rápido e ofereça interações perfeitas é 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 saber o que está acontecendo nos bastidores enquanto ele é executado em uma janela de tempo. O painel Desempenho do DevTools é uma ótima ferramenta de criação de perfil para analisar e otimizar o desempenho de aplicativos da Web. Se o seu aplicativo estiver sendo executado no Chrome, você terá uma visão geral detalhada do que o navegador está fazendo enquanto o seu aplicativo é executado. Entender essa atividade pode ajudar a identificar padrões, gargalos e pontos de acesso de desempenho em que você pode tomar medidas para melhorar o desempenho.

O exemplo a seguir mostra como usar o painel Desempenho.

Configurar e recriar o cenário de criação de perfil

Recentemente, definimos uma meta para melhorar a performance do painel Desempenho. Em especial, 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 ao capturar dados de alta granularidade. Para conseguir isso, foi necessário primeiro entender como o aplicativo estava funcionando e por que ele foi feito dessa maneira, usando uma ferramenta de criação de perfil.

Como você deve saber, o DevTools é um aplicativo da Web. Dessa forma, é possível criar um perfil usando o painel Desempenho. Para criar um perfil desse painel, abra o DevTools e depois outra instância 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 que está inspecionando a primeira instância é 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 do 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. Já 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 terminam de carregar, os dados da criação de perfil de desempenho, normalmente chamados de trace, são vistos na segunda instância do DevTools do painel do perf que carrega um perfil.

O estado inicial: identificar oportunidades de melhoria

Após a conclusão do carregamento, observamos o seguinte na nossa segunda instância do painel perf na captura de tela seguinte. Concentre-se na atividade da linha de execução principal, visível abaixo da faixa Principal. Pode-se ver que há cinco grandes grupos de atividade no Flame Chart. Elas consistem nas tarefas em que o carregamento está demorando mais tempo. 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 e 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 aparente que o primeiro grupo de atividade era um código legado que ainda era executado, mas não era realmente necessário. Basicamente, tudo o que estava abaixo do bloco verde com o rótulo processThreadEvents foi desperdício de esforço. Essa foi uma vitória rápida. A remoção dessa chamada de função foi economizada em cerca de 1,5 segundo. Ótimo!

Segundo grupo de atividades

No segundo grupo de atividades, a solução não foi tão simples quanto a do 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 Memory no painel perf para investigar mais e vimos que a atividade buildProfileCalls também estava usando muita memória. Aqui, é possível conferir como o gráfico de linhas azul muda de repente no momento em que buildProfileCalls é executado, o que sugere um possível vazamento de memória.

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 perf) para investigar. No painel "Memória", o tipo de criação de perfil "Amostragem de alocação" foi selecionado, o que registrou o snapshot da heap para o painel de desempenho que carrega o perfil de CPU.

Captura de tela do estado inicial do Memory Profiler. A opção "amostragem de alocação" é destacada com uma caixa vermelha e indica que é a melhor opção para perfis de memória 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.

Com base nesse snapshot de heap, foi observado 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 foi 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 do 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, já que os dados armazenados eram exclusivos da origem. Por isso, os cenários não eram necessários. Para melhorar a alocação de memória, o tipo de propriedade mudou de Set para matriz simples. Após a aplicação dessa alteração, outro instantâneo de heap foi tirado e uma alocação de memória reduzida foi observada. Apesar de não alcançar melhorias consideráveis de velocidade com essa mudança, o benefício secundário era que o aplicativo travava com menos frequência.

Captura de tela do Memory Profiler. A operação baseada em Set que antes consumia 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: você pode ver no Flame Chart que ela consiste em colunas estreitas, mas altas, que denotam chamadas de função profunda, e recursões profundas neste caso. No total, essa seção durou cerca de 1,4 segundo. Na parte de baixo desta seção, notamos que a largura dessas colunas foi determinada pela duração de uma função: appendEventAtLevel, 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 (conhecida no código como o "evento"), um item era adicionado a um mapa que acompanhava a posição vertical das entradas da linha do tempo. Isso foi um problema, porque a quantidade de itens armazenados era muito grande. O Google Maps é rápido para pesquisas baseadas em chaves, mas essa vantagem não é sem custo financeiro. À medida que um mapa fica maior, adicionar dados a ele pode, por exemplo, se tornar caro devido à reciclagem. Esse custo se torna perceptível quando grandes quantidades de itens são adicionados ao mapa sucessivamente.

/**
 * 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);

  // ...
}

Experimentamos 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 de fato relacionado à sobrecarga incorrida pela adição de todos os dados ao mapa. O tempo em que o grupo de atividades diminuiu de cerca de 1,4 segundo para cerca de 200 milissegundos.

Antes:

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

Depois:

Uma captura de tela do painel de performance após as otimizações na função anexarEventAtLevel. O tempo total para a execução da função foi de 207,2 milissegundos.

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

Analisando o zoom dessa janela, podemos 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 um código que está criando árvores (por exemplo, com nomes como refreshTree ou buildChildren). Na verdade, o código relacionado é aquele que cria as visualizações de á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 mostradas. Além disso, como você pode 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, em vez de antecipadamente.

Identificamos dois problemas nessa imagem:

  1. Uma tarefa não crítica estava prejudicando o desempenho do tempo de carregamento. Os usuários nem sempre precisam dessa 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 mudarem.

Começamos com o cálculo de árvore adiado para quando o usuário abria manualmente a visualização em árvore. Só então vale a pena pagar o preço de criação dessas á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. Também estamos analisando o armazenamento em cache desses tipos de tarefas.

Quinto grupo de atividades: evite hierarquias de chamada complexas quando possível

Analisando esse grupo de perto, ficou claro que uma cadeia de chamadas específica estava sendo invocada repetidamente. O mesmo padrão apareceu seis vezes em lugares diferentes no Flame Chart, 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 trace, cada uma com pilhas de chamadas profundas.

O código relacionado 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 ficava claro por que isso acontecia várias vezes, mas certamente não precisava acontecer seis vezes. Na verdade, a saída do código deve permanecer atual se nenhum outro perfil for carregado. Em teoria, o código precisa ser executado apenas uma vez.

Após investigação, descobriu-se 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 com o 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 maneira de resolvê-lo 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 permanecerem inalterados. Depois de implementar isso, temos esta perspectiva do cronograma:

Uma captura de tela do painel de desempenho mostrando as seis chamadas de função separadas para gerar o mesmo minimapa de rastro reduzida a apenas duas vezes.

Observe que a execução da renderização do minimapa ocorre duas vezes, não uma vez. Isso ocorre porque dois minimapas estão 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 atualmente 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, portanto, um pode ser reutilizado para o outro.

Como os dois minimapas são imagens desenhadas em uma tela, era uma questão de usar o utilitário de tela drawImage e, posteriormente, executar o código apenas uma vez para economizar tempo extra. 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 da seguinte forma:

Antes:

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

Depois:

Uma captura de tela do painel de performance mostrando o carregamento de traces 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 maior parte do que era feito consistia em correções rápidas. Obviamente, identificar adequadamente o que fazer inicialmente era fundamental, e o painel perf foi a ferramenta certa para isso.

Também é importante ressaltar que esses números são específicos de um perfil usado como assunto do 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 perf.

Pontos principais

Você deve aprender algumas lições sobre esses resultados em termos de otimização do desempenho do seu 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 enquanto ele está em 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 nativa de criação de perfil da Web no navegador e é ativamente mantida para que ela esteja atualizada com os recursos mais recentes da plataforma Web. Além disso, agora está significativamente mais rápido! 😉

Use amostras que possam ser usadas como cargas de trabalho representativas e confira o que encontrar.

2. Evite hierarquias de chamadas complexas

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

3. Identificar trabalho desnecessário

É comum que as bases de código antigas contenham códigos que não são mais necessários. No nosso caso, os códigos legados e desnecessários estavam tomando uma parte significativa do tempo total de carregamento. A remoção dele foi a conquista mais fácil.

4. Usar estruturas de dados de forma adequada

Use estruturas de dados para otimizar o desempenho, mas também entenda os custos e as compensações de cada tipo de estrutura de dados ao decidir quais usar. Não se trata apenas da complexidade de espaço da estrutura de dados, mas também da complexidade de tempo das operações aplicáveis.

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

Se a execução da operação for dispendiosa, faz sentido armazenar seus resultados para a próxima vez que for necessária. Também faz sentido fazer isso se a operação for feita muitas vezes, mesmo que cada tempo individual não seja particularmente caro.

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 ideal se tornam cruciais. Não analisamos essa categoria neste exemplo, mas a importância dificilmente pode ser subestimada.

8. Bônus: compare seus pipelines

Para garantir que o código em evolução permaneça rápido, monitore o comportamento e compare-o com os padrões. Dessa forma, você identifica proativamente regressões e melhora a confiabilidade geral, preparando você para o sucesso a longo prazo.