Benchmark im Unit-Test-Stil
Best of NuGet, Teil 4

Unabhängig von allen Problemen, die man sich mit Microbenchmarks einhandelt, sei BenchmarkDotNet schon aufgrund seiner Vorgehensweise positiv hervorgehoben: Benchmarks entstehen hier in an Unit-Tests erinnernden Methoden, die das Framework danach zur Ausführung bringt. Auf diese Weise lässt sich der „Performance-Zustand“ des Systems als Ganzes beobachten, außerdem sind niederschwellige Tests zum Vergleich von Implementierungen und Vorgehensweisen vereinfacht möglich.
Einrichtung des Benchmark-Systems
Um die Komplexität in Grenzen zu halten, funktioniert das Benchmark-System nur mit Applikationen, die in Form einer Konsolenanwendung vorliegen. In den folgenden Schritten wollen wir ein Programmbeispiel namens NMGBenchConsole1 verwenden. Erweitern Sie es in NuGet danach um das Paket BenchmarkDotNet.
Die eigentliche Ausführung des Runners erfolgt über Code, der im Rahmen der main()-Methode der Kommandozeilenapplikation zur Anwendung gelangt. Für unser erstes Beispiel sind folgende Anpassungen erforderlich:
using BenchmarkDotNet.Running; var summary = BenchmarkRunner.Run<BenchmrkWorkerClass>();
Testlogik wird in Form dedizierter Klassen angeliefert. Für einen ersten Test wollen wir die Kommandozeile mit lästigem Fauchen traktieren, weshalb wir folgenden Benchmarkblock konstruieren:
internal class BenchmrkWorkerClass { [Benchmark] public void HissOnce() { Console.WriteLine("Fauch!"); } [Benchmark] public void HissALot() { for(int i=0;i<1000;i++) Console.WriteLine("Fauch!"); } }
Jede Testmethode ist mit der Annotation Benchmark auszustatten – zur Laufzeit erfolgt eine Reflection, um die zu erledigenden Payloads zu finden. Ein hoffnungsfroher Klick auf das Startsymbol liefert dann das in Bild 1 gezeigte Ergebnis.

Der Runner zeigt sich weinerlich (Bild 1)
AutorDa das Benchmarken von mit dem Debugger verbundenem Code zu verschiedenen Problemen führt, blockiert das Benchmark-Framework in diesem Fall den Start des Runners. Zur Behebung muss in Visual Studio ein Release-Build ausgewählt werden, eventuelle Fehlermeldungen des Debuggers sind zu ignorieren. Wichtig ist außerdem, die Klasse mit den Benchmarks nach folgendem Schema zu exponieren:
public class BenchmrkWorkerClass
Sofern die Behebung der vom Runner monierten Passagen erfolgreich verlief, liefert der Benchmark das in Bild 2 gezeigte Ergebnis.

Lasset uns fauchen
In der Praxis dienen Benchmarks oft dem Vergleich unterschiedlicher Systeme oder Komponenten. BenchmarkDotNet bildet dies durch das Baseline-Attribut ab, das nach folgendem Schema auf einen Benchmark angewendet wird:
[Benchmark(Baseline = true)] public void HissOnce() { Console.WriteLine("Fauch!"); }
Daraufhin wird die von anderen Benchmark-Runs verbrauchte Zeit in der Ergebnisanalyse fortan in Vergleich gesetzt.

