DEV Community

Poveda
Poveda

Posted on • Edited on

1

Performance pode ser simples: Arquivo gigante em memória

Quando se fala em performance de uma aplicação é muito comum pensar em escovação de bit, mudança de paradigmas de implementação ou até mesmo de linguagem. No entanto, as principais otimizações acontecem olhando o básico: o gargalo atual da aplicação.

Nessa série de posts vou trazer alguns desafios que ajudei a resolver. A ideia é mostrar a linha de raciocínio, quais soluções se mostraram promissoras e quais soluções foram descartadas devido aos riscos adicionados. Em todos os casos demonstro que a ideia da otimização não teve qualquer coleta de ferramentas especializadas ou escovação de bit, apenas uso dos recursos do próprio visual studio, leitura de documentação, análise de código e conhecimento na linguagem utilizada.

Desafio: Geração do arquivo csv de 65Mb consumindo 1,2Gb de ram

Objetivo:

O sistema deve buscar no banco cerca de 200k registros, transformar os dados, gerar um arquivo .csv e transferi-lo para um fileserver remoto.

Cenário atual:

Todo o processamento e "armazenamento" dos dados fica em memória, ou seja, nada é salvo em disco do lado da aplicação. O arquivo só será salvo no disco do servidor de arquivos.

O problema encontrado:

Todo processamento aloca aproximadamente de 1,2Gb de memória para gerar um arquivo de aproximadamente 65Mb. Portanto existe um consumo de memória de 18x maior para gerar o arquivo em questão.

Mapeamento das restrições

O primeiro questionamento foi:

Por que não podemos salvar o arquivo localmente e em seguida transferi-lo o servidor de arquivos?

A resposta:

A data de publicação do sistema foi acordada com o cliente previamente e não pode mudar. Qualquer correção que ultrapasse a data acordada ficará para um segundo momento e o sistema subirá em produção no estado em que se encontra.

Inicio da análise

Com a restrição definida, hora de baixar o código, analisá-lo e colocá-lo para rodar.

Lendo novamente o objetivo do sistema pensei em 3 pontos de gargalo:

  1. A quantidade de dados retornados do banco
  2. A transformação dos dados
  3. A forma como o .csv é gerado

Analisando o código para cada uma das suspeitas cheguei nessas observações:

  1. O problema principal do retorno do banco de dados é que todos os registros são do tipo string.
  2. A transformação dos dados não é muito preocupante, pois 96% das informações já vêm prontas para serem escritas no arquivo.
  3. Na geração do csv é onde o problema começa a ficar evidente:
    1. Temos diversas operações com strings: Join, ToString.
    2. Diversos StringBuilders de uso único que invocam o método .ToString() ao final de cada execução
    3. StringBuilder mal utilizado

Com essas suspeitas anotadas hora de rodar a aplicação!

Rodando a aplicação

Realizo a primeira execução da aplicação no estado atual para entender melhor o fluxo, tempos de processamento, validar acessos, etc. Ao final do teste o resultado vai de encontro com o problema informado:

primeira execução mostra consumo de 1,2Gb de memória am e picos de 5% de uso do processador

Tamanho do arquivo gerado:

evidencia provando que tamanho do arquivo csv gerado é 68Mb

Executo a aplicação novamente para observar as mudanças de memória e encontrar o motivo da sua subida vertiginosa.

Utilizando o gráfico acima como referência, nota-se que existem 3 etapas no aumento de memória:

  1. A primeira curva representa a obtenção dos dados.
  2. Um platô representado pela configuração do arquivo csv
  3. Uma subida rápida representada pela geração do csv em memória

Portanto é na terceira etapa que o consumo de memória torna-se expressivo. Gargalo encontrado!

Implementando melhorias

Com o gargalo encontrado, voltamos as observações levantadas para a etapa 3: A forma como o .csv é construído.

  1. Temos diversas operações com strings: Join, ToString.
  2. Diversos StringBuilders de uso único e invocando o método .ToString() ao final de cada execução
  3. StringBuilder subutilizados

Os trechos de código abaixo mimetizam a estrutura original da aplicação:

Método GenerateInMemoryCsv: gera um CSV em memória

public static string GenerateInMemoryCsv(IEnumerable<Vehicle> vehicles)
{
   string[] headerFields =  Vehicle.GetHeaderFields();    
   string header = string.Join(";", headerFields);
   string rows = string.Join("\n", vehicles.Select(x => x.ConvertToCsv()));
   return new StringBuilder().AppendJoin("\n", header, rows).ToString();    
}
Enter fullscreen mode Exit fullscreen mode

Método GetHeaderField: obtém o cabeçalho do csv

public static string[] GetHeaderFields() =>
   new string[] { "Marca", "Ano", "Modelo", "Quantidade de Portas", "Motor" };
