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, compartilho 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 adotado.
Como fiz o teste
Acredito que é sempre importante ter mensurações para pautar otimização. Eis minha implementação:
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, estou apurando tempo aproximado de execução. Poderia utilizar algo mais preciso (como Benchmark.net), mas, geralmente começo considerando uma abordagem mais simples (apenas para ter uma ordem de grandeza).
Como, geralmente, problemas de performance tem relação direta com o Garbage Collector, gosto de apurar o comportamento do mesmo também.
Podemos considerar que testar performance validando 1_000_000 de CPFs como sendo 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 demonstrar problemas com o estilo de codificação adotado.
O código original (ainda em produção)
Aqui, o código original, encontrado em uma base de código quente de um cliente (mas, bem similiar ao que tenho encontrado em minhas consultorias em outros clientes).
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 retorno da execução em meu computador.
O número que chama atenção aqui é a quantidade de coletas na Gen0 (algumas centenas). Algo precisa ser feito.
Eliminando alocações obviamente desnecessárias
A primeira coisa que tentei fazer para diminuir a pressão sobre o Garbage Collector foi reduzir a quantidade de alocações. Comecei 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. Optei 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
Empolgado com a melhoria na performance, resolvi remover as 3 alocações de strings que ocorrem logo no início do código. Resolvi 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; } }
Como estou fazendo uma validação, não preciso gerar um string "limpo". Optei por pagar o preço. Parece que há muito mais processamento, não é mesmo?
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?
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, nada de impossível. Concorda?
Aqui 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?
Verifique seu código e constate seus pontos de melhoria. Se precisar de ajuda, entre em contato.