Benchmarking di piccoli esempi di codice in C #, questa implementazione può essere migliorata?


104

Molto spesso su SO mi ritrovo a fare il benchmarking di piccoli blocchi di codice per vedere quale implementazione è più veloce.

Spesso vedo commenti che il codice di benchmarking non tiene conto del jitting o del garbage collector.

Ho la seguente semplice funzione di benchmarking che ho lentamente evoluto:

  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
});

Questa implementazione ha qualche difetto? È abbastanza buono per mostrare che l'implementazione X è più veloce dell'implementazione Y su Z iterazioni? Riesci a pensare a qualche modo per migliorarlo?

EDIT È abbastanza chiaro che un approccio basato sul tempo (al contrario delle iterazioni) è preferito, qualcuno ha implementazioni in cui i controlli del tempo non influiscono sulle prestazioni?


Risposte:


95

Ecco la funzione modificata: come raccomandato dalla comunità, sentiti libero di modificare questo è un wiki della comunità.

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;
}

Assicurati di compilare in Release con le ottimizzazioni abilitate ed esegui i test all'esterno di Visual Studio . Quest'ultima parte è importante perché JIT blocca le sue ottimizzazioni con un debugger allegato, anche in modalità Release.


Potresti voler srotolare il ciclo per un certo numero di volte, come 10, per ridurre al minimo il sovraccarico del ciclo.
Mike Dunlavey,

2
Ho appena aggiornato per utilizzare Stopwatch.StartNew. Non è una modifica funzionale, ma salva una riga di codice.
LukeH

1
@Luke, grande cambiamento (vorrei poterlo fare +1). @ Mike non sono sicuro, sospetto che il sovraccarico della chiamata virtuale sarà molto più alto del confronto e dell'assegnazione, quindi la differenza di prestazioni sarà trascurabile
Sam Saffron,

Ti proporrei di passare il conteggio delle iterazioni all'azione e di creare il ciclo lì (possibilmente, anche srotolato). Se stai misurando un'operazione relativamente breve, questa è l'unica opzione. E preferirei vedere la metrica inversa, ad esempio conteggio dei passaggi / sec.
Alex Yakunin

2
Cosa ne pensi di mostrare il tempo medio. Qualcosa di simile: Console.WriteLine ("Tempo medio trascorso {0} ms", watch.ElapsedMilliseconds / iterations);
rudimenter

22

La finalizzazione non sarà necessariamente completata prima della GC.Collectrestituzione. La finalizzazione viene messa in coda e quindi eseguita su un thread separato. Questo thread potrebbe essere ancora attivo durante i tuoi test, influenzando i risultati.

Se vuoi assicurarti che la finalizzazione sia stata completata prima di iniziare i tuoi test, potresti voler chiamare GC.WaitForPendingFinalizers, che si bloccherà fino a quando la coda di finalizzazione non viene cancellata:

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

10
Perché GC.Collect()ancora una volta?
colinfang

7
@colinfang Perché gli oggetti che vengono "finalizzati" non vengono inseriti in GC dal finalizzatore. Quindi il secondo Collectè lì per assicurarsi che anche gli oggetti "finalizzati" vengano raccolti.
MAV

15

Se desideri escludere le interazioni GC dall'equazione, potresti eseguire la chiamata di "riscaldamento" dopo la chiamata GC.Collect, non prima. In questo modo sai che .NET avrà già abbastanza memoria allocata dal sistema operativo per il working set della tua funzione.

Tieni presente che stai effettuando una chiamata al metodo non inline per ogni iterazione, quindi assicurati di confrontare le cose che stai testando con un corpo vuoto. Dovrai anche accettare che puoi calcolare in modo affidabile solo cose che sono molte volte più lunghe di una chiamata al metodo.

Inoltre, a seconda del tipo di cose che stai profilando, potresti voler eseguire la corsa basata sul tempo per un certo periodo di tempo piuttosto che per un certo numero di iterazioni: può tendere a portare a numeri più facilmente confrontabili senza dover avere un periodo molto breve per la migliore implementazione e / o molto lungo per il peggio.


1
punti positivi, avresti in mente un'implementazione basata sul tempo?
Sam Saffron

6

Eviterei affatto di passare il delegato:

  1. La chiamata del delegato è una chiamata al metodo ~ virtuale. Non economico: ~ 25% dell'allocazione di memoria minima in .NET. Se sei interessato ai dettagli, vedi ad esempio questo link .
  2. I delegati anonimi possono portare all'utilizzo di chiusure, che non noterai nemmeno. Anche in questo caso, l'accesso ai campi di chiusura è notevolmente rispetto, ad esempio, all'accesso a una variabile nello stack.

Un codice di esempio che porta all'utilizzo della chiusura:

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

Se non sei a conoscenza delle chiusure, dai un'occhiata a questo metodo in .NET Reflector.


Punti interessanti, ma come creeresti un metodo Profile () riutilizzabile se non passi un delegato? Esistono altri modi per passare codice arbitrario a un metodo?
Ash

1
Usiamo "utilizzando (nuova misura (...)) {... codice misurato ...}". Quindi otteniamo l'oggetto Measurement che implementa IDisposable invece di passare il delegato. Vedi code.google.com/p/dataobjectsdotnet/source/browse/Xtensive.Core/…
Alex Yakunin

Questo non porterà a problemi con le chiusure.
Alex Yakunin

3
@AlexYakunin: il tuo collegamento sembra essere interrotto. Potresti includere il codice per la classe di misurazione nella tua risposta? Sospetto che indipendentemente da come lo implementerai, non sarai in grado di eseguire il codice da profilare più volte con questo approccio IDisposable. Tuttavia, è davvero molto utile in situazioni in cui si desidera misurare le prestazioni delle diverse parti di un'applicazione complessa (intrecciata), purché si tenga presente che le misurazioni potrebbero essere imprecise e incoerenti se eseguite in momenti diversi. Uso lo stesso approccio nella maggior parte dei miei progetti.
ShdNx

1
Il requisito di eseguire il test delle prestazioni più volte è molto importante (riscaldamento + misurazioni multiple), quindi sono passato anche a un approccio con delegato. Inoltre, se non si utilizzano le chiusure, la chiamata del delegato è più veloce della chiamata al metodo di interfaccia nel caso di IDisposable.
Alex Yakunin

6

Penso che il problema più difficile da superare con metodi di benchmarking come questo sia tenere conto dei casi limite e degli imprevisti. Ad esempio: "Come funzionano i due frammenti di codice in condizioni di carico elevato della CPU / utilizzo della rete / thrash del disco / ecc." Sono ottimi per i controlli logici di base per vedere se un particolare algoritmo funziona molto più velocemente di un altro. Ma per testare correttamente la maggior parte delle prestazioni del codice, dovresti creare un test che misuri i colli di bottiglia specifici di quel particolare codice.

Direi ancora che testare piccoli blocchi di codice spesso ha un piccolo ritorno sull'investimento e può incoraggiare l'uso di codice eccessivamente complesso invece di semplice codice gestibile. Scrivere un codice chiaro che altri sviluppatori, o me stesso 6 mesi dopo, possiamo capire rapidamente avrà maggiori vantaggi in termini di prestazioni rispetto al codice altamente ottimizzato.


1
significativo è uno di quei termini che è davvero carico. a volte avere un'implementazione che è il 20% più veloce è significativo, a volte deve essere 100 volte più veloce per essere significativo. D'accordo con te sulla chiarezza vedi: stackoverflow.com/questions/1018407/…
Sam Saffron,

In questo caso significativo non è tutto ciò che viene caricato. Stai confrontando una o più implementazioni simultanee e se la differenza nelle prestazioni di queste due implementazioni non è statisticamente significativa, non vale la pena impegnarsi con il metodo più complesso.
Paul Alexander

5

Chiamavo func()più volte per il riscaldamento, non solo una.


1
L'intenzione era di garantire che venga eseguita la compilazione jit, quale vantaggio si ottiene chiamando func più volte prima della misurazione?
Sam Saffron

3
Per dare alla JIT la possibilità di migliorare i suoi primi risultati.
Alexey Romanov,

1
.NET JIT non migliora i suoi risultati nel tempo (come fa Java). Converte un metodo da IL ad Assembly solo una volta, alla prima chiamata.
Matt Warren

4

Suggerimenti per un miglioramento

  1. Rilevare se l'ambiente di esecuzione è buono per il benchmarking (come rilevare se un debugger è collegato o se l'ottimizzazione jit è disabilitata, il che comporterebbe misurazioni errate).

  2. Misurare parti del codice in modo indipendente (per vedere esattamente dove si trova il collo di bottiglia).

  3. Confronto tra diverse versioni / componenti / blocchi di codice (nella prima frase si dice "... confrontando piccoli blocchi di codice per vedere quale implementazione è più veloce.").

Per quanto riguarda # 1:

  • Per rilevare se è collegato un debugger, leggi la proprietà System.Diagnostics.Debugger.IsAttached (ricordarsi di gestire anche il caso in cui il debugger non è inizialmente collegato, ma viene allegato dopo un po 'di tempo).

  • Per rilevare se l'ottimizzazione jit è disabilitata, leggi la proprietà DebuggableAttribute.IsJITOptimizerDisableddegli assembly rilevanti:

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

Per quanto riguarda # 2:

Questo può essere fatto in molti modi. Un modo è consentire la fornitura di più delegati e quindi misurare tali delegati individualmente.

Per quanto riguarda # 3:

Ciò potrebbe anche essere fatto in molti modi e diversi casi d'uso richiederebbero soluzioni molto diverse. Se il benchmark viene richiamato manualmente, la scrittura sulla console potrebbe andare bene. Tuttavia, se il benchmark viene eseguito automaticamente dal sistema di compilazione, la scrittura sulla console probabilmente non va bene.

Un modo per farlo è restituire il risultato del benchmark come un oggetto fortemente tipizzato che può essere facilmente utilizzato in diversi contesti.


Etimo.Benchmarks

Un altro approccio consiste nell'utilizzare un componente esistente per eseguire i benchmark. In realtà, nella mia azienda abbiamo deciso di rilasciare il nostro strumento di benchmark al pubblico dominio. In sostanza, gestisce il garbage collector, il jitter, il riscaldamento, ecc., Proprio come suggeriscono alcune delle altre risposte qui. Ha anche le tre caratteristiche che ho suggerito sopra. Gestisce molti dei problemi discussi nel blog di Eric Lippert .

Questo è un output di esempio in cui vengono confrontati due componenti e i risultati vengono scritti nella console. In questo caso le due componenti messe a confronto si chiamano 'KeyedCollection' e 'MultiplyIndexedKeyedCollection':

Etimo.Benchmarks: output della console di esempio

È disponibile un pacchetto NuGet , un pacchetto NuGet di esempio e il codice sorgente è disponibile su GitHub . C'è anche un post sul blog .

Se hai fretta, ti suggerisco di ottenere il pacchetto di esempio e modificare semplicemente i delegati di esempio secondo necessità. Se non hai fretta, potrebbe essere una buona idea leggere il post del blog per capirne i dettagli.


1

È inoltre necessario eseguire un passaggio di "riscaldamento" prima della misurazione effettiva per escludere il tempo che il compilatore JIT impiega per eseguire il jitting del codice.


viene eseguita prima della misurazione
Sam Saffron

1

A seconda del codice su cui si esegue il benchmark e della piattaforma su cui viene eseguito, potrebbe essere necessario tenere conto di come l'allineamento del codice influisce sulle prestazioni . Per fare ciò probabilmente sarebbe necessario un wrapper esterno che eseguisse il test più volte (in domini o processi di app separati?), Alcune delle volte prima chiamando il "codice di riempimento" per forzarne la compilazione JIT, in modo da far sì che il codice venga benchmark per essere allineati in modo diverso. Un risultato completo del test fornirebbe i tempi migliori e peggiori per i vari allineamenti di codice.


1

Se stai cercando di eliminare l'impatto della Garbage Collection dal benchmark completo, vale la pena impostarlo GCSettings.LatencyMode?

In caso contrario, e vuoi che l'impatto della spazzatura creata funcsia parte del benchmark, non dovresti anche forzare la raccolta alla fine del test (all'interno del timer)?


0

Il problema di base con la tua domanda è il presupposto che una singola misurazione possa rispondere a tutte le tue domande. È necessario misurare più volte per ottenere un'immagine efficace della situazione e soprattutto in una lingua raccolta dalla spazzatura come C #.

Un'altra risposta fornisce un modo corretto di misurare le prestazioni di base.

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);
}

Tuttavia, questa singola misurazione non tiene conto della garbage collection. Un profilo appropriato tiene conto inoltre delle prestazioni peggiori della raccolta dei rifiuti distribuita su molte chiamate (questo numero è in qualche modo inutile poiché la VM può terminare senza mai raccogliere i rifiuti avanzati, ma è comunque utile per confrontare due diverse implementazioni di 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 si potrebbe anche voler misurare le prestazioni nel caso peggiore della garbage collection per un metodo che viene chiamato solo una volta.

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);
}

Ma più importante che raccomandare eventuali misurazioni aggiuntive specifiche da profilare è l'idea che si dovrebbero misurare più statistiche diverse e non solo un tipo di statistica.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.