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".
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.
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.
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.
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.
A captura de tela a seguir mostra o instantâneo de alocação heap que foi coletado.
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.
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:
Depois:
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.
Identificamos dois problemas nessa imagem:
- 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.
- 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.
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:
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:
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 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.