Tausend Fauchvorgänge brauchen 948-mal so viel Zeit wie eine alleinstehende Wefzerei (Bild 3)
AutorWer in einem Testfall mehrere Baselines benötigt, darf die einzelnen Testfälle nach dem folgenden Schema gruppieren. Die in BenchmarkCategory übergebenen Strings legen fest, welche Baseline zur Bewertung der jeweils erhaltenen Ergebnisse heranzuziehen ist:
[BenchmarkCategory("Fast"), Benchmark(Baseline = true)] public void Time50() => Thread.Sleep(50); [BenchmarkCategory("Fast"), Benchmark] public void Time100() => Thread.Sleep(100);
Das hier gezeigte Snippet würde zwei Gruppen anlegen. Die obigen Benchmarks beziehen sich auf Fast, während die folgenden zur Gruppe Slow gehören:
[BenchmarkCategory("Slow"), Benchmark(Baseline = true)] public void Time550() => Thread.Sleep(550); [BenchmarkCategory("Slow"), Benchmark] public void Time600() => Thread.Sleep(600);
Interessant ist die Möglichkeit, die Verhaltensweisen der verschiedenen Benchmark-Routinen durch Parameter festzulegen. Zunächst wollen wir die Anzahl der vom Programm zu generierenden Fauchereien modifizieren. Um die Ausführungsdauer in Grenzen zu halten, treten wir anfangs jeweils zehn und 200 Wefzvorgänge los. Hierzu ist die Deklaration eines Params-Objekts erforderlich, das nach folgendem Schema aufzubauen ist:
public class BenchmrkWorkerClass { [Params(10, 200)] public int N { get; set; }
Die Liste bestimmt dann, welche Werte der Runner während der Ausführung des Benchmarks in das Feld N einzuschreiben hat.
Das eigentliche Musizieren nutzt nun den Params-Wert N als Obergrenze für die for-Schleife:
[Benchmark] public void HissALot() { for(int i=0;i<N;i++) Console.WriteLine("Fauch!"); }
Daraus resultieren die in Bild 4 gezeigten Ergebnisse. Offensichtlich ist, dass sich der Wert von N nur auf den Testfall HissALot, aber nicht auf seinen Kollegen HissOnce auswirkt.

Der Wert von N ist nur für einen der beiden Benchmarks relevant (Bild 4)
AutorEine „Sondervariante“ von Params ist für die Verarbeitung von Enums vorgesehen. [ParamsAllValues] ermöglicht es nach folgendem Schema, alle Felder der Enum als im Rahmen des Benchmark-Runs zu testende Werte festzulegen:
public enum CustomEnum { One = 1, Two, Three } [ParamsAllValues] public CustomEnum E { get; set; }
Fauchen im Konzert
Obwohl Konsolenausgabeoperationen nicht zu den am schnellsten ablaufenden Aufgaben der Systemtechnik gehören, ist die für die Abarbeitung unserer Benchmark-Suite notwendige Zeit vergleichsweise lang. Als Erklärung hierfür könnte man annehmen, dass BenchmarkDotNet im Interesse der Herauskalibration von Ungenauigkeiten mehrere Durchläufe unseres Testfalls durchführt.
Zur Analyse dieses Verhaltens wollen wir die musikalische Testsuite auf die Methode HissOnce beschränken. In der Theorie dürfte die Ausführung des Programms dann nur noch zu einer einzigen Belästigung des Users führen.
Bei sorgfältiger Betrachtung der in der Konsole ausgegebenen Ergebnisse sehen wir einen nach dem Schema Mean = 137.139 us, StdErr = 0.712 us (0.52%), N = 23, StdDev = 3.417 us aufgebauten String; außerdem findet sich ein Histogramm mit verschiedenen Runtimes. Daraus lässt sich mit hoher Wahrscheinlichkeit schließen, dass der Runner von Haus aus mehrere Läufe absolviert.
Zur genaueren Untersuchung dieses Aspekts des Verhaltens sehen wir uns den Lebenszyklus unserer Testsuite näher an. Als Erstes betrachten wir die Attribute [GlobalSetup] und [GlobalCleanup], die – nomen est omen – den Beginn und das Ende der Ausführung der gesamten Testsuite anzeigen:
[GlobalSetup] public void GlobalSetup() { Console.WriteLine("Das Konzert beginnt!"); } [GlobalCleanup] public void GlobalCleanup() { Console.WriteLine("Das Konzert endet!"); }
Auf Ebene der individuellen Testfälle lässt sich nach folgendem Schema deklarieren:
[IterationSetup] public void IterationSetup() => Console.WriteLine("Die Wefze sagt: "); [IterationCleanup] public void IterationCleanup() => Console.WriteLine("und gibt wieder Ruhe!");
Zu guter Letzt sei noch angemerkt, dass Methoden wie GlobalSetup nur für einzelne Testfälle als relevant markiert werden können – dies setzt nach dem Schema [GlobalSetup(Targets = new[] { nameof(BenchmarkB), nameof(BenchmarkC) })] aufgebauten Code voraus. Dann ist eine SimpleJob-Deklaration erforderlich, die nach folgendem Schema die individuell notwendige Durchlaufzahl festlegt:
[SimpleJob(RunStrategy.Monitoring, launchCount: 1, warmupCount: 0, iterationCount: 3)] public class BenchmrkWorkerClass { ...
Wichtig ist, dass interpretative Runtimes – .NET gehört explizit dazu – seit langer Zeit verschiedene Pre-Kompilations-Features mitbringen. Um genauere Ergebnisse zu erhalten, kann es vernünftig sein, den Microbenchmark-Code mehrfach auszuführen.
Die Ergebnisse präsentieren sich nun wie in Bild 5 gezeigt.

Die Musik ertönt nun nur noch drei Mal (Bild 5)
AutorSchon aus Platzgründen kann dieser Artikel nicht auf alle Konfigurationsmöglichkeiten eingehen, die der Runner zur Verfügung stellt. In der Dokumentation von BenchmarkDotNet finden sich detaillierte weitere Informationen.
Fauchen auf Schallplatten!
Sinn der Ausführung von Benchmarks ist in vielen Fällen die Generierung eines Paper Trails. Obwohl die hier abgedruckten Screenshots der Runtime durchaus ansprechend aussehen, ist es in der Praxis wünschenswert, dass der Runner persistierbare Resultate erzeugt. Dies ist die Aufgabe der Exporters-Module, die in der BenchmarkDotNet-Dokumentation en détail dokumentiert sind.
Wir wollen an dieser Stelle nur einen grundlegenden HTML-Exporter einsetzen, was folgende Modifikation an der Deklaration unseres Jobs voraussetzt:
namespace NMGBenchConsole1 { [SimpleJob(RunStrategy.Monitoring, launchCount: 1, warmupCount: 0, iterationCount: 3)] [HtmlExporter] public class BenchmrkWorkerClass
Die Programmausführung des Benchmark-Harnischs erfolgt dann ohne wesentliche Besonderheiten. Bei sorgfältiger Betrachtung der Ausgabe lässt sich allerdings wie in Bild 6 gezeigt feststellen, dass der Runner über die erfolgreiche Ausgabe informiert.

Der Export der Daten verlief erfolgreich (Bild 6)
AutorFür die Aberntung der Ergebnisse bietet es sich an, das Projekt im Project Explorer rechts anzuklicken und die Option Enthaltenden Ordner öffnen zu wählen. Auf der Workstation des Autors findet sich im Ordner C:\Users\tamha\source\repos\NMGBenchConsole1\NMGBenchConsole1\bin\Release\net8.0\BenchmarkDotNet.Artifacts\results dann eine HTML-Datei, die sich im geöffneten Zustand wie in Bild 7 gezeigt präsentiert.

Fazit
Mit BenchmarkDotNet steht eine Infrastrukturkomponente zur Verfügung, die die Ermittlung von Performance-Informationen über .NET-Code erleichtert. Angesichts seiner immensen Flexibilität kommt das System nicht nur im Stand-alone-Betrieb, sondern auch als Komponente von anderen Utilities zum Einsatz.