Esta publicação está disponível em vídeo, ampliada e revisada, no canal da EximiaCo.
Uma das causas mais comuns para problemas de performance em .NET é o descuido com o Garbage Collector. Mesmo funções simples, se implementadas de forma descuidada, podem ocasionar boas dores de cabeça.
Neste post, compartilha a análise e otimização de uma simples função para validação de CPF. Algo pequeno, frente ao tamanho de um sistema, mas sintomático quanto ao estilo de código que temos encontrado.
Como fizemos o teste
Acreditamos que é sempre importante ter mensurações para pautar qualquer otimização. Eis um exemplo simples de como mensurar uma função isolada:
static void Main(string[] args) { var sw = new Stopwatch(); var before2 = GC.CollectionCount(2); var before1 = GC.CollectionCount(1); var before0 = GC.CollectionCount(0); Func<string, bool> sut = Original.ValidarCPF; sw.Start(); for (int i = 0; i < 1_000_000; i++) { if (!sut("771.189.500-33")) { throw new Exception("Error!"); } if (sut("771.189.500-34")) { throw new Exception("Error!"); } } sw.Stop(); Console.WriteLine("Tempo total: {sw.ElapsedMilliseconds}ms"); Console.WriteLine("GC Gen #2 : {GC.CollectionCount(2) - before2}"); Console.WriteLine("GC Gen #1 : {GC.CollectionCount(1) - before1}"); Console.WriteLine("GC Gen #0 : {GC.CollectionCount(0) - before0}"); Console.WriteLine("Done!"); }
Aqui, estamos apurando tempo aproximado de execução. Poderíamos utilizar algo mais preciso (como Benchmark.net), mas, geralmente começamos considerando uma abordagem mais simples (apenas para termos uma ordem de grandeza).
Como, geralmente, problemas de performance tem relação direta com o Garbage Collector, apuramos o comportamento do mesmo também.
Obviamente, testar performance validando 1_000_000 de CPFs é um exagero. Perceba, porém, que este número “exagerado”, serve para que a comparação seja perceptível.
Mais uma vez, a ideia não é focar na função em si, mas destacar problemas com o estilo de codificação adotado.
O código original
Aqui, o código original, encontrado em uma base de código quente de um cliente.
public static bool ValidarCPF(string sourceCPF) { if (String.IsNullOrWhiteSpace(sourceCPF)) return false; string clearCPF; clearCPF = sourceCPF.Trim(); clearCPF = clearCPF.Replace("-", ""); clearCPF = clearCPF.Replace(".", ""); if (clearCPF.Length != 11) { return false; } int[] cpfArray; int totalDigitoI = 0; int totalDigitoII = 0; int modI; int modII; if (clearCPF.Equals("00000000000") || clearCPF.Equals("11111111111") || clearCPF.Equals("22222222222") || clearCPF.Equals("33333333333") || clearCPF.Equals("44444444444") || clearCPF.Equals("55555555555") || clearCPF.Equals("66666666666") || clearCPF.Equals("77777777777") || clearCPF.Equals("88888888888") || clearCPF.Equals("99999999999")) { return false; } foreach (char c in clearCPF) { if (!char.IsNumber(c)) { return false; } } cpfArray = new int[11]; for (int i = 0; i < clearCPF.Length; i++) { cpfArray[i] = int.Parse(clearCPF[i].ToString()); } for (int posicao = 0; posicao < cpfArray.Length - 2; posicao++) { totalDigitoI += cpfArray[posicao] * (10 - posicao); totalDigitoII += cpfArray[posicao] * (11 - posicao); } modI = totalDigitoI % 11; if (modI < 2) { modI = 0; } else { modI = 11 - modI; } if (cpfArray[9] != modI) { return false; } totalDigitoII += modI * 2; modII = totalDigitoII % 11; if (modII < 2) { modII = 0; } else { modII = 11 - modII; } if (cpfArray[10] != modII) { return false; } // CPF Válido! return true; }
Eis o os resultados .
O número que chama atenção aqui é a quantidade de coletas na Gen0 (algumas centenas).
Eliminando alocações obviamente desnecessárias
A primeira coisa que fizemos para diminuir a pressão sobre o Garbage Collector foi reduzir a quantidade de alocações. Começamos pela parte mais óbvia: todo digito do CPF, antes de ser convertido para número, gerava um string. Além disso, um array era gerado para armazenar a “versão numérica” do CPF. Optamos por uma estratégia sem essas alocações.
public static class Version2 { [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int ObterDigito( string value, int pos ) => value[pos] - '0'; public static bool ValidarCPF(string sourceCPF) { if (String.IsNullOrWhiteSpace(sourceCPF)) return false; var clearCPF = sourceCPF.Trim(); clearCPF = clearCPF.Replace("-", ""); clearCPF = clearCPF.Replace(".", ""); if (clearCPF.Length != 11) { return false; } int totalDigitoI = 0; int totalDigitoII = 0; if (clearCPF.Equals("00000000000") || clearCPF.Equals("11111111111") || clearCPF.Equals("22222222222") || clearCPF.Equals("33333333333") || clearCPF.Equals("44444444444") || clearCPF.Equals("55555555555") || clearCPF.Equals("66666666666") || clearCPF.Equals("77777777777") || clearCPF.Equals("88888888888") || clearCPF.Equals("99999999999")) { return false; } foreach (char c in clearCPF) { if (!char.IsNumber(c)) { return false; } } for (int posicao = 0; posicao < clearCPF.Length - 2; posicao++) { totalDigitoI += ObterDigito(clearCPF, posicao) * (10 - posicao); totalDigitoII += ObterDigito(clearCPF, posicao) * (11 - posicao); } var modI = totalDigitoI % 11; if (modI < 2) { modI = 0; } else { modI = 11 - modI; } if (ObterDigito(clearCPF, 9) != modI) { return false; } totalDigitoII += modI * 2; var modII = totalDigitoII % 11; if (modII < 2) { modII = 0; } else { modII = 11 - modII; } if (ObterDigito(clearCPF, 10) != modII) { return false; } return true; } }
O resultado obtido foi animador.
Reduzimos o volume de alocações, logo, reduzimos a quantidade de coletas de lixo e melhoramos a performance do nosso código em mais de 4x.
Trocando alocações por processamento
Agora, vamos remover as 3 alocações de strings que ocorrem logo no início do código. VAmos pagar o preço com processamento para ver o resultado.
public static class Version3 { public struct Cpf { private readonly string _value; private Cpf(string value) { _value = value; } public int CalculaNumeroDeDigitos() { if (_value == null) { return 0; } var result = 0; for (var i = 0; i < _value.Length; i++) { if (char.IsDigit(_value[i])) { result++; } } return result; } public bool VerficarSeTodosOsDigitosSaoIdenticos() { var previous = -1; for (var i = 0; i < _value.Length; i++) { if (char.IsDigit(_value[i])) { var digito = _value[i] - '0'; if (previous == -1) { previous = digito; } else { if (previous != digito) { return false; } } } } return true; } public int ObterDigito(int posicao) { int count = 0; for (int i = 0; i < _value.Length; i++) { if (char.IsDigit(_value[i])) { if (count == posicao) { return _value[i] - '0'; } count++; } } return 0; } public static implicit operator Cpf(string value) => new Cpf(value); public override string ToString() => _value; } public static bool ValidarCPF(Cpf sourceCPF) { if (sourceCPF.CalculaNumeroDeDigitos() != 11) { return false; } int totalDigitoI = 0; int totalDigitoII = 0; if (sourceCPF.VerficarSeTodosOsDigitosSaoIdenticos()) { return false; } for (int posicao = 0; posicao < 9; posicao++) { var digito = sourceCPF.ObterDigito(posicao); totalDigitoI += digito * (10 - posicao); totalDigitoII += digito * (11 - posicao); } var modI = totalDigitoI % 11; if (modI < 2) { modI = 0; } else { modI = 11 - modI; } if (sourceCPF.ObterDigito(9) != modI) { return false; } totalDigitoII += modI * 2; var modII = totalDigitoII % 11; if (modII < 2) { modII = 0; } else { modII = 11 - modII; } if (sourceCPF.ObterDigito(10) != modII) { return false; } return true; } }
Vejamos o resultado:
Adeus alocações! Veja quanta melhoria na performance. Menos memória alocada, nenhuma chamada ao GC, e um código que entrega resultado em muito menos tempo.
Evitando processamento em duplicidade
Nosso processamento tirou o Garbage Collector de cena e melhorou a saúde do sistema. Mas, há um bocado de coisas que poderiam ser melhores. Não acha?
Vamos reescrever nosso código, agora removendo processamento desnecessário.
public static class Version5 { public struct Cpf { private readonly string _value; public readonly bool EhValido; private Cpf(string value) { _value = value; if (value == null) { EhValido = false; return; } var posicao = 0; var totalDigito1 = 0; var totalDigito2 = 0; var dv1 = 0; var dv2 = 0; bool digitosIdenticos = true; var ultimoDigito = -1; foreach (var c in value) { if (char.IsDigit(c)) { var digito = c - '0'; if (posicao != 0 && ultimoDigito != digito) { digitosIdenticos = false; } ultimoDigito = digito; if (posicao < 9) { totalDigito1 += digito * (10 - posicao); totalDigito2 += digito * (11 - posicao); } else if (posicao == 9) { dv1 = digito; } else if (posicao == 10) { dv2 = digito; } posicao++; } } if (posicao > 11) { EhValido = false; return; } if (digitosIdenticos) { EhValido = false; return; } var digito1 = totalDigito1 % 11; digito1 = digito1 < 2 ? 0 : 11 - digito1; if (dv1 != digito1) { EhValido = false; return; } totalDigito2 += digito1 * 2; var digito2 = totalDigito2 % 11; digito2 = digito2 < 2 ? 0 : 11 - digito2; EhValido = dv2 == digito2; } public static implicit operator Cpf(string value) => new Cpf(value); public override string ToString() => _value; } public static bool ValidarCPF(Cpf sourceCPF) => sourceCPF.EhValido; }
O código ficou um pouco mais complexo. Mas compreensível.
Aqui está o resultado:
Acho que chegamos a um bom ponto.
Finalizando
O objetivo desse post foi mostrar como o Garbage Collector pode influenciar na eficiência de um sistema. Acho que ficou claro. Não acha?
Você consegue identificar esse problema nos códigos de sua empresa? Podemos ajudar.
Olá Elemar, este último ficou mais rápido porém não está validando o CPF corretamente:
Exemplo: 3157645480 ou 31576454800 traz como verdadeiro
Abs
Você está certo. Eu estou pegando os 11 primeiros dígitos e ignorando os demais.
Olá, repliquei o teste em Java tomando cuidado de converter as implementações pra obter resultados similares e obtive as mesmas melhorias, mas rolou uma dúvida. Habilitei o log de GCs e contando os timestamps, vi que o tempo gasto coletando a sujeira era ínfimo, exemplos (tempo total de execução / quantidade de coletas / tempo total com as coletas):
– Validador 1 = 1800ms / 13 GC / 13ms em GCs;
– Validador 2 = 1518ms / 12 GC / 11ms em GCs;
– Validador 3 = 149ms / 0 GC.
De fato rolou uma boa melhoria dada evolução no algoritmo de validação, mas não me parece que o grande vilão do tempo de execução dos primeiros validadores foi o GC (no meu caso). No seu exemplo a quantidade de coletas é muito maior, sem comparações. Mas em relação ao tempo de execução, será mesmo que a maior parte do tempo rolou por conta das coletas? Qual foi a proporção?
O problema, no meu entender, não é o tempo das coletas, mas as interrupções. Seria interessante medir o tempo em interrupções.
Repare, inclusive, que há um novo post mostrando uma outra abordagem usando recursos novos, próprios de .net, mas com o mesmo algoritmo original onde o ganho também fica expresso.
Oi Elemar! Tudo bem?
Não achei esse outro post utilizando os novos recursos…
Publicamos em eximiaco.ms. Segue o link:
https://www.eximiaco.ms/pt/2020/01/10/no-c-8-ficou-mais-facil-alocar-arrays-na-stack-e-isso-pode-ter-um-impacto-positivo-tremendo-na-performance/