Enter fullscreen mode Exit fullscreen mode

Método ConvertToCsv: converte o registro em uma linha válida para o csv

public string ConvertToCsv()
{
   StringBuilder sb = new();
   sb.AppendJoin( ";", Marca, Ano, Modelo, QtdPortas, Motor);

   return sb.ToString();
}
Enter fullscreen mode Exit fullscreen mode

Reduzindo a alocação de strings

Pensando na questão do uso excessivo de operações com string que retornam outras strings, é necessário lembrar que a string é um tipo readonly. Parafraseando a documentação oficial do C#:

Objetos do tipo string são imutáveis: não podem ser alterados após serem criados.
Todos os métodos String e operadores C# que aparecem para modificar uma string retornam, na verdade, os resultados em um novo string.

Ou seja, para cada Join e para cada ToString utilizado, uma nova string é gerada sem descartar ou sobrescrever a antiga. Isso implica em dobrar a quantidade de memória cada vez que essas operações são utilizadas. Portanto o primeiro passo para otimização é reduzir a quantidade de operações do tipo Join e ToString.

Analisando os códigos acima o método que possui mais operações que retornam novas strings é o ConvertToCsv. Pode não parecer óbvio olhando os métodos separados, mas lembre-se do enunciado do problema: O sistema deve buscar no banco cerca de 200k registros.
Outra situação que chama atenção nesse método é a utilização de um StringBuilder efêmero utilizado para concatenar as strings. Apesar do StringBuilder ser ótimo para evitar a criação desnecessária de strings, neste caso ele gera um impacto negativo devido sua frequência de criação e descarte. Portanto, nessa correção o AppendJoin do StringBuilder foi substituído por um string.Join e o ToString do final do método foi removido. Essa mudança reduziu a quantidade de alocações de duas (criação do StringBuilder + nova string utilizando ToString) para uma (nova string utilizando Join).

O código do método ConvertToCsv refatorado fica assim:

public string ConvertToCsv()
{   
   return string.Join( ";", Marca, Ano, Modelo, QtdPortas, Motor);
}
Enter fullscreen mode Exit fullscreen mode

Após essa alteração, rodei novamente a aplicação e obtive o seguinte resultado:

segunda execução

É possível notar uma redução de 200Mb de memória ou 17% de ganho em relação a primeira operação. Ainda sim, não parece ser uma melhoria satisfatória, visto que a aplicação ainda utiliza 1Gb de ram para gerar um arquivo de 65Mb (aproximadamente 16x mais).

Otimizando a utilização do StringBuilder do método GenerateInMemoryCsv

Conforme comentado anteriormente, a aplicação possuía diversos StringBuilders mal utilizados e em sua maioria desnecessários. O StringBuilder do método GenerateInMemoryCsv é o único caso útil para o fluxo e encontra-se subutilizado. Contudo, a forma atual de sua utilização, concatenar o cabeçalho com o corpo, gera o mesmo resultado de uma concatenação de string simples e com alocação de memória semelhante. Portanto o código deve ser alterado para melhor aproveitar esse StringBuilder.

Realizando a mudança do código para melhor aproveitamento do StringBuilder o método ficaria assim:

public static string GenerateInMemoryCsv(IEnumerable<Vehicle> vehicles)
{
   string[] headerFields =  Vehicle.GetHeaderFields();    
   string header = string.Join(";", headerFields);

   StringBuilder csvInMemory = new StringBuilder(header);
   csvInMemory.AppendJoin("\n", vehicles.Select(x => x.ConvertToCsv()));

   return csvInMemory.ToString();    
}
Enter fullscreen mode Exit fullscreen mode

Obs: Poderíamos realizar o AppendJoin dos campos de header direto no StringBuilder e evitar uma alocação extra de string, porém essa otimização aqui não compensa. Preferi manter a legibilidade do código do que ganhar alguns Kb nessa operação.

Esse tipo de escolha também faz parte do processo de otimização, pois evita gastar energia onde não haverá melhorias expressivas.

Rodando novamente o código obtive o seguinte resultado:

terceira execução

Agora sim uma redução de memória expressiva. A aplicação reduziu o consumo em aproximadamente 750Mb ou 60% em relação a implementação original.

Com toda essa redução de consumo fica a pergunta

Será é possível diminuir a alocação de memória ainda mais?

Nem toda otimização traz benefícios

