Wie man ein benutzerdefiniertes Benchmarking-Harness in Rust erstellt
Everett Pompeii
Was ist Benchmarking?
Benchmarking ist die Praxis, die Leistung Ihres Codes zu testen, um zu sehen, wie schnell (Latenz) oder wie viel (Durchsatz) Arbeit er leisten kann. Dieser oft übersehene Schritt in der Softwareentwicklung ist entscheidend für die Erstellung und Wartung von schnellem und leistungsstarkem Code. Benchmarking liefert die notwendigen Metriken für Entwickler, um zu verstehen, wie gut ihr Code unter verschiedenen Arbeitslasten und Bedingungen funktioniert. Aus den gleichen Gründen, aus denen Sie Unit- und Integrationstests schreiben, um Funktionsregressionen zu verhindern, sollten Sie Benchmarks schreiben, um Leistungsregressionen zu verhindern. Leistungsfehler sind Fehler!
Benchmarking in Rust
Die drei beliebten Optionen für Benchmarking in Rust sind: libtest bench, Criterion, und Iai.
libtest ist Rusts eingebaute Einheitentest- und Benchmarking-Bibliothek.
Obwohl es Teil der Rust-Standardbibliothek ist, wird libtest bench immer noch als instabil betrachtet,
weshalb es nur in den nightly
Compiler-Releases verfügbar ist.
Um mit dem stabilen Rust-Compiler zu arbeiten,
muss ein separates Benchmarking-Harness
verwendet werden.
Keines von beiden wird jedoch aktiv weiterentwickelt.
Das beliebteste Benchmarking-Harness innerhalb des Rust-Ökosystems ist Criterion.
Es funktioniert sowohl mit stabilen als auch mit nightly
Rust-Compiler-Releases
und ist zum De-facto-Standard innerhalb der Rust-Community geworden.
Criterion ist auch viel funktionsreicher im Vergleich zum libtest bench.
Eine experimentelle Alternative zu Criterion ist Iai, vom selben Ersteller wie Criterion. Es verwendet jedoch Befehlszählungen anstelle der Echtzeitmessung: CPU-Befehle, L1-Zugriffe, L2-Zugriffe und RAM-Zugriffe. Dies ermöglicht einmalige Benchmarks, da diese Metriken zwischen den Durchläufen nahezu identisch bleiben sollten.
Mit all dem im Hinterkopf, wenn du die Laufzeit deiner Codes messen möchtest, dann solltest du wahrscheinlich Criterion verwenden. Wenn du deinen Code in CI benchmarken möchtest mit geteilten Runnern, könnte es sich lohnen, Iai auszuprobieren. Beachte jedoch, dass Iai seit über 3 Jahren nicht aktualisiert wurde. Du könntest stattdessen Iai-Callgrind verwenden.
Aber was, wenn du weder die Laufzeit noch die Anzahl der Anweisungen benchmarken willst? Was, wenn du eine völlig andere Benchmark verfolgen möchtest‽ Glücklicherweise macht es Rust unglaublich einfach, ein benutzerdefiniertes Benchmarking-Harness zu erstellen.
Wie cargo bench
Funktioniert
Bevor wir ein benutzerdefiniertes Benchmarking-Harness erstellen,
müssen wir verstehen, wie Rust-Benchmarks funktionieren.
Für die meisten Rust-Entwickler bedeutet dies, das das cargo bench
Kommando auszuführen.
Das cargo bench
Kommando kompiliert und führt Ihre Benchmarks aus.
Standardmäßig versucht cargo bench
, das eingebaute (aber instabile) libtest Bench-Harness zu verwenden.
libtest bench wird dann Ihren Code durchgehen und alle Funktionen ausführen, die mit dem #[bench]
Attribut gekennzeichnet sind.
Um ein benutzerdefiniertes Benchmarking-Harness zu verwenden, müssen wir cargo bench
mitteilen, dass libtest bench nicht verwendet werden soll.
Verwenden Sie ein benutzerdefiniertes Benchmarking-Harness mit cargo bench
Um cargo bench
daran zu hindern, das libtest-Benchmark zu verwenden,
müssen wir Folgendes zu unserer Cargo.toml
-Datei hinzufügen:
Leider können wir das Attribut #[bench]
nicht mit unserem benutzerdefinierten Benchmarking-Harness verwenden.
Vielleicht eines Tages bald, aber nicht heute.
Stattdessen müssen wir ein separates benches
-Verzeichnis erstellen, um unsere Benchmarks zu speichern.
Das benches
-Verzeichnis ist für Benchmarks
was das tests
-Verzeichnis für Integrationstests ist.
Jede Datei im benches
-Verzeichnis wird als separates Crate behandelt.
Das zu benchmarkende Crate muss daher ein Bibliothekscrate sein.
Das heißt, es muss eine lib.rs
-Datei haben.
Zum Beispiel, wenn wir ein grundlegendes Bibliothekscrate namens game
hätten,
könnten wir eine benutzerdefinierte Benchmark-Datei namens play_game
zum benches
-Verzeichnis hinzufügen.
Unsere Verzeichnisstruktur würde folgendermaßen aussehen:
Als Nächstes müssen wir cargo bench
über unser benutzerdefiniertes Benchmark-Crate play_game
informieren.
Also aktualisieren wir unsere Cargo.toml
-Datei:
Code zum Benchmarken schreiben
Bevor wir einen Leistungstest schreiben können, benötigen wir etwas Bibliothekscode zum Benchmarken. Für unser Beispiel werden wir das FizzBuzzFibonacci-Spiel spielen.
Die Regeln für FizzBuzzFibonacci sind wie folgt:
Schreibe ein Programm, das die Ganzzahlen von
1
bis100
(einschließlich) druckt:
- Für Vielfache von drei drucke
Fizz
- Für Vielfache von fünf drucke
Buzz
- Für Vielfache von sowohl drei als auch fünf drucke
FizzBuzz
- Für Zahlen, die Teil der Fibonacci-Sequenz sind, drucke nur
Fibonacci
- Für alle anderen drucke die Zahl
So sieht unsere Implementierung in src/lib.rs
aus:
Erstellen eines benutzerdefinierten Benchmarking-Harness
Wir werden ein benutzerdefiniertes Benchmarking-Harness in benches/play_game.rs
erstellen.
Dieses benutzerdefinierte Benchmarking-Harness wird Speicherallokationen messen
unter Verwendung des dhat-rs Crate.
dhat-rs
ist ein fantastisches Werkzeug zur Verfolgung von Speicherallokationen in Rust-Programmen,
erstellt von Rust-Performance-Experte Nicholas Nethercote.
Um uns bei der Verwaltung unserer Benchmark-Funktionen zu helfen,
werden wir das inventory Crate
verwenden, entwickelt vom erstaunlich produktiven David Tolnay.
Fügen wir dhat-rs
und inventory
als dev-dependencies
zu unserer Cargo.toml
Datei hinzu:
Einen benutzerdefinierten Allokator erstellen
Da unser benutzerdefinierter Benchmarking-Harness Heap-Allocierungen messen wird,
müssen wir einen benutzerdefinierten Heap-Allokator verwenden.
Rust erlaubt die Konfiguration eines benutzerdefinierten, globalen Heap-Allokators
mit dem Attribut #[global_allocator]
.
Fügen Sie Folgendes an den Anfang von benches/play_game.rs
hinzu:
Dies teilt Rust mit, dass dhat::Alloc
als unser globaler Heap-Allokator verwendet werden soll.
🐰 Es kann nur ein globaler Heap-Allokator gleichzeitig gesetzt werden. Wenn Sie zwischen mehreren globalen Allokatoren wechseln möchten, müssen diese durch bedingte Kompilierung mit [Rust-Features] verwaltet werden.
Einen benutzerdefinierten Benchmark-Sammler erstellen
Um ein benutzerdefiniertes Benchmarking-Framework zu erstellen,
brauchen wir eine Möglichkeit, unsere Benchmark-Funktionen zu identifizieren und zu speichern.
Wir werden eine Struktur verwenden, die passend CustomBenchmark
genannt wird, um jede Benchmark-Funktion zu kapseln.
Ein CustomBenchmark
hat einen Namen und eine Benchmark-Funktion, die dhat::HeapStats
als Ausgabe zurückgibt.
Dann werden wir das inventory
-Crate verwenden, um eine Sammlung für alle unsere CustomBenchmark
s zu erstellen:
Erstellen Sie eine Benchmark-Funktion
Nun können wir eine Benchmark-Funktion erstellen, die das FizzBuzzFibonacci-Spiel spielt:
Zeile für Zeile:
- Erstellen Sie eine Benchmark-Funktion, die der Signatur in
CustomBenchmark
entspricht. - Erstellen Sie einen
dhat::Profiler
im Testmodus, um Ergebnisse von unseremdhat::Alloc
benutzerdefinierten, globalen Speicherzuteiler zu sammeln. - Führen Sie unsere
play_game
Funktion in einer „black box“ aus, damit der Compiler unseren Code nicht optimiert. - Iterieren Sie von
1
bis100
inklusive. - Rufen Sie für jede Zahl
play_game
auf, wobeiprint
auffalse
gesetzt ist. - Geben Sie unsere Speicherbelegungsstatistiken als
dhat::HeapStats
zurück.
🐰 Wir setzen
false
für dieplay_game
Funktion. Dies verhindert, dassplay_game
auf Standardausgabe druckt. Das Parameterisieren Ihrer Bibliotheksfunktionen auf diese Weise kann sie besser für Benchmarking geeignet machen. Allerdings bedeutet dies, dass wir die Bibliothek möglicherweise nicht genau so benchmarken wie sie in der Produktion verwendet wird.In diesem Fall müssen wir uns fragen:
- Sind die Ressourcen, die zum Drucken auf Standardausgabe benötigt werden, für uns relevant?
- Ist das Drucken auf Standardausgabe eine mögliche Rauschquelle?
Für unser Beispiel haben wir folgende Antworten gewählt:
- Nein, uns ist das Drucken auf Standardausgabe nicht wichtig.
- Ja, es ist sehr wahrscheinlich eine Rauschquelle.
Daher haben wir das Drucken auf Standardausgabe als Teil dieses Benchmarks weggelassen. Benchmarking ist schwierig, und oft gibt es nicht die eine richtige Antwort auf solche Fragen. Es kommt darauf an.
Registriere die Benchmark-Funktion
Mit unserer geschriebenen Benchmark-Funktion müssen wir
einen CustomBenchmark
erstellen
und diesen mit unserer Benchmark-Sammlung unter Verwendung von inventory
registrieren.
Wenn wir mehr als einen Benchmark hätten, würden wir diesen Vorgang wiederholen:
- Erstelle eine Benchmark-Funktion.
- Erstelle einen
CustomBenchmark
für die Benchmark-Funktion. - Registriere den
CustomBenchmark
bei derinventory
-Sammlung.
Erstellen eines benutzerdefinierten Benchmark-Runners
Schließlich müssen wir einen Runner für unser benutzerdefiniertes Benchmark-Harness erstellen. Ein benutzerdefiniertes Benchmark-Harness ist eigentlich nur ein Binärprogramm, das alle unsere Benchmarks für uns ausführt und seine Ergebnisse meldet. Der Benchmark-Runner ist das, was all dies orchestriert.
Wir möchten, dass unsere Ergebnisse im Bencher-Metrik-Format (BMF) JSON ausgegeben werden.
Um dies zu erreichen, müssen wir eine letzte Abhängigkeit hinzufügen,
das serde_json
Crate, genau, David Tolnay!
Als Nächstes implementieren wir eine Methode für CustomBenchmark
, um seine Benchmark-Funktion auszuführen
und dann die Ergebnisse als BMF JSON zurückzugeben.
Die BMF JSON-Ergebnisse enthalten sechs Messungen für jeden Benchmark:
- Final Blocks: Endgültige Anzahl der Blöcke, die beim Abschluss des Benchmarks zugewiesen wurden.
- Final Bytes: Endgültige Anzahl der Bytes, die beim Abschluss des Benchmarks zugewiesen wurden.
- Max Blocks: Maximale Anzahl der Blöcke, die während des Benchmark-Laufs zu einem Zeitpunkt zugewiesen wurden.
- Max Bytes: Maximale Anzahl der Bytes, die während des Benchmark-Laufs zu einem Zeitpunkt zugewiesen wurden.
- Total Blocks: Gesamte Anzahl der Blöcke, die während des Benchmark-Laufs zugewiesen wurden.
- Total Bytes: Gesamte Anzahl der Bytes, die während des Benchmark-Laufs zugewiesen wurden.
Schließlich können wir eine main
-Funktion erstellen, um alle Benchmarks in unserer inventory
-Sammlung auszuführen
und die Ergebnisse als BMF JSON auszugeben.
Das benutzerdefinierte Benchmark-Harness ausführen
Alles ist nun bereit. Wir können endlich unser benutzerdefiniertes Benchmark-Harness ausführen.
Die Ausgabe sowohl in der Standardausgabe
als auch in einer Datei namens results.json
sollte so aussehen:
Die genauen Zahlen, die Sie sehen, können je nach Architektur Ihres Computers etwas anders sein. Aber das Wichtige ist, dass Sie zumindest einige Werte für die letzten vier Metriken haben.
Benutzerdefinierte Benchmark-Ergebnisse verfolgen
Die meisten Benchmark-Ergebnisse sind flüchtig. Sie verschwinden, sobald Ihr Terminal die Scrollback-Grenze erreicht. Einige Benchmark-Harnesses lassen Sie Ergebnisse zwischenspeichern, aber das ist viel Aufwand. Und selbst dann könnten wir unsere Ergebnisse nur lokal speichern. Zum Glück für uns funktioniert unser benutzerdefiniertes Benchmarking-Harness mit Bencher! Bencher ist eine Suite von kontinuierlichen Benchmarking-Tools, die es uns ermöglicht, die Ergebnisse unserer Benchmarks über die Zeit zu verfolgen und Leistungsregressionen vor ihrer Einführung in die Produktion zu erfassen.
Sobald Sie Bencher mit Bencher Cloud oder Bencher Self-Hosted eingerichtet haben, können Sie die Ergebnisse unseres benutzerdefinierten Benchmarking-Harnesses verfolgen, indem Sie ausführen:
Sie können auch mehr darüber lesen, wie man benutzerdefinierte Benchmarks mit Bencher verfolgt und den JSON Benchmark Adapter.
Fazit
Wir haben diesen Beitrag begonnen, indem wir uns die drei beliebtesten Benchmarking-Gerüste im Rust-Ökosystem angesehen haben: libtest bench, Criterion, und Iai. Auch wenn diese die Mehrheit der Anwendungsfälle abdecken mögen, kann es manchmal notwendig sein, etwas anderes als die Echtzeit oder die Anzahl der Anweisungen zu messen. Dies führte uns dazu, ein benutzerdefiniertes Benchmarking-Gerüst zu erstellen.
Unser benutzerdefiniertes Benchmarking-Gerüst misst Heap-Allokationen unter Verwendung von dhat-rs
.
Die Benchmark-Funktionen wurden mit inventory
gesammelt.
Beim Ausführen geben unsere Benchmarks die Ergebnisse als Bencher Metric Format (BMF) JSON aus.
Wir konnten dann Bencher nutzen, um unsere benutzerdefinierten Benchmark-Ergebnisse im Laufe der Zeit zu verfolgen
und Performance-Regressionen in der CI zu erfassen.
Der gesamte Quellcode für diese Anleitung ist auf GitHub verfügbar.
Bencher: Kontinuierliches Benchmarking
Bencher ist eine Suite von kontinuierlichen Benchmarking-Tools. Hatten Sie jemals eine Performance Regression, die Ihre Nutzer beeinflusste? Bencher hätte das verhindern können. Bencher ermöglicht es Ihnen, Leistungsregressionen vorher zu erkennen und zu verhindern, bevor sie in die Produktion gelangen.
- Ausführen: Führen Sie Ihre Benchmarks lokal oder in CI mit Ihren bevorzugten Benchmarking-Tools aus. Das
bencher
CLI umfasst einfach Ihr vorhandenes Benchmark-Harness und speichert die Ergebnisse. - Verfolgen: Verfolgen Sie die Ergebnisse Ihrer Benchmarks im Laufe der Zeit. Überwachen, abfragen und grafisch darstellen der Ergebnisse mit der Bencher Web Konsole auf Basis des Quellzweigs, Testbetts und Maßnahme.
- Auffangen: Fangen Sie Leistungsregressionen in CI ab. Bencher verwendet modernste, anpassbare Analysen, um Leistungsregressionen zu erkennen, bevor sie in die Produktion gelangen.
Aus denselben Gründen, warum Unit Tests in CI laufen, um Feature Regressionen zu verhindern, sollten Benchmarks in CI mit Bencher ausgeführt werden, um Leistungsregressionen zu verhindern. Performance-Bugs sind Fehler!
Beginnen Sie damit, Leistungsregressionen in CI aufzufangen - probieren Sie Bencher Cloud kostenlos aus.