Comparando pequenos exemplos de código em C #, essa implementação pode ser melhorada?

104

Freqüentemente, no SO, eu me pego comparando pequenos pedaços de código para ver qual implementação é mais rápida.

Frequentemente vejo comentários de que o código de benchmarking não leva em conta o jitting ou o coletor de lixo.

Tenho a seguinte função de benchmarking simples, que evoluí lentamente:

  static void Profile(string description, int iterations, Action func) {
        // warm up 
        func();
        // clean up
        GC.Collect();

        var watch = new Stopwatch();
        watch.Start();
        for (int i = 0; i < iterations; i++) {
            func();
        }
        watch.Stop();
        Console.Write(description);
        Console.WriteLine(" Time Elapsed {0} ms", watch.ElapsedMilliseconds);
    }

Uso:

Profile("a descriptions", how_many_iterations_to_run, () =>
{
   // ... code being profiled
});

Esta implementação tem alguma falha? É bom o suficiente para mostrar que a implementação X é mais rápida do que a implementação Y em iterações Z? Você pode pensar em alguma maneira de melhorar isso?

EDITAR É bastante claro que uma abordagem baseada no tempo (em oposição às iterações) é preferida. Alguém tem alguma implementação em que as verificações de tempo não afetam o desempenho?

Sam Saffron
fonte
Veja também BenchmarkDotNet .
Ben Hutchison

Respostas:

95

Aqui está a função modificada: conforme recomendado pela comunidade, sinta-se à vontade para alterá-la, é uma wiki da comunidade.

static double Profile(string description, int iterations, Action func) {
    //Run at highest priority to minimize fluctuations caused by other processes/threads
    Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High;
    Thread.CurrentThread.Priority = ThreadPriority.Highest;

    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
    return watch.Elapsed.TotalMilliseconds;
}

Certifique-se de compilar na versão com otimizações habilitadas e execute os testes fora do Visual Studio . Esta última parte é importante porque o JIT restringe suas otimizações com um depurador anexado, mesmo no modo Release.

Sam Saffron
fonte
Você pode querer desenrolar o loop algumas vezes, como 10, para minimizar a sobrecarga do loop.
Mike Dunlavey
2
Acabei de atualizar para usar o Stopwatch.StartNew. Não é uma mudança funcional, mas salva uma linha de código.
LukeH
1
@Luke, grande mudança (gostaria de poder marcar com +1). @Mike, não tenho certeza, suspeito que a sobrecarga do virtualcall será muito maior do que a comparação e atribuição, então a diferença de desempenho será insignificante
Sam Saffron
Eu proporia que você passasse a contagem de iterações para a Ação e criasse o loop lá (possivelmente - até mesmo desenrolado). Caso você esteja medindo uma operação relativamente curta, esta é a única opção. E eu prefiro ver métricas inversas - por exemplo, contagem de passes / s.
Alex Yakunin
2
O que você acha de mostrar o tempo médio. Algo assim: Console.WriteLine ("Average Time Elapsed {0} ms", watch.ElapsedMilliseconds / iterations);
rudimentador
22

A finalização não será necessariamente concluída antes do GC.Collectretorno. A finalização é enfileirada e, em seguida, executada em um thread separado. Este thread ainda pode estar ativo durante seus testes, afetando os resultados.

Se você deseja garantir que a finalização foi concluída antes de iniciar seus testes, convém chamar GC.WaitForPendingFinalizers, que será bloqueado até que a fila de finalização seja limpa:

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
LukeH
fonte
10
Por GC.Collect()que mais uma vez?
colinfang
7
@colinfang Porque os objetos sendo "finalizados" não são GC'ed pelo finalizador. Portanto, o segundo Collectestá lá para garantir que os objetos "finalizados" também sejam coletados.
MAV
15

Se você quiser tirar as interações do GC da equação, pode executar sua chamada de 'aquecimento' após a chamada GC.Collect, não antes. Dessa forma, você saberá que o .NET já terá memória suficiente alocada do sistema operacional para o conjunto de trabalho de sua função.

Lembre-se de que você está fazendo uma chamada de método não sequencial para cada iteração, portanto, certifique-se de comparar as coisas que está testando com um corpo vazio. Você também terá que aceitar que só pode cronometrar com segurança coisas que são várias vezes mais longas do que uma chamada de método.

Além disso, dependendo do tipo de coisa que você está traçando, você pode querer fazer sua execução com base no tempo por um certo período de tempo, em vez de por um certo número de iterações - isso pode levar a números mais facilmente comparáveis ​​sem ter que ter um prazo muito curto para a melhor implementação e / ou um prazo muito longo para a pior.

Jonathan Rupp
fonte
1
pontos positivos, você teria uma implementação baseada no tempo em mente?
Sam Saffron
6

Eu evitaria passar pelo delegado:

  1. A chamada de delegado é ~ chamada de método virtual. Não é barato: ~ 25% da menor alocação de memória no .NET. Se você estiver interessado em detalhes, consulte, por exemplo, este link .
  2. Delegados anônimos podem levar ao uso de fechamentos, que você nem notará. Novamente, acessar os campos de fechamento é mais perceptível do que, por exemplo, acessar uma variável na pilha.

Um exemplo de código que leva ao uso de fechamento:

public void Test()
{
  int someNumber = 1;
  Profiler.Profile("Closure access", 1000000, 
    () => someNumber + someNumber);
}

Se você não está ciente dos fechamentos, dê uma olhada neste método no .NET Reflector.

Alex Yakunin
fonte
Pontos interessantes, mas como você criaria um método Profile () reutilizável se não aprovasse um delegado? Existem outras maneiras de passar código arbitrário para um método?
Ash
1
Usamos "using (new Measurement (...)) {... Measurement code ...}". Portanto, obtemos o objeto Measurement implementando IDisposable em vez de passar o delegado. Consulte code.google.com/p/dataobjectsdotnet/source/browse/Xtensive.Core/…
Alex Yakunin
Isso não levará a problemas com fechamentos.
Alex Yakunin
3
@AlexYakunin: seu link parece estar quebrado. Você poderia incluir o código para a classe Measurement em sua resposta? Suspeito que não importa como você o implemente, não será capaz de executar o código a ser perfilado várias vezes com essa abordagem IDisposable. No entanto, é realmente muito útil em situações em que você deseja medir o desempenho de diferentes partes de um aplicativo complexo (entrelaçado), contanto que você tenha em mente que as medições podem ser imprecisas e inconsistentes quando executadas em momentos diferentes. Estou usando a mesma abordagem na maioria dos meus projetos.
ShdNx
1
O requisito de executar o teste de desempenho várias vezes é muito importante (aquecimento + medições múltiplas), então mudei para uma abordagem com o delegado também. Além disso, se você não usar encerramentos, a invocação do delegado será mais rápida do que a chamada do método de interface no caso de IDisposable.
Alex Yakunin
6

Acho que o problema mais difícil de superar com métodos de benchmarking como esse é levar em conta os casos extremos e o inesperado. Por exemplo - "Como os dois trechos de código funcionam sob alta carga de CPU / uso de rede / fragmentação de disco / etc." Eles são ótimos para verificações lógicas básicas para ver se um algoritmo específico funciona significativamente mais rápido do que outro. Mas, para testar adequadamente a maior parte do desempenho do código, você teria que criar um teste que meça os gargalos específicos desse código em particular.

Eu ainda diria que testar pequenos blocos de código geralmente tem pouco retorno sobre o investimento e pode encorajar o uso de código excessivamente complexo em vez de código simples de manutenção. Escrever um código claro que outros desenvolvedores, ou eu mesmo 6 meses depois, possamos entender rapidamente terá mais benefícios de desempenho do que um código altamente otimizado.

Paul Alexander
fonte
1
significativo é um daqueles termos que é realmente carregado. às vezes, ter uma implementação 20% mais rápida é significativo, às vezes tem que ser 100 vezes mais rápido para ser significativo. Concorde com você sobre a clareza, consulte: stackoverflow.com/questions/1018407/…
Sam Saffron
Neste caso, o significativo não é tão carregado. Você está comparando uma ou mais implementações simultâneas e se a diferença no desempenho dessas duas implementações não for estatisticamente significativa, não vale a pena se comprometer com o método mais complexo.
Paul Alexander
5

Eu ligaria func()várias vezes para o aquecimento, não apenas uma.

Alexey Romanov
fonte
1
A intenção era garantir que a compilação jit fosse executada. Qual a vantagem de você chamar a função várias vezes antes da medição?
Sam Saffron
3
Para dar ao JIT uma chance de melhorar seus primeiros resultados.
Alexey Romanov
1
o .NET JIT não melhora seus resultados com o tempo (como o Java faz). Ele converte um método de IL em Assembly apenas uma vez, na primeira chamada.
Matt Warren de
4

Sugestões para melhorar

  1. Detectar se o ambiente de execução é bom para benchmarking (como detectar se um depurador está conectado ou se a otimização jit está desabilitada, o que resultaria em medições incorretas).

  2. Medir partes do código de forma independente (para ver exatamente onde está o gargalo).

  3. Comparando diferentes versões / componentes / pedaços de código (em sua primeira frase você diz '... comparando pequenos pedaços de código para ver qual implementação é mais rápida.').

