Ir ao conteúdo

Ferramentas de análise de desempenho: Performance Profiler do Visual Studio – 2ª Parte

Esse é a segunda parte de três posts sobre as ferramentas dentro do “Performance Profiler” no Visual Studio! 😀

No primeiro post expliquei sobre o objetivo do “Performance Profiler” e apresentei com detalhe as funcionalidades das ferramentas: Memory Usage e CPU Usage. Se você está acessando primeiro esse post, eu aconselho que você leia a parte 1, assim você será contextualizado sobre essas ferramentas e irá se empolgar ainda mais. O primeiro post está aqui: http://charlesmms.azurewebsites.net/2021/01/16/ferramentas-analise-desempenho-performance-profiler-do-visual-studio-1-parte/.

Aqui, neste post vou apresentar com detalhes as funcionalidades das seguintes ferramentas:

  • .NET Object Allocation Tracking;
  • .NET Counters;

Para acessar o  “Performance Profiler” no Visual Studio você só precisa pressionar Alt+F2 ou ir através do menu desta maneira:

E havia falado no primeiro post, você consegue utilizar essas ferramentas e todos os recursos delas em todas as versões do Visual Studio, incluindo a versão Community (versão gratuita do Visual Studio, link).

NOTA: se você estiver abrindo essa ferramenta em modo Debug, vai aparecer a mensagem abaixo. Essa mensagem informa que seria melhor alterar a solução para executar em modo de Release, porque assim os resultados vão ser mais precisos. Isso é porque, quando nós executamos a aplicação em modo Debug existem instrumentos e outras ferramentas dentro do Visual Studio que ficam analisando a aplicação, então o resultado vai sofrer alguma interferência. 
Notificação sobre a utilização do Performance Profiler em modo Debug
Notificação sobre a utilização do Performance Profiler em modo Debug

Ferramenta: .NET Object Allocation Tracking

A ferramenta .NET Object Allocation Tracking auxilia-nos a investigar onde os objetos são alocados e quando eles são reciclados pelo Coletor de Lixo (Garbage Collection – GC).

Para executá-la, abra o painel do Performance Profiler e selecione “.NET Object Allocation Tracking“, conforme imagem abaixo, e depois clique em Start para iniciar a análise.

Após a sua aplicação ser executada ou quando você tiver certeza que o código ao qual queiras avaliar foi executado, pare a aplicação. A ferramenta pode demorar um tempo para terminar a análise. O resultado dela será esse Dashboard:

Assim como nas outras ferramentas, no cabeçalho do Dashboard tem o tempo que durou a análise desta ferramenta.

Já no primeiro gráfico, chamado Live Objects, temos uma contagem total de objetos que alocaram memória dentro do seu código durante o período de avaliação da ferramenta. Por exemplo, neste caso o total de objetos que alocam memória durante os 15,304 segundos foram 368.800. E como você pode ver no gráfico, ele ficou serrilhado. Isso ocorre porque o GC fica em constante trabalho, desalocando os objetos que não estavam sendo mais utilizados nas gerações (G0, G1 e G2).

O segundo gráfico, chamado Object delta, apresenta o delta da quantidade de objetos que alocaram memória durante o período de avaliação da ferramenta. Normalmente, esse gráfico sempre vai ter um pico no início, porque toda vez que uma aplicação inicia vai alocar uma quantidade grande de objetos e no decorrer da execução o GC vai começar a trabalhar e assim vai diminuindo. Outro detalhe importante deste gráfico é que todos os plots verdes representam uma quantidade grande de objetos que alocaram memória e os plots vermelhos representam quando o GC desalocou os objetos:

Neste dois gráficos é possível selecionar um período e assim as informações são reajustadas de acordo com o período selecionado:

Na parte de baixo, na aba Allocations, assim como ocorreu nas outras ferramentas, têm mais informações do que existem nos gráficos acima. Nesta aba têm detalhes do tamanho de memória alocado para executar a aplicação, durante o período de avaliação da ferramenta (indicado no cabeçalho deste dashboard). Além disso, ela mostra os resultados nas duas principais categorias de tipos de objetos na coluna Type: objetos por referência (reference types) e por valor (value types):

Essa indicação é feita através dos ícones:

  • laranja = indicando ser objetos reference type;
  • azul = indicando ser objetos value type.