Com as melhorias no fluxo mais crítico concluídas e ainda sobrando um tempo, resolvi revisitar a primeira rampa de memória e descobri que essa alocação é gerada pela obtenção dos registros no banco de dados. Revisando o método de obtenção dos dados, notei que mesmo utilizando um IEnumerable ocorria essa alocação de memória expressiva. Supus que ocorria a alocação de uma lista por baixo dos panos (viva o polimorfismo). No método implementado não existia o uso do ToList, portanto a alocação deveria ocorrer no método Query do Dapper. Olhando os parâmetros do método Query encontrei o parâmetro chamado buffered(opcional). O buffered é utilizado para decidir se o resultado da query deve ser carregado numa lista em memória ou não. Por padrão esse parâmetro é true, ou seja, cria a lista em memória. Buscando o código do método Query na lib, é possível ter uma ideia do que acontece:

 public static IEnumerable<T> Query<T>(this IDbConnection cnn, string sql, object param = null, IDbTransaction transaction = null, bool buffered = true, int? commandTimeout = null, CommandType? commandType = null)
 {
     var command = new CommandDefinition(sql, param, transaction, commandTimeout, commandType, buffered ? CommandFlags.Buffered : CommandFlags.None);
     var data = QueryImpl<T>(cnn, command, typeof(T));
     return command.Buffered ? data.ToList() : data;
 }
Enter fullscreen mode Exit fullscreen mode

Nesse caso usei o disassembly do próprio visual studio, mas é possível obter o código a partir do repositório do github

Método ToList encontrado! Quando o parâmetro buffered é passado como true o método Query devolve um List mesmo que o IEnumerable seja retornado.

Resolvi passar o valor dessa variável como false e realizar um nova execução. O resultado foi mais surpreendente do que o esperado. O gráfico abaixo mostra a diferença de alocação de memória:

quarta execução

Tivemos uma queda de 64% no consumo de memória(306Mb) em relação a execução anterior. Inclusive o processador teve um pequeno ganho (não calculado). No entanto, o tempo da execução cresceu 2,5x, pois agora toda operação realizada sobre os resultados necessita iterar sobre essa lista. No processo existem pelo menos 3 operações na lista (Any, Count e Geração do CSV). Mesmo eliminando ou remanejando o Count, o tempo de execução segue alto. Outro ponto bastante preocupante é o risco de uma desconexão com o banco ocasionar um erro no processo.

Para o cenário apresentado, o tempo de execução e a confiabilidade da execução também eram requisitos importantes. Portanto, por mais que essa otimização reduza bastante a quantidade de memória alocada, ela não compensa pelos dois pontos mencionados.

Ideias de evolução

A trilha de otimização dessa aplicação gerou um resultado satisfatório, porém distante do ideal. Boa parte do trabalho de otimização poderia ser atingido mudando a forma como o arquivo é gerado e transferido.

A primeira ideia é salvar o arquivo localmente antes de envia-lo para o destino. Essa abordagem faz o consumo de memória ficar muito próximo ao da otimização de leitura direto do banco (sem buffer), pois não seria necessário criar todo o arquivo em memória antes da transferência. Extrapolando a ideia, poderia ser feita a leitura da informação direto do banco, transformar cada linha e salvar direto no arquivo. É óbvio que outros tipos de decisão e controle deveriam ser tomados, mas isso faz parte do processo de otimização.

Outra ideia que tive durante esse processo e que ainda carece de estudos e testes é utilizar stream de dados e gravar o documento direto no destino, sem a necessidade de gerar um arquivo local ou construir todo o arquivo em memória. Algumas dúvidas que teriam que ser respondidas:

  1. O C# permite tal abordagem apontando para um servidor remoto?
  2. O que fazer em caso de falha de comunicação com servidor remoto durante o processo de escrita?
  3. Faria a escrita do arquivo em blocos de x registros ou tudo de uma vez?
  4. Escrevendo o arquivo em blocos, em caso de falha, iniciar todo o processo novamente ou ter alguma forma de continuar onde parou?

O projeto está disponível no github para quem desejar baixar e conferir a implementação e os testes executados

https://github.com/julianopoveda/export_file

Conclusão

Como foi mostrado nesse post, pequenas melhorias no código podem trazer grandes vantagens na alocação de recursos. Já outras soluções podem trazer melhorias de um lado, agregando mais risco e exigindo novos controles para a aplicação.

Por fim, uma questão que pode ter sido levantada na mente do leitor é o fato de eu ter focado exclusivamente na alocação de memória e não ter me preocupado com o uso da CPU. Tal decisão foi tomada lá no inicio da análise ao descobrir que a aplicação lida muito com IO (consulta no banco, alocação de memória, transferência do arquivo) e pouco com CPU. A medida que a correção ia evoluindo, o uso de CPU manteve-se estável, provando que estávamos trabalhando com uma aplicação de pouco processamento.

Buy Me a Coffee at ko-fi.com

Referências

Speedy emails, satisfied customers

Postmark Image

Are delayed transactional emails costing you user satisfaction? Postmark delivers your emails almost instantly, keeping your customers happy and connected.

Sign up

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay