Devemos esquecer as pequenas eficiências, digamos cerca de 97% das vezes: a otimização prematura é a raiz de todos os males. No entanto, não devemos perder nossas oportunidades nesses 3% críticos.
Donald E. Knuth
Performance sempre será um atributo de qualidade importante, em qualquer sistema. Sem performance satisfatória, aplicações perdem em usabilidade, na percepção de disponibilidade e, seguramente, na escalabilidade. Em última instância, sistemas que não atendem expectativas mínimas de boa performance falham em cumprir os objetivos do negócio.
0
Você concorda?x

Para que um bom sistema tenha boa performance, é necessário analisar criteriosamente aspectos como o workload que precisa ser suportado, os recursos tecnológicos disponíveis, comportamento dos usuários que pode gerar enfileiramento e, claro, a qualidade do código. Ou seja, há bem mais fatores influenciando a performance do que a qualidade do código. Ainda assim, ela é extremamente relevante.

Como programadores, é nosso compromisso escrever código de qualidade que colabore para a boa performance. Entretanto, não podemos ignorar o fato de que, frequentemente, código mais performático também é mais difícil de manter. Por isso, precisamos entender como balancear excelência em performance com outros atributos de qualidade.

Este manual tem como propósito apresentar tecnologias, padrões e práticas de codificação em .NET que melhoram a performance, mas, também, claramente, ponderar sobre quando essas técnicas são aplicáveis.

Um desafio, quatro soluções

Realizar o parse de arquivos grandes é uma tarefa recorrente no dia a dia de desenvolvedores, ao mesmo tempo em que pode ser desafiadora. É muito fácil, ao fazer parsing, escrever código lento que consome muita memória – em termos simples, com performance ruim.

Como exemplo, vamos considerar um arquivo CSV com a seguinte estrutura (o tamanho completo do arquivo é de ~500 MB):

userId,movieId,rating,timestamp
1,2,3.5,1112486027
1,29,3.5,1112484676
1,32,3.5,1112484819
1,47,3.5,1112484727
1,50,3.5,1112484580
1,112,3.5,1094785740
1,151,4.0,1094785734
1,223,4.0,1112485573
1,253,4.0,1112484940

Esse arquivo contém avaliações para diversos filmes, de diversos usuários. Imagine-se desafiado a implementar o cálculo da média das avaliações para o filme “Coração Valente”, cujo id é 110.

Solução #1 – Em busca da simplicidade

Buscando simplicidade, uma primeira implementação honesta, provavelmente, poderia ser semelhante com a que segue:

var lines = File.ReadAllLines(filePath);
var sum = 0d;
var count = 0;

foreach (var line in lines)
{
    var parts = line.Split(',');

    if (parts[1] == "110")
    {
        sum += double.Parse(parts[2], CultureInfo.InvariantCulture);
        count++;
    }
}

Console.WriteLine($"Classificação média para o filme Coração Valente é {sum/count} ({count}).");

De fato, essa “primeira tentativa” tem código é fácil de ler, o que é muito bom. Entretanto, é potencialmente pouco performática e exigente de recursos (demorou mais de 6 segundos para ser executado na minha máquina, alocando mais de 2 GB de RAM para processar um arquivo de 500 MB).

O principal problema dessa implementação é que estamos trazendo todos os dados para a memória, pressionando bastante o garbage collector.

Solução #2 – Eliminando desperdícios evidentes

De fato, a primeira solução apresentada é demasiadamente ingênua. Manter todo o arquivo na memória, o tempo todo, parece ser uma medida obviamente ineficaz. Mais sensato é carregar dados conforme o necessário, descartando-os assim que possível.

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] == "110")
    {
        sum += double.Parse(parts[2], CultureInfo.InvariantCulture);
        count++;
    }
}
Console.WriteLine($"Classificação média para o filme Coração Valente é {sum/count} ({count}).");

