Kann diese Implementierung beim Benchmarking kleiner Codebeispiele in C # verbessert werden?

104

Sehr oft finde ich mich bei SO dabei, kleine Codestücke zu vergleichen, um festzustellen, welche Implementierung am schnellsten ist.

Sehr oft sehe ich Kommentare, dass der Benchmarking-Code das Jitting oder den Garbage Collector nicht berücksichtigt.

Ich habe die folgende einfache Benchmarking-Funktion, die ich langsam weiterentwickelt habe:

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

Verwendung:

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

Hat diese Implementierung irgendwelche Mängel? Ist es gut genug zu zeigen, dass die Implementierung X über Z-Iterationen schneller ist als die Implementierung Y? Können Sie sich Möglichkeiten vorstellen, wie Sie dies verbessern können?

BEARBEITEN Es ist ziemlich klar, dass ein zeitbasierter Ansatz (im Gegensatz zu Iterationen) bevorzugt wird. Hat jemand Implementierungen, bei denen die Zeitprüfungen keinen Einfluss auf die Leistung haben?

Sam Safran
quelle
Siehe auch BenchmarkDotNet .
Ben Hutchison

Antworten:

95

Hier ist die geänderte Funktion: Wie von der Community empfohlen, können Sie dies als Community-Wiki ändern.

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

Stellen Sie sicher , dass Sie in Release mit aktivierten Optimierungen kompilieren und die Tests außerhalb von Visual Studio ausführen . Dieser letzte Teil ist wichtig, da die JIT ihre Optimierungen auch im Release-Modus mit einem angehängten Debugger beendet.

Sam Saffron
quelle
Möglicherweise möchten Sie die Schleife einige Male abrollen, z. B. 10, um den Schleifenaufwand zu minimieren.
Mike Dunlavey
2
Ich habe gerade aktualisiert, um Stopwatch.StartNew zu verwenden. Keine Funktionsänderung, sondern speichert eine Codezeile.
LukeH
1
@ Luke, tolle Abwechslung (ich wünschte ich könnte +1 es). @ Mike Ich bin mir nicht sicher, ich vermute, dass der Overhead für virtuelle Anrufe viel höher sein wird als der Vergleich und die Zuweisung, so dass der Leistungsunterschied vernachlässigbar sein wird
Sam Saffron
Ich würde vorschlagen, dass Sie die Iterationszahl an die Aktion übergeben und dort die Schleife erstellen (möglicherweise - sogar entrollt). Wenn Sie einen relativ kurzen Betrieb messen, ist dies die einzige Option. Und ich würde es vorziehen, eine inverse Metrik zu sehen - z. B. Anzahl der Durchgänge / Sek.
Alex Yakunin
2
Was denkst du über die Anzeige der durchschnittlichen Zeit? So etwas wie das: Console.WriteLine ("Durchschnittliche verstrichene Zeit {0} ms", watch.ElapsedMilliseconds / iterations);
Rudimenter
22

Die Finalisierung wird nicht unbedingt vor der GC.CollectRücksendung abgeschlossen sein. Die Finalisierung wird in die Warteschlange gestellt und dann in einem separaten Thread ausgeführt. Dieser Thread ist möglicherweise während Ihrer Tests noch aktiv und wirkt sich auf die Ergebnisse aus.

Wenn Sie sicherstellen möchten, dass die Finalisierung abgeschlossen ist, bevor Sie mit den Tests beginnen, möchten Sie möglicherweise einen Aufruf durchführen GC.WaitForPendingFinalizers, der blockiert wird, bis die Finalisierungswarteschlange gelöscht wird:

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
LukeH
quelle
10
Warum GC.Collect()noch einmal?
Colinfang
7
@colinfang Weil Objekte, die "finalisiert" werden, vom Finalizer nicht GC'ed werden. Die zweite Collectist also da, um sicherzustellen, dass die "finalisierten" Objekte auch gesammelt werden.
MAV
15

Wenn Sie GC-Interaktionen aus der Gleichung herausnehmen möchten, möchten Sie möglicherweise Ihren Aufwärmaufruf nach dem GC.Collect-Aufruf ausführen, nicht vorher. Auf diese Weise wissen Sie, dass .NET bereits genügend Speicher vom Betriebssystem für den Arbeitssatz Ihrer Funktion zugewiesen hat.

Denken Sie daran, dass Sie für jede Iteration einen nicht inlinierten Methodenaufruf ausführen. Vergleichen Sie daher die zu testenden Dinge mit einem leeren Körper. Sie müssen auch akzeptieren, dass Sie nur Dinge zuverlässig zeitlich festlegen können, die um ein Vielfaches länger sind als ein Methodenaufruf.

Abhängig davon, welche Art von Material Sie profilieren, möchten Sie Ihr Timing möglicherweise für eine bestimmte Zeit und nicht für eine bestimmte Anzahl von Iterationen ausführen. Dies kann dazu führen, dass es ohne weiteres zu vergleichbareren Zahlen führt für die beste Implementierung eine sehr kurze und / oder für die schlechteste eine sehr lange Laufzeit haben zu müssen.

Jonathan Rupp
quelle
1
Gute Punkte, hätten Sie eine zeitbasierte Implementierung im Sinn?
Sam Saffron
6

Ich würde es überhaupt vermeiden, den Delegierten zu überholen:

  1. Der Delegatenaufruf ist ein virtueller Methodenaufruf. Nicht billig: ~ 25% der kleinsten Speicherzuordnung in .NET. Wenn Sie an Details interessiert sind, siehe zB diesen Link .
  2. Anonyme Delegierte können dazu führen, dass Schließungen verwendet werden, die Sie nicht einmal bemerken. Auch hier ist der Zugriff auf Schließfelder spürbar als z. B. der Zugriff auf eine Variable auf dem Stapel.

Ein Beispielcode, der zur Verwendung von Schließungen führt:

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

Wenn Ihnen Schließungen nicht bekannt sind, sehen Sie sich diese Methode in .NET Reflector an.

Alex Yakunin
quelle
Interessante Punkte, aber wie würden Sie eine wiederverwendbare Profile () -Methode erstellen, wenn Sie keinen Delegaten übergeben? Gibt es andere Möglichkeiten, beliebigen Code an eine Methode zu übergeben?
Ash
1
Wir verwenden "using (new Measurement (...)) {... gemessener Code ...}". Wir erhalten also ein Messobjekt, das IDisposable implementiert, anstatt den Delegaten zu übergeben. Siehe code.google.com/p/dataobjectsdotnet/source/browse/Xtensive.Core/…
Alex
Dies führt nicht zu Problemen mit Schließungen.
Alex Yakunin
3
@AlexYakunin: Ihr Link scheint defekt zu sein. Könnten Sie den Code für die Messklasse in Ihre Antwort aufnehmen? Ich vermute, dass Sie den zu profilierenden Code mit diesem IDisposable-Ansatz nicht ausführen können, unabhängig davon, wie Sie ihn implementieren. Dies ist jedoch in der Tat sehr nützlich, wenn Sie die Leistung verschiedener Teile einer komplexen (miteinander verflochtenen) Anwendung messen möchten, sofern Sie berücksichtigen, dass die Messungen möglicherweise ungenau und inkonsistent sind, wenn sie zu unterschiedlichen Zeiten ausgeführt werden. Ich verwende in den meisten meiner Projekte den gleichen Ansatz.
ShdNx
1
Die Anforderung, Leistungstests mehrmals durchzuführen, ist sehr wichtig (Aufwärmen + Mehrfachmessungen), daher habe ich auch mit Delegierten zu einem Ansatz gewechselt. Wenn Sie keine Closures verwenden, ist der Aufruf von Delegaten schneller als der Aufruf der Schnittstellenmethode bei IDisposable.
Alex Yakunin
6

Ich denke, das schwierigste Problem, das mit solchen Benchmarking-Methoden zu überwinden ist, besteht darin, Randfälle und das Unerwartete zu berücksichtigen. Zum Beispiel: "Wie funktionieren die beiden Codefragmente bei hoher CPU-Auslastung / Netzwerkauslastung / Festplatten-Thrashing / etc." Sie eignen sich hervorragend für grundlegende Logikprüfungen, um festzustellen, ob ein bestimmter Algorithmus wesentlich schneller als ein anderer funktioniert . Um die meisten Codeleistungen ordnungsgemäß zu testen, müssen Sie jedoch einen Test erstellen, der die spezifischen Engpässe dieses bestimmten Codes misst.

Ich würde immer noch sagen, dass das Testen kleiner Codeblöcke oft nur einen geringen Return on Investment hat und die Verwendung von übermäßig komplexem Code anstelle von einfach wartbarem Code fördern kann. Das Schreiben von klarem Code, den andere Entwickler oder ich 6 Monate später schnell verstehen können, hat mehr Leistungsvorteile als hochoptimierter Code.

Paul Alexander
quelle
1
signifikant ist einer dieser Begriffe, der wirklich geladen ist. Manchmal ist eine Implementierung, die 20% schneller ist, von Bedeutung, manchmal muss sie 100-mal schneller sein, um signifikant zu sein. Stimmen Sie der Klarheit zu, siehe: stackoverflow.com/questions/1018407/…
Sam Saffron
In diesem Fall ist signifikant nicht alles geladen. Sie vergleichen eine oder mehrere gleichzeitige Implementierungen. Wenn der Leistungsunterschied dieser beiden Implementierungen statistisch nicht signifikant ist, lohnt es sich nicht, sich auf die komplexere Methode festzulegen.
Paul Alexander
5

Ich würde func()mehrmals zum Aufwärmen anrufen , nicht nur eines.

Alexey Romanov
quelle
1
Die Absicht war sicherzustellen, dass die JIT-Kompilierung durchgeführt wird. Welchen Vorteil haben Sie, wenn Sie func vor der Messung mehrmals aufrufen?
Sam Saffron
3
Um der JIT die Chance zu geben, ihre ersten Ergebnisse zu verbessern.
Alexey Romanov
1
Das .NET JIT verbessert seine Ergebnisse im Laufe der Zeit nicht (wie das Java). Eine Methode wird beim ersten Aufruf nur einmal von IL in Assembly konvertiert.
Matt Warren
4

Vorschläge zur Verbesserung

  1. Erkennen, ob die Ausführungsumgebung für das Benchmarking geeignet ist (z. B. Erkennen, ob ein Debugger angeschlossen ist oder ob die JIT-Optimierung deaktiviert ist, was zu falschen Messungen führen würde).

  2. Teile des Codes unabhängig messen (um genau zu sehen, wo der Engpass liegt).

  3. Vergleich verschiedener Versionen / Komponenten / Codeabschnitte (In Ihrem ersten Satz sagen Sie "... Benchmarking kleiner Codeabschnitte, um festzustellen, welche Implementierung am schnellsten ist.").

Zu # 1:

  • Um festzustellen, ob ein Debugger angehängt ist, lesen Sie die Eigenschaft System.Diagnostics.Debugger.IsAttached(Denken Sie daran, auch den Fall zu behandeln, in dem der Debugger anfangs nicht angehängt ist, aber nach einiger Zeit angehängt wird).

  • Lesen Sie die Eigenschaft DebuggableAttribute.IsJITOptimizerDisabledder entsprechenden Assemblys , um festzustellen, ob die JIT-Optimierung deaktiviert ist :

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

Zu # 2:

Dies kann auf viele Arten erfolgen. Eine Möglichkeit besteht darin, die Bereitstellung mehrerer Delegaten zuzulassen und diese Delegierten dann einzeln zu messen.

Zu # 3:

Dies könnte auch auf viele Arten geschehen, und unterschiedliche Anwendungsfälle würden sehr unterschiedliche Lösungen erfordern. Wenn der Benchmark manuell aufgerufen wird, ist das Schreiben in die Konsole möglicherweise in Ordnung. Wenn der Benchmark jedoch automatisch vom Build-System ausgeführt wird, ist das Schreiben in die Konsole wahrscheinlich nicht so gut.

Eine Möglichkeit, dies zu tun, besteht darin, das Benchmark-Ergebnis als stark typisiertes Objekt zurückzugeben, das leicht in verschiedenen Kontexten verwendet werden kann.


Etimo.Benchmarks

Ein anderer Ansatz besteht darin, eine vorhandene Komponente zur Durchführung der Benchmarks zu verwenden. Tatsächlich haben wir in meinem Unternehmen beschlossen, unser Benchmark-Tool öffentlich zugänglich zu machen. Im Kern verwaltet es den Garbage Collector, Jitter, Warmups usw., genau wie einige der anderen Antworten hier vorschlagen. Es hat auch die drei Funktionen, die ich oben vorgeschlagen habe. Es verwaltet mehrere der im Eric Lippert-Blog behandelten Themen .

Dies ist eine Beispielausgabe, bei der zwei Komponenten verglichen und die Ergebnisse in die Konsole geschrieben werden. In diesem Fall heißen die beiden verglichenen Komponenten 'KeyedCollection' und 'MultiplyIndexedKeyedCollection':

Etimo.Benchmarks - Beispielkonsolenausgabe

Es gibt ein NuGet-Paket , ein Beispiel-NuGet-Paket und der Quellcode ist bei GitHub verfügbar . Es gibt auch einen Blog-Beitrag .

Wenn Sie es eilig haben, empfehlen wir Ihnen, das Beispielpaket zu erwerben und die Beispieldelegierten einfach nach Bedarf zu ändern. Wenn Sie es nicht eilig haben, ist es möglicherweise eine gute Idee, den Blog-Beitrag zu lesen, um die Details zu verstehen.

Joakim
quelle
1

Sie müssen vor der eigentlichen Messung auch einen "Aufwärm" -Pass ausführen, um die Zeit auszuschließen, die der JIT-Compiler für das Jitting Ihres Codes benötigt.

Alex Yakunin
quelle
es wird vor der Messung durchgeführt
Sam Saffron
1

Abhängig vom Code, den Sie vergleichen, und der Plattform, auf der er ausgeführt wird, müssen Sie möglicherweise berücksichtigen, wie sich die Code-Ausrichtung auf die Leistung auswirkt . Um dies zu tun, wäre wahrscheinlich ein äußerer Wrapper erforderlich, der den Test mehrmals ausgeführt hat (in separaten App-Domänen oder -Prozessen?). Manchmal wird zuerst "Padding-Code" aufgerufen, um die JIT-Kompilierung zu erzwingen und den Code zu veranlassen Benchmarking anders ausgerichtet. Ein vollständiges Testergebnis würde die besten und schlechtesten Zeitpunkte für die verschiedenen Code-Ausrichtungen liefern.

Edward Brey
quelle
1

Wenn Sie versuchen, die Auswirkungen der Garbage Collection aus dem vollständigen Benchmark zu entfernen, lohnt es sich, diese festzulegen GCSettings.LatencyMode ?

Wenn nicht, und Sie möchten, dass die Auswirkungen des erzeugten Mülls funcTeil des Benchmarks sind, sollten Sie dann nicht auch die Erfassung am Ende des Tests (innerhalb des Timers) erzwingen?

Danny Tuppeny
quelle
0

Das Grundproblem bei Ihrer Frage ist die Annahme, dass eine einzige Messung alle Ihre Fragen beantworten kann. Sie müssen mehrmals messen, um ein effektives Bild der Situation zu erhalten, insbesondere in einer Sprache, in der Müll gesammelt wird, wie z. B. C #.

Eine andere Antwort bietet eine gute Möglichkeit, die Grundleistung zu messen.

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

Diese einzelne Messung berücksichtigt jedoch nicht die Speicherbereinigung. Ein korrektes Profil berücksichtigt zusätzlich die Worst-Case-Leistung der Garbage Collection, die über viele Anrufe verteilt ist (diese Nummer ist nutzlos, da die VM beendet werden kann, ohne jemals übrig gebliebenen Garbage Collection zu sammeln, aber dennoch nützlich ist, um zwei verschiedene Implementierungen von zu vergleichen 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);
}

Möglicherweise möchten Sie auch die Worst-Case-Leistung der Speicherbereinigung für eine Methode messen, die nur einmal aufgerufen wird.

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

Wichtiger als die Empfehlung spezifischer möglicher zusätzlicher Messungen für das Profil ist jedoch die Idee, dass mehrere verschiedene Statistiken und nicht nur eine Art von Statistik gemessen werden sollten.

Steven Stewart-Gallus
quelle