Esta publicação está disponível em vídeo, ampliada e revisada, no canal da EximiaCo.
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. 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
Vamos supor que você precise calcular a média das classificações, ou ratings, dadas ao filme ‘Coração Valente’, que possui `movieId`= 110. Como você o implementaria? Provavelmente uma primeira versão seria semelhante com a seguinte implementação:
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}).");
O código anterior é fácil de ler, o que é muito bom. Entretanto, é lento (demorou mais de 6 segundos para ser executado na minha máquina) e consome muita RAM (mais de 2 GB alocados processando 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. Não há necessidade de fazer isso.
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, estamos carregando dados conforme necessário e descartando-os da memória. Esse código é ~ 30% mais rápido que o anterior, exige menos memória (não mais que 13 MB para processar um arquivo de 500 MB) e exerce menos pressão sobre o garbage collector (não há mais grandes objetos nem objetos que sobrevivam às coletas gen#0) .
Vamos tentar algo diferente.
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 memoria, reduzir a pressão no garba collector e obter melhor desempenho. Sucesso! Esse código é 4x mais rápido que o original, consome apenas 6 MB e exige ~ 50% menos coleções do garbage collector (Parabéns, Microsoft!).
Porém, 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<byte>(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.
O que você acha? Como você implementaria essa solução? Compartilhe suas ideias nos comentários.
enfrentei esse problema de fazer parser de grandes arquivos utilizando Span para ter uma performance melhor; no fim acabei criando uma lib pra isso, ja que as demais usam string e portanto tem performance ruim.; a quem interessar
https://github.com/leandromoh/RecordParser
nesse não esta linfo quando a ultima linha não tem \r\n,,, estou tentado ler com o caso