Anzeige
Anzeige
Anzeige
Anzeige
Anzeige
Anzeige
Lesedauer 7 Min.

Benchmark im Unit-Test-Stil

NuGet-Pakete können das Leben des .NET-Entwicklers sogar um neue Werkzeuge erweitern, die sich auf den Entwicklungsworkflow (und nicht den bearbeiteten Code) auswirken. Mit BenchmarkDotNet steht ein Musterbeispiel dieses Konzepts zur Verfügung, das die Überprüfung der Performance von Code aller Arten ermöglicht.
© EMGenie

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)

Der Runner zeigt sich weinerlich (Bild 1)

© Autor

Da 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.

Nach dem Konzert folgt die Analyse (Bild 2)
Nach dem Konzert folgt die Analyse (Bild 2) © Autor

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)

Tausend Fauchvorgänge brauchen 948-mal so viel Zeit wie eine alleinstehende Wefzerei (Bild 3)

© Autor

Wer 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)

Der Wert von N ist nur für einen der beiden Benchmarks relevant (Bild 4)

© Autor

Eine „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)

Die Musik ertönt nun nur noch drei Mal (Bild 5)

© Autor

Schon 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)

Der Export der Daten verlief erfolgreich (Bild 6)

© Autor

Fü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.

Die „Basisversion“ zeigt sich eher spröde (Bild 7)
Die „Basisversion“ zeigt sich eher spröde (Bild 7) © Autor

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.

Neueste Beiträge

Layouts, Grids und responsive Gestaltung - Moderne UI-Gestaltung mit der Uno Platform, Teil 1
Mit Layout-Containern in der Uno Platform lassen sich strukturierte, performante und responsive Oberflächen erstellen.
10 Minuten
Von Text zu Struktur: JSON-Ausgaben aus LLMs zuverlässig nutzen - KI für KMU, Teil 4
Mit JSON-Schema lassen sich LLM-Ausgaben direkt deserialisieren, typsicher verarbeiten und in bestehende Workflows integrieren.
7 Minuten
27. Nov 2025
Designsysteme und Tools: Von Figma zu UNO Platform Studio - Moderne UI-Gestaltung mit der Uno Platform, Teil 2
Mit Designsystemen als Fundament und der Integration von Figma lassen sich UI-Komponenten in der Uno Platform konsistent, plattformübergreifend und schnell erstellen, pflegen und in verschiedenen Projekten wiederverwenden.
8 Minuten

Das könnte Dich auch interessieren

Erstellung von ZUGFeRD 2.3 mit .NET C# - Rechnungserstellung
ZUGFeRD 2.3 konforme Rechnungen mit TX Text Control .NET Server für ASP.NET erstellen.
3 Minuten
9. Jan 2025
Fünf freie .NET Decompiler - Mark Pelf, CodeProject
Der serbische Softwareentwickler Mark Pelf stellt bei CodeProject fünf freie .NET Decompiler vor.
2 Minuten
6. Mär 2023
Attraktives GUI mit Spectre.Console - Best of NuGet, Teil 6
Mit der Bibliotheksfamilie Spectre.Console steht ein neues Produkt ante portas, das die Realisierung von visuell ansprechenden Kommandozeileninterfaces zu erleichtern sucht.
7 Minuten
29. Okt 2025
Anzeige
Anzeige
Anzeige
Anzeige
Anzeige