É importante sabermos disso porque os objetos reference type são armazenados na memória HEAP, sendo eles: String, Listas, Classes e Delegates (objetos mais pesados e complexos). Enquanto os objetos value type são armazenados na memória STACK, sendo eles: bool, byte, char, decimal, double, enum, float, int, long, sbyte, short, struct, uint, ulong e ushort (objetos mais leves e menos complexos). Por isso é interessante utilizarmos objetos reference type com mais responsabilidade e não deixá-los durante muito tempo na memória. Dependendo da aplicação, tentar usar o máximo possível dos objetos value type .

Claro que nem sempre conseguimos usar os objetos value type, mas em alguns momentos podemos nos esforçar para fazer uma solução que aloca menos objetos reference type ou por um tempo menor. Abaixo vou mostrar para você um exemplo comparando dois códigos que fazem a mesma coisa, mas um código se preocupa em deixar menos tempo os objetos por reference types na memória e o outro não.

Para saber mais sobre memória HEAP e STACK, consulte esses links: C# Heap(ing) Vs Stack(ing) In .NET, Difference between a Value Type and a Reference Type e Allocating on the stack or the heap. 

Ainda dentro da aba de Allocations, existe a coluna com o mesmo nome, Allocations. Ela mostra a quantidade de objetos criados e alocados para o tipo indicado na frente. Por exemplo, neste caso o objeto String foi criado e alocado 1.158.081 vezes (na primeira linha do grid da imagem acima).

Agora, na aba Call Tree, você vai ver a callstack de toda a execução avaliada pela ferramenta, com a indicação dos códigos que são bottlenecks (um gargalo na execução comum da aplicação, o código ofensor de desempenho) através de um ícone indicando fogo (linha em destaque abaixo):

A partir desta aba, você consegue saber qual foi o método que está sendo indicado ser o bottleneck (coluna Function Name), qual é a quantidade total de objetos criados e alocados dentro do método (colunas Total e Self Allocations), o tamanho em bytes da alocação desses objetos na memória (coluna Self Size (bytes)) durante o período de avaliação da ferramenta e o nome da DLL (coluna Module Name).

Ainda na aba Call Tree, você consegue chegar até o código do método indicado ser o bottleneck, desta maneira:

Na aba Functions, conforme é possível ver no gif acima, mostra onde é o método que mais objetos criou e alocou (colunas Total e Self Allocations) e o tamanho em bytes da alocação desses objetos na memória (coluna Self Size (bytes)). Neste exemplo, como foi String, teremos essas informações:

E na aba de Collections, temos a quantidade de vezes que o GC foi acionado (coluna GC) e para cada vez que ele foi acionado, tem a informação da quantidade de objetos foram coletados (coluna Collected) e quantidade que continuram ativos/sobreviveram (coluna Survived):

Ainda na aba de Collections, se você clicar na linha indicando a vez que GC foi acionado, você vai ver com detalhes, através de um gráfico pizza, quais foram os tipos dos objetos que mais foram coletados (Top Collected Types) e os que sobreviveram a coleta (Top Survived Types):

Muito legal, né?!

Abaixo, vou mostrar um exemplo prático, comparando dois códigos através desta ferramenta. Todo o código fonte apresentado nos exemplos deste post estão dentro do meu GitHub: https://github.com/MackMendes/Evaluation-Performance-Tools.

Exemplo prático

Neste exemplo, vamos analisar através da ferramenta .NET Object Allocation Tracking dois código que fazem a mesma coisa, mas de maneiras diferentes. Basicamente, ambos os códigos vão ler um arquivo CSV com 4.254.895 linhas e 100 MB (esse dataset contém a avaliação de alguns usuários sobre filmes, e originalmente ele tem 508 MB – o link do dataset original no Kaggle). Os códigos vão percorrer por todas as linhas deste csv e calcular a média de um determinado conjunto de dados (quando o ID do filme for igual a 223).

Os dois códigos são esses:

public static void Version1()  {      
    var lines = File.ReadAllLines(this.filePath);      
    var sum = 0d;      
    var count = 0;      
    foreach (var line in lines)       
    {             
        var parts = line.Split(',');             
        if (parts[1] == "223")             
        {
             sum += double.Parse(parts[2], CultureInfo.InvariantCulture);
             count++;
        }
     }
     Console.WriteLine($"The average is {sum / count}.");
}
public static void Version2()
 {
     var sum = 0d;
     var count = 0;
     string line;
     using (var fs = File.OpenRead(filePath)) 
     using (var reader = new StreamReader(fs))     
        while ((line = reader.ReadLine()) != null)     
        {         
            var parts = line.Split(',');         
            if (parts[1] == "223")         
            {             
                sum += double.Parse(parts[2], CultureInfo.InvariantCulture);
                count++;         
            }     
        } 
    Console.WriteLine($"The average is {sum / count}.");
 }

