Independentemente do tipo de aplicativo que você está desenvolvendo, otimizar o desempenho dele e garantir que ele carregue rapidamente e ofereça interações suaves é 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 durante a execução. 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. Compreender 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 com mais rapidez. 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, foi necessário entender como o aplicativo estava funcionando e por que ele estava funcionando dessa maneira, o que foi alcançado usando uma ferramenta de criação de perfil.
Como você sabe, 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 está inspecionando a primeira será chamada de segunda instância do 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 fica visível abaixo da faixa Main. É possível ver que há cinco grandes grupos de atividades no gráfico de chamas. Essas são as 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.
Primeiro grupo de atividades: trabalho desnecessário
Ficou claro que o primeiro grupo de atividades era um código legado que ainda era executado, mas não era realmente necessário. Basicamente, tudo no bloco verde rotulado como processThreadEvents
foi um esforço desperdiçado. 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 a primeira. O buildProfileCalls
levou cerca de 0,5 segundo, e essa tarefa não poderia ser evitada.
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.
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 carregou o perfil da CPU.
A captura de tela a seguir mostra o snapshot de heap que foi coletado.
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 de forma desnecessária. 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. Por isso, os conjuntos não eram necessá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 conseguido melhorias consideráveis na velocidade com essa mudança, o benefício secundário foi que o aplicativo travava com menos frequência.
Terceiro grupo de atividades: avaliar as compensações da estrutura de dados
A terceira seção é peculiar: você pode ver no gráfico de chamas 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 segundos. Na parte de baixo desta seção, ficou claro que a largura dessas colunas foi determinada pela duração de uma função: appendEventAtLevel
, o que sugeriu que ela 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 "evento"), um item foi adicionado a um mapa que rastreava 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 cresce, a adição de dados a ele pode se tornar cara devido à repetição. Esse custo se torna perceptível quando grandes quantidades de itens são adicionadas ao mapa de forma sucessiva.
/**
* 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 diagrama de chamas. 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:
Depois:
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.
Identificamos dois problemas com essa imagem:
- Uma tarefa não crítica estava prejudicando a performance do tempo de carregamento. Os usuários nem sempre precisam da saída. Portanto, a tarefa não é essencial para o carregamento do perfil.
- 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. Também estamos estudando o armazenamento em cache desses tipos de tarefas.
Quinto grupo de atividades: evite hierarquias de chamadas complexas sempre que possível
Ao analisar 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.
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 correçã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:
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, eles têm o mesmo conteúdo, então um pode ser reutilizado para o outro.
Como esses minimapas são imagens desenhadas em uma tela, basta usar o drawImage
canvas utility 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:
Depois:
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 seu aplicativo:
1. Use ferramentas de criação de perfil para identificar padrões de desempenho 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 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, agora ele é muito 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 chamadas complexas, é fácil introduzir regressões de desempenho e difícil entender por que o código está sendo executado dessa forma, o que dificulta a realização de melhorias.
3. Identificar trabalho desnecessário
É 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 entenda também os custos e as compensações que cada tipo de estrutura de dados traz ao decidir quais usar. Isso não é apenas a complexidade de espaço da própria estrutura de dados, 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 de alto custo, faz sentido armazenar os resultados para a próxima vez que ela for necessária. Também faz sentido fazer isso se a operação for realizada muitas vezes, mesmo que cada vez não seja particularmente custosa.
6. Adie o trabalho não crítico
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 adiar a chamada dela 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 essenciais. Não analisamos essa categoria neste exemplo, mas a importância dela é inestimável.
8. Bônus: compare seus pipelines
Para garantir que o 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.