Em relação ao nº 1:

  • Para detectar se um depurador está anexado, leia a propriedade System.Diagnostics.Debugger.IsAttached(lembre-se de lidar também com o caso em que o depurador inicialmente não está anexado, mas é anexado depois de algum tempo).

  • Para detectar se a otimização jit está desabilitada, leia a propriedade DebuggableAttribute.IsJITOptimizerDisableddos conjuntos relevantes:

    private bool IsJitOptimizerDisabled(Assembly assembly)
    {
        return assembly.GetCustomAttributes(typeof (DebuggableAttribute), false)
            .Select(customAttribute => (DebuggableAttribute) customAttribute)
            .Any(attribute => attribute.IsJITOptimizerDisabled);
    }

Em relação ao nº 2:

Isso pode ser feito de várias maneiras. Uma maneira é permitir que vários delegados sejam fornecidos e então medir esses delegados individualmente.

Em relação ao nº 3:

Isso também poderia ser feito de várias maneiras, e diferentes casos de uso exigiriam soluções muito diferentes. Se o benchmark for invocado manualmente, pode ser bom gravar no console. No entanto, se o benchmark for executado automaticamente pelo sistema de compilação, provavelmente não será tão bom gravar no console.

Uma maneira de fazer isso é retornar o resultado do benchmark como um objeto fortemente tipado que pode ser facilmente consumido em diferentes contextos.


Etimo.Benchmarks

Outra abordagem é usar um componente existente para realizar os benchmarks. Na verdade, na minha empresa, decidimos lançar nossa ferramenta de benchmark para domínio público. Em seu núcleo, ele gerencia o coletor de lixo, jitter, aquecimentos etc, assim como algumas das outras respostas aqui sugerem. Ele também tem os três recursos que sugeri acima. Ele gerencia vários dos problemas discutidos no blog de Eric Lippert .

Este é um exemplo de saída em que dois componentes são comparados e os resultados são gravados no console. Neste caso, os dois componentes comparados são chamados de 'KeyedCollection' e 'MultiplyIndexedKeyedCollection':

Etimo.Benchmarks - Exemplo de saída do console

Há um pacote NuGet , um pacote NuGet de amostra e o código-fonte está disponível no GitHub . Há também uma postagem no blog .

Se você estiver com pressa, sugiro que pegue o pacote de amostra e simplesmente modifique os representantes de amostra conforme necessário. Se você não estiver com pressa, pode ser uma boa ideia ler a postagem do blog para entender os detalhes.

Joakim
fonte
1

Você também deve executar uma passagem de "aquecimento" antes da medição real para excluir o tempo que o compilador JIT gasta para ajustar seu código.

Alex Yakunin
fonte
é realizada antes da medição
Sam Saffron
1

Dependendo do código que você está avaliando e da plataforma em que ele é executado, pode ser necessário considerar como o alinhamento do código afeta o desempenho . Para fazer isso, provavelmente seria necessário um wrapper externo que executasse o teste várias vezes (em domínios ou processos de aplicativos separados?), Algumas das vezes chamando primeiro o "código de preenchimento" para forçá-lo a ser compilado por JIT, de modo a fazer com que o código seja avaliado para ser alinhado de forma diferente. Um resultado de teste completo forneceria os tempos de melhor e pior caso para os vários alinhamentos de código.

Edward Brey
fonte
1

Se você está tentando eliminar o impacto da coleta de lixo do benchmark completo, vale a pena defini-lo GCSettings.LatencyMode?

Se não, e você deseja que o impacto do lixo criado em faça funcparte do benchmark, então não deveria também forçar a coleta no final do teste (dentro do cronômetro)?

Danny Tuppeny
fonte
0

O problema básico com sua pergunta é a suposição de que uma única medição pode responder a todas as suas perguntas. Você precisa medir várias vezes para obter uma imagem eficaz da situação e, especialmente, em uma linguagem de coleta de lixo como C #.

Outra resposta fornece uma maneira correta de medir o desempenho básico.

static void Profile(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

No entanto, essa medição única não leva em consideração a coleta de lixo. Um perfil adequado também é responsável pelo pior caso de desempenho da coleta de lixo espalhada por muitas chamadas (esse número é meio inútil, pois a VM pode terminar sem nunca coletar o lixo restante, mas ainda é útil para comparar duas implementações diferentes de func).

static void ProfileGarbageMany(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

E alguém também pode querer medir o desempenho do pior caso de coleta de lixo para um método que é chamado apenas uma vez.

static void ProfileGarbage(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();

        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

Porém, mais importante do que recomendar quaisquer medidas adicionais específicas possíveis para o perfil é a ideia de que se deve medir várias estatísticas diferentes e não apenas um tipo de estatística.

Steven Stewart-Gallus
fonte