Desta vez, os dados vão para a memória conforme o necessário e são liberados em seguida. Essa abordagem foi ~ 30% mais rápido performática que a anterior, exigindo muito menos memória (não mais que 13 MB para processar um arquivo de 500 MB) . Parte do ganho ocorreu por que esse código faz menos pressão sobre o garbage collector (não há mais grandes objetos nem objetos que sobrevivam às coletas gen#0).

Solução #3 – Diminuindo o volume de alocações

Como ficará muito evidente neste livro, em .NET, o garbage collector pode exercer forte influência sobre a performance.

Tentemos, agora, algo diferente, exercendo ainda menos pressão sobre o GC:

var sum = 0d;
var count = 0;
string line;

// ID do filme Coração Valente como um Span;
var lookingFor = "110".AsSpan();

using (var fs = File.OpenRead(filePath))
using (var reader = new StreamReader(fs))
while ((line = reader.ReadLine()) != null)
{
    // Ignorando o ID do usuário 
    var span = line.AsSpan(line.IndexOf(',') + 1);

    // ID do filme
    var firstCommaPos = span.IndexOf(',');
    var movieId = span.Slice(0, firstCommaPos);
    if (!movieId.SequenceEqual(lookingFor)) continue;

    // Classificação
    span = span.Slice(firstCommaPos + 1);
    firstCommaPos = span.IndexOf(',');
    var rating = double.Parse(span.Slice(0, firstCommaPos), provider: CultureInfo.InvariantCulture);

    sum += rating;
    count++;
}

O objetivo principal do código anterior era alocar menos objetos na memória, reduzindo a pressão no garbage collector como forma de melhorar ainda mais a performance. Na prática, essa abordagem tem performance 4x melhor que a original, consumindo apenas 6 MB e exige ~50% menos coleções do garbage collector.

Solução #4 – Diminuindo (ainda mais) o volume de alocações

Ainda estamos alocando um objeto string para cada linha do arquivo CSV. Vamos mudar isso.

var sum = 0d;
var count = 0;

var lookingFor = Encoding.UTF8.GetBytes("110").AsSpan();
var rawBuffer =  new byte[1024*1024];
using (var fs = File.OpenRead(filePath))
{
    var bytesBuffered = 0;
    var bytesConsumed = 0;

    while (true)
    {
        var bytesRead = fs.Read(rawBuffer, bytesBuffered, rawBuffer.Length - bytesBuffered);

        if (bytesRead == 0) break;
        bytesBuffered += bytesRead;

        int linePosition;

        do
        {
            linePosition = Array.IndexOf(rawBuffer, (byte) 'n', bytesConsumed,
                bytesBuffered - bytesConsumed);

            if (linePosition >= 0)
            {
                var lineLength = linePosition - bytesConsumed;
                var line = new Span(rawBuffer, bytesConsumed, lineLength);
                bytesConsumed += lineLength + 1;


                // Ignorando o ID do usuário
                var span = line.Slice(line.IndexOf((byte)',') + 1);

                // ID do filme
                var firstCommaPos = span.IndexOf((byte)',');
                var movieId = span.Slice(0, firstCommaPos);
                if (!movieId.SequenceEqual(lookingFor)) continue;

                // Classíficação
                span = span.Slice(firstCommaPos + 1);
                firstCommaPos = span.IndexOf((byte)',');
                var rating = double.Parse(Encoding.UTF8.GetString(span.Slice(0, firstCommaPos)), provider: CultureInfo.InvariantCulture);

                sum += rating;
                count++;
            }

        } while (linePosition >= 0 );

        Array.Copy(rawBuffer, bytesConsumed, rawBuffer, 0, (bytesBuffered - bytesConsumed));
        bytesBuffered -= bytesConsumed;
        bytesConsumed = 0;
    }
}
Console.WriteLine($"Classificação média para o filme Coração Valente é {sum/count} ({count}).");

Desta vez, estamos carregando os dados em chunks de 1 MB. O código parece um pouco mais complexo (e é). Mas, ele é executado quase 10x mais rápido que a implementação original. Além disso, não há alocações suficientes para ativar o garbage collector.

Maior performance, mais complexidade

As quatro soluções propostas na seção anterior parecem evidenciar um problema comum em otimização para a performance: quanto mais otimizado, geralmente mais complexo é o código.

O último código é 10x mais performático do que o primeiro, consumindo um fração irrisória dos recursos computacionais. Entretanto, é evidentemente mais complexo. Enquanto o primeiro código é evidente, o último não é fácil nem mesmo para programadores mais experientes.

Encontrando o equilíbrio

A demanda por performance e, consequentemente, a tolerância a complexidade devem ser observadas criteriosamente, analisando o workload e as restrições de infraestrutura.

Seis segundos para processar 500 MB de informação pode não ser um grande problema, nem mesmo a demanda de 2 GB de memória RAM. Tudo depende da quantidade de arquivos com esse tamanho que precisam ser processados (workload), bem como a taxa de chegada (arrival rate).

Para a grande maioria dos cenários, a segunda solução, que processa os mesmos 500MB em ~4,5 segundos parece ser ok, principalmente pelo uso moderado de memória RAM (apenas 13 MB). Raramente, uma abordagem extrema, como a da quarta solução fará sentido.

O que vem por aí…

Em todos os capítulos desse livro apresentarei técnicas, padrões e práticas que vão lhe ajudar a escrever código com performance extrema. Entretanto, sabendo que com grandes poderes vêm grandes responsabilidades, irei sempre apresentar os prós e contras que vão te ajudar a escrever código rápido, não só de executar, mas de manter.

Referências bibliográficas

SEVERO JÚNIOR, Elemar R.. Manual do Arquiteto de Software. Campo Bom, Rs: Eximiaco, 2021. Disponível em: https://arquiteturadesoftware.online/. Acesso em: 29 jun. 2021.

Compartilhe este capítulo:

Compartilhe:

Comentários

Participe da construção deste capítulo deixando seu comentário:

Inscrever-se
Notify of
guest
3 Comentários
Oldest
Newest Most Voted
Feedbacks interativos
Ver todos os comentários
Atila Fernandes Moreira
Atila Fernandes Moreira
3 meses atrás

Muito legal o tema e a abordagem, direto ao ponto.. Exemplos práticos e explicação clara.. Parabéns.. Aguardando ansioso os próximos capítulos..

Apenas como observação, ficou estranho o comentário do primeiro exemplo:

De fato, essa “primeira tentativa” tem código é fácil de ler, 

Parece que seria tem Um código QUE é

Gabriel Simas
Gabriel Simas
3 meses atrás

Perfeito Elemar… tenho um arquivo de 7Gb aqui pra passar pra uma base de Dados, será que consigo utilizar esse seu exemplo e não causar muito dano na memória?

Luiz Fogliato
Luiz Fogliato
3 meses atrás

Olá Elemar!

Admiro demais o seu trabalho e tento acompanhar suas publicações nas diferentes plataformas.
Esse post é simplesmente genial e me despertou muita reflexão.
Como sugestão, antes de falar de códigos .Net de alta performance acredito que seja completamente relevante para o propósito desse livro dizer porque .Net C# é indicado para alta performance, comparando com outras alternativas do mercado, por exemplo, vi algumas postagens com comparações de APIs em .Net Core entregando mais requisições por segundo que algo equivalente implementado em NodeJs ou Java, por exemplo. Comparações sobre determinadas situações pode ajudar times tomar a decisão de utilizar .Net como uma de suas stacks principais quando se tem por objetivo a performance como um dos focos para o negócio.

Sou seu fã, grande abraço!

AUTOR

Elemar Júnior

Fundador e CEO da EximiaCo, atua como tech trusted advisor ajudando diversas empresas a gerar mais resultados através da tecnologia. 

COAUTOR

Raphael Castilho

Desenvolvedor especialista em .NET com experiência em aplicações corporativas de larga escala na EximiaCo.

TECH

&

BIZ

-   Insights e provocações sobre tecnologia e negócios   -   

55 51 9 9942 0609  |  me@elemarjr.com

+55 51 3049-7890 |  contato@eximia.co

+55 51 3049-7890 |  contato@eximia.co

3
0
Quero saber a sua opinião, deixe seu comentáriox
()
x