Não vou entrar em detalhes, mas basicamente o código da Versão 1 (Version1) faz a leitura de todos os dados que estão no CSV e coloca-os na memória, mais especificamente, na memória Heap. Durante todo o processo do código para avaliar e calcular a média, esse objeto com todos os dados do CSV será usado para ser percorrido.

Já na Versão 2 (Version2) a preocupação é grande em evitar trazer tudo para a memória. Por isso, a avaliação é feita durante o momento da leitura do arquivo CSV, trazendo uma linha em memória e avaliando-a, assim sucessivamente.

Antes de executar a ferramenta, vou alterar a configuração abaixo para registrar apenas os objetos que alocam memória 200 vezes ou mais:

Assim, a ferramenta retira objetos que alocaram poucas vezes a memória. Outra razão pela qual eu tive que fazer isso, foi porque a ferramenta iria demorar muito tempo para obter os dados e renderizá-los, se eu tivesse deixao o Default. Eu até tentei rodar assim, e  depois de 3 horas vi que não iria rolar… 😅

Agora, vamos executar esses códigos e comparar os resultados.

Resultados Versão 1

  • Tempo de avaliação da ferramenta: 1:53 minutos
  • Quantidade máxima de objetos que foram alocados na memória: 22k
Live Objetos e Object delta – Versão 1
  • Os três tipos de objetos mais alocados: String, Char e StringBuilder;
Aba Allocations – Versão 1
  • A quantidade de bytes de memória alocada pelos dois métodos ofensores foram: 4.392.658 bytes e 1.557.806 bytes.
Aba de Call Tree – Versão 1
  • A quantidade média de objetos que permaneciam na memória HEAP após o GC trabalhar era (peguei todos os dados na aba Collections e calculei essa média): 17.699 objetos.
Aba Collections – Versão 1

Resultados Versão 2

  • Tempo de avaliação da ferramenta: 14,519 segundos
  • Quantidade máxima de objetos que foram alocados na memória: 346
Live Objetos e Object delta – Versão 2
  • Os três tipos de objetos mais alocados: String, StringBuilder e SByte;
Aba Allocations – Versão 2
  • A quantidade de bytes de memória alocada pelos dois métodos ofensores foram: 2.915.032 bytes e 2.408.046 bytes.
Aba de Call Tree – Versão 2
  • A quantidade média de objetos que permaneciam na memória HEAP após o GC trabalhar era (peguei todos os dados na aba Collections e calculei essa média): 4 objetos.
Aba Collections – Versão 2

Conclusão

Como é possível verificar, a Versão 1 o código da versão tem uma quantidade muito grande de objetos permanecendo na memória Heap após o GC trabalhar. Por mais que a quantidade de objetos reference type que foram criados são quase iguais nas duas abordagens, o problema foi o tempo que eles precisaram ficar na memória. Com um simples ajuste, tivemos uma boa melhora.

Se você quiser brincar mais com essa ferramenta, no meu repositório, dentro da mesma classe que tem esses métodos (Version1 e Version2), existem mais 2 outras versões. Na Versão 4 (os códigos fontes destes exemplos vieram dos exemplos deste artigo: PARSING LARGE FILES), não é feito quase nenhum alocação da memória Heap e o GC nem precisar ser acionado… quando você tiver um tempo, faz um teste e compara os resultados. 😉

Configurações

Nas configurações desta ferramenta, você consegue alterar o threshold (limite) da quantidade de objetos que alocaram memória e vão ser computados na avaliação:

Configurações – Ferramenta: .NET Object Allocation Tracking

No exemplo acima, onde a quantidade de dados é muito grande, eu tive que alterar essa configuração para 200. Então, os objetos que foram computados foram apenas os que alocaram memória mais de 200 vezes.

Segue o link da documentação desta ferramenta: Analyze memory usage by using the .NET Object Allocation tool. E além desta documentação, segue um vídeo bem legal da Microsoft falando sobre essa ferramenta:

Vamos para a próxima ferramenta!

Ferramenta: .NET Counters

A .Net Counters foi a última ferramenta acrescentada pela Microsoft dentro do Performance Profiler. Ela é simples e fácil de usar, tanto que nem tem configurações. Não tem muitos detalhes assim como as outras que apresentei até agora, mas você consegue executá-la em conjunto com outras ferramentas:

Ferramenta: .Net Counters

Como é possível ver acima, ao selecioná-la, ainda fica habilitado para selecionar as ferramentas CPU Usage, Events Viewer, Database e .Net Async.

Provavelmente, você deve estar se perguntando para que serve essa ferramenta… bom, dentro do ecossistema do .NET existem alguns indicadores que informam sobre o tamanho da memória Heap e quando foi necessário usá-la para executar aquele código, ou o tempo médio de execução do GC, entre outros; e são úteis para obter um overview de como está a sua aplicação. Outro detalhe importante, é que essa ferramenta é muito leve e serve para ser executada em muitos cenários, mesmo se for para avaliar de forma geral um processo pesado ou demorado.

A ferramenta .Net Counters mostra os seguintes indicadores: 

Resultado do .Net Counters

Basicamente, cada um deles significam, respectivamente:

  • % Time in GC since last GC: Essa é a porcentagem do tempo gasto pelo GC desde o final da último vez que o GC foi executado. Por exemplo, já se passaram 1 milhão de ciclos desde que o último GC terminou e gastamos 0,3 milhões de ciclos no GC atual, este contador mostrará 30%;
  • Allocation Rate: taxa em bytes alocados na Heap entre os intervalos de avaliação;
  • CPU Usage: A porcentagem de uso da CPU do processo em relação a todos os recursos da CPU do sistema [0-100];
  • Exception Count: Quantidade de exceções;
  • GC Heap Size: Tamanho total do heap indicado pelo GC (MB);
  • Gen 0 GC Count: Número de GCs Gen 0 entre os intervalos de avaliação da ferramenta;
  • Gen 0 Size: Tamanho da Heap da geração 0;
  • Gen 1 GC Count: Número de GCs Gen 1 entre os intervalos de avaliação da ferramenta;
  • Gen 1 Size: Tamanho da Heap da geração 1;
  • Gen 2 GC Count: Número de GCs Gen 2 entre os intervalos de avaliação da ferramenta;
  • Gen 2 Size: Tamanho da Heap da geração 2;
  • LOH Size: Tamanho da Large Object Heap;
  • Monitor Lock Contention Count: Número de vezes que houve contenção ao tentar tirar o bloqueio do monitor (avaliador da ferramenta) entre os intervalos de avaliação da ferramenta;
  • Number of Active Timers: Número de temporizadores que estão atualmente ativos;
  • Number of Assemblies Loaded: Número de assemblies/DLLs carregados;
  • ThreadPool Completed Work Item Count: Contagem de itens de works concluídos do ThreadPool;
  • ThreadPool Queue Length: Comprimento da queue de itens de works ThreadPool;
  • ThreadPool Thread Count: Número de threads no ThreadPool;
  • Working Set: Quantidade de conjunto de works usado pelo processo (MB).

E para cada um destes indicadores tem o valor mínimo, máximo e a média.

Além de mostrar os indicadores, é possível selecionar os que você queira avaliar. E assim, será mostrado no gráfico em qual período do tempo de avaliação da ferramenta detectou aquele indicador:

É isso sobre essa ferramenta. Como eu disse, ela é bem simples, mas pode ser muito interessante se combinada com outra ferramenta. Por exemplo, executei ela junto com a CPU Usage e esse foi o resultado:

Resultado das ferramentas: .Net Counters e CPU Usage

Vou deixar aqui alguns link de referencia bem interessantes! Caso você tenha interessante, pode lê-los com calma:

Continuação

No próximo e último post vou apresentar as ferramentas:

  • .NET Async;
  • Database;

Então, até o próximo post.

Referências

Vídeos

  • Performance Profiling | Part 1 An Introduction
  • Performance Profiling | Part 2: Choosing the right tool
  • Performance with Profiling Part 3: Profiling and Production

Artigos

Publicado emAnalisador de CódigoEngenharia de SoftwareFerramentasMicrosoftPerformanceTroubleshootVisual Studio