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.

Mais posts da série “Cases de Melhoria de Performance”:

10 Comentários
  1. Elemar, boa noite.
    Primeiramente obrigado por compartilhar conosco suas experiências, elas são de grande valia, pode ter certeza.

    Mas queria na verdade uma indicação de ferramentas ou alguma abordagem para identificar possíveis pontos em uma aplicação que possam estar comprometendo sua performance e até consumindo muito recurso dos servidores de forma desnecessária.

    Agradeço se puder me auxiliar.
    Grande abraço

    1. Há um grande número de profilers que você pode utilizar. O próprio Visual Studio é bom nisso. Além disso, há o Ants (Red Gate), o dotMemory (Jetbrains)… Há também as opções de depuração como Perfview, WinDBG, etc… 🙂 O monitor de performance do Windows também é ótimo para detectar saltos.

  2. Eu nunca tinha visto essa forma de converter string para inteiro:

    var digito = _value[i] – ‘0’;

    Você poderia dar uma breve explicação a respeito dela?

    Parabéns pelo excelente artigo! Espero que a partir de agora o pessoal dê ctrl c + ctrl v nessa sua versão e não na original! 😉

    1. Um char tem conversão implícita para inteiro. Ou seja, quando você tenta subtrair um char ‘3’-‘0’ ele faz 51 – 58 = 3.
      Os valores 51 e 48 são os números decimais do sinal ‘3’ e ‘0’ respectivamente. O compilador vê o operador de subtração e já converte para inteiro.

  3. Se você usar o pior caso do seu processamento que é uma sequência de números iguais ex: 999.999.999-99 na validação (o que te forçará a fazer a verificação de dígito a dígito para cada cpf) provavelmente terá um maior custo computacional de processamento, mas no fundo a vida é feita de escolhas, parabéns pelo artigo, pensar sobre isso é muito bom e normalmente são coisas que não damos atenção no dia-dia.

    1. Então.. acho que não. Todos os dígitos iguais resultam apenas em um CNPJ inválido. Não vejo incremendo de tempo de execução. Bora medir!?

  4. Incrível como aprender alguns conceitos pode fazer tanta a diferença, vi tudo que você fez, apliquei a mesma ideia para o CNPJ e ainda pude comprar com a sua versão.
    Gostei de brincar de medir e percebi que se ao invés de utilizar o char.IsDigit() fazer a verificação manual ganha alguns milissegundos.
    var digito = c – ‘0’;
    if (digito >=0 && digito < 10)

Deixe uma resposta

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *