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.
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 é
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?
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!
Oi Elemar.
Gostei da abordagem. O equilíbrio entre complexidade e performance é algo que muitas vezes os desenvolvedores juniores não possuem experiência suficiente para determinar.
Lendo aqui, tive várias ideias sobre os possíveis tópicos. Sinta-se livre para usar como quiser o resultado do meu brainstorm:
Apêndice: Os pecados em banco de dados relacionais.
Simplesmente sensacional. Aprendizagem inclusive sobre a verdadeira computação.