Como realizar testes de performance

Na engenharia de software é comum existir várias soluções para o mesmo problema. A escolha da solução mais adequada depende de fatores como: performance, manutenção, disponibilidade e criticidade da aplicação.

Ao se tratar de performance, é necessário obter dados que justifiquem ou comprovem a escolha de uma solução ao contrário de outra. Esses dados podem ser obtidos e avaliados a partir de benchmarks.

Benchmark é o ato de testar trechos de código e transformar os resultados obtidos em relatórios que possuem métricas e informações detalhadas sobre os recursos utilizados e tempo de processamento gasto.

Isso possibilita analisar a performance do código atual e entender exatamente qual trecho de código precisa ser otimizado, ou então entender se algum código novo degradou a performance da aplicação.

Ao melhorar a performance da aplicação, você gasta menos recursos computacionais, economiza custos, aumenta escalabilidade e oferece menos latência aos clientes e consumidores da aplicação.

BenchmarkDotNet

BenchmarkDotNet é uma excelente biblioteca open-source que gera benchmarks confiáveis e precisos de forma simples. A biblioteca é muito conhecida e utilizada pela comunidade, além da própria Microsoft utilizar em projetos críticos referentes à performance, como no runtime do dotnet, Entity Framework Core, Roslyn, ASP.NET Core, SignalR, entre outros.

Antes de utilizarmos a biblioteca, considere o código abaixo, que compara duas strings e retorna se elas possuem o mesmo conteúdo:

public bool EqualsToUpper(string str, string str2)
{
    return str.ToUpper() == str2.ToUpper();
}

É um código simples e muito utilizado por vários desenvolvedores. Como será a performance dele?

equalsToUpperBenchmark

Podemos ver no resultado acima que sua performance possui um tempo médio de 62 ns (nanosegundos) e que em cada execução o método aloca 80 bytes de memória.

Será que é possível melhorar essa performance?

Vamos explorar outras formas de resolver o mesmo problema:

public bool EqualsInvariantCultureIgnoreCase(string str, string str2)
{
    return str.Equals(str2, StringComparison.InvariantCultureIgnoreCase);
}

public bool EqualsOrdinalIgnoreCase(string str, string str2)
{
    return str.Equals(str2, StringComparison.OrdinalIgnoreCase);
}

Executando os testes de performance novamente:

stringEqualsSecondBenchmark

Utilizando outras soluções para o mesmo problema, conseguimos obter resultados muito melhores tanto no tempo de execução quanto na quantidade de memória alocada. A memória alocada nos dois métodos iniciais acima é zero, além do tempo de processamento ser muito menor.

É incrível como pequenas mudanças de código podem surtir um efeito muito grande na performance da aplicação. Entender o que acontece “por baixo dos panos” é essencial para entender a diferença de performance entre várias soluções.

Strings no .NET são imutáveis, isso significa que o conteúdo dela não pode ser alterado após o objeto ser criado. Caso alguma modificação seja feita em um string, o compilador do C#, internamente, cria uma nova string. As strings também são conhecidas por serem do tipo referência, ou seja, seu valor é armazenado na área de memória “Heap”, enquanto que seu endereço/referência é armazenado na área de memória “Stack”.

É por isso que o método EqualsToUpper é lerdo, pois internamente é criada outra string ao utilizar o método ToUpper(), e como foi explicado, durante a criação de strings elas são armazenadas na área de memória “Heap”, alocando memória durante sua execução.

A biblioteca BenchmarkDotNet também permite realizar testes de performance utilizando runtimes diferentes ao mesmo tempo e comparar o desempenho de cada um. Isso pode ajudar na tomada de decisão sobre atualizar a versão do .NET. Escrevi um post somente sobre este assunto aqui.

Considere os seguintes métodos:

private IEnumerable<int> _source = Enumerable.Range(1, 1024).ToArray();

public void Min() => _source.Min();
public void Max() => _source.Max();
public void Sum() => _source.Sum();
public void Average() => _source.Average();
public void OrderBy() => _source.OrderBy(x => x);

Como será o desempenho deles se executarmos em diferentes runtimes?

linqBenchmarks

É possível notar que a Microsoft fez um trabalho espetacular na melhoria do LINQ no .NET 7, reduzindo memória alocada e muito tempo de processamento. Portanto, vale a pena realizar comparações entre diferentes runtimes, pois a atualização de versão pode te proporcionar muito desempenho sem grandes alterações ou refatorações em códigos já existentes.

O projeto utilizado de exemplo pode ser acessado clicando aqui.

Conclusão

A performance de um algoritmo é definida pelas estruturas de dados utilizadas e lógica aplicada, e por se tratar de um ponto muito importante em qualquer aplicação, ela não pode ser ignorada.

Porém, mais importante do que a otimização de códigos, é saber quando e o que otimizar.

A melhor maneira de saber quando uma otimização deve ser feita é definir a performance necessária para uma funcionalidade. Dessa maneira, é possível concentrar esforços em soluções mais performáticas e mensurar o progresso feito através de benchmarks.

Para identificar o que otimizar, um bom início é mapear todos os hot paths (trechos de códigos mais executados) da aplicação e iniciar por eles. Tenho certeza de que o resultado será muito positivo e notável.

Seguindo essas boas práticas, é possível evitar a otimização prematura, concentrando em entregas de valor ao negócio e ao mesmo tempo não deixar de lado possíveis refatorações e melhorias de performance quando for necessário.

A escrita de um código legível facilita refatorações e otimizações focadas em performance. A utilização de testes unitários também simplifica esse processo, dedicando-se a validar o comportamento da aplicação e garantindo que seu comportamento não seja alterado.

Também é importante mencionar que os compiladores atuais otimizam seu código e utilizam técnicas bem avançadas para isso, visando melhorar performance e reduzir o uso de recursos computacionais. Algumas otimizações aplicadas podem ser consultadas aqui.

Em suma, a decisão sobre otimizar ou não a aplicação deve ser feita em conjunto pela equipe, que, munidos de informações relevantes e conhecimentos específicos sobre o negócio, podem tomar uma decisão apropriada.