Fazendo “parsing” de arquivos grandes

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.

Compartilhe este insight:

Comentários

Participe deixando seu comentário sobre este artigo a seguir:

Subscribe
Notify of
guest
2 Comentários
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
leandromoh
leandromoh
7 meses atrás

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

Juan
Juan
2 meses atrás

nesse não esta linfo quando a ultima linha não tem \r\n,,, estou tentado ler com o caso

AUTOR

Elemar Júnior
Fundador e CEO da EximiaCo atua como tech trusted advisor ajudando empresas a gerar mais resultados através da tecnologia.

SOLUÇÕES EXIMIACO

ESTRATÉGIA & EXECUÇÃO EM TI

Simplificamos, potencializamos 
aceleramos resultados usando a tecnologia do jeito certo.

COMO PODEMOS LHE AJUDAR?

Vamos marcar uma conversa para que possamos entender melhor sua situação e juntos avaliar de que forma a tecnologia pode trazer mais resultados para o seu negócio.

COMO PODEMOS LHE AJUDAR?

Vamos marcar uma conversa para que possamos entender melhor sua situação e juntos avaliar de que forma a tecnologia pode trazer mais resultados para o seu negócio.

+55 51 3049-7890
[email protected]

+55 51 3049-7890 |  [email protected]

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

Tenho interesse em conversar

Se você está querendo gerar resultados através da tecnologia, preencha este formulário que um de nossos consultores entrará em contato com você:

O seu insight foi excluído com sucesso!

O seu insight foi excluído e não está mais disponível.

O seu insight foi salvo com sucesso!

Ele está na fila de espera, aguardando ser revisado para ter sua publicação programada.