Wie man ein benutzerdefiniertes Benchmarking-Harness in Rust erstellt

Everett Pompeii

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:

Cargo.toml
[[bench]]
harness = false

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:

game
├── Cargo.lock
├── Cargo.toml
└── benches
└── play_game.rs
└── src
└── lib.rs

Als Nächstes müssen wir cargo bench über unser benutzerdefiniertes Benchmark-Crate play_game informieren. Also aktualisieren wir unsere Cargo.toml-Datei:

Cargo.toml
[[bench]]
name = "play_game"
harness = false

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 bis 100 (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:

src/lib.rs
pub fn play_game(n: u32, print: bool) {
let result = fizz_buzz_fibonacci(n);
if print {
println!("{result}");
}
}
fn fizz_buzz_fibonacci(n: u32) -> String {
if is_fibonacci_number(n) {
"Fibonacci".to_string()
} else {
match (n % 3, n % 5) {
(0, 0) => "FizzBuzz".to_string(),
(0, _) => "Fizz".to_string(),
(_, 0) => "Buzz".to_string(),
(_, _) => n.to_string(),
}
}
}
fn is_fibonacci_number(n: u32) -> bool {
let (mut previous, mut current) = (0, 1);
while current < n {
let next = previous + current;
previous = current;
current = next;
}
current == n
}

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:

Cargo.toml
[dev-dependencies]
dhat = "0.3"
inventory = "0.3"

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:

benches/play_game.rs
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;

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.

benches/play_game.rs
#[derive(Debug)]
struct CustomBenchmark {
name: &'static str,
benchmark_fn: fn() -> dhat::HeapStats,
}

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 CustomBenchmarks zu erstellen:

benches/play_game.rs
#[derive(Debug)]
struct CustomBenchmark {
name: &'static str,
benchmark_fn: fn() -> dhat::HeapStats,
}
inventory::collect!(CustomBenchmark);

Erstellen Sie eine Benchmark-Funktion

Nun können wir eine Benchmark-Funktion erstellen, die das FizzBuzzFibonacci-Spiel spielt:

benches/play_game.rs
fn bench_play_game() -> dhat::HeapStats {
let _profiler = dhat::Profiler::builder().testing().build();
std::hint::black_box(for i in 1..=100 {
play_game(i, false);
});
dhat::HeapStats::get()
}

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 unserem dhat::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 bis 100 inklusive.
  • Rufen Sie für jede Zahl play_game auf, wobei print auf false gesetzt ist.
  • Geben Sie unsere Speicherbelegungsstatistiken als dhat::HeapStats zurück.

🐰 Wir setzen print auf false für die play_game Funktion. Dies verhindert, dass play_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:

  1. Sind die Ressourcen, die zum Drucken auf Standardausgabe benötigt werden, für uns relevant?
  2. Ist das Drucken auf Standardausgabe eine mögliche Rauschquelle?

Für unser Beispiel haben wir folgende Antworten gewählt:

  1. Nein, uns ist das Drucken auf Standardausgabe nicht wichtig.
  2. 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.

benches/play_game.rs
fn bench_play_game() -> dhat::HeapStats {
let _profiler = dhat::Profiler::builder().testing().build();
std::hint::black_box(for i in 1..=100 {
play_game(i, false);
});
dhat::HeapStats::get()
}
inventory::submit!(CustomBenchmark {
name: "bench_play_game",
benchmark_fn: bench_play_game
});

Wenn wir mehr als einen Benchmark hätten, würden wir diesen Vorgang wiederholen:

  1. Erstelle eine Benchmark-Funktion.
  2. Erstelle einen CustomBenchmark für die Benchmark-Funktion.
  3. Registriere den CustomBenchmark bei der inventory-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!

Cargo.toml
[dev-dependencies]
dhat = "0.3"
inventory = "0.3"
serde_json = "1.0"

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.

benches/play_game.rs
impl CustomBenchmark {
fn run(&self) -> serde_json::Value {
let heap_stats = (self.benchmark_fn)();
let measures = serde_json::json!({
"Final Blocks": {
"value": heap_stats.curr_blocks,
},
"Final Bytes": {
"value": heap_stats.curr_bytes,
},
"Max Blocks": {
"value": heap_stats.max_blocks,
},
"Max Bytes": {
"value": heap_stats.max_bytes,
},
"Total Blocks": {
"value": heap_stats.total_blocks,
},
"Total Bytes": {
"value": heap_stats.total_bytes,
},
});
let mut benchmark_map = serde_json::Map::new();
benchmark_map.insert(self.name.to_string(), measures);
benchmark_map.into()
}
}

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.

benches/play_game.rs
fn main() {
let mut bmf = serde_json::Map::new();
for benchmark in inventory::iter::<CustomBenchmark> {
let mut results = benchmark.run();
bmf.append(results.as_object_mut().unwrap());
}
let bmf_str = serde_json::to_string_pretty(&bmf).unwrap();
std::fs::write("results.json", &bmf_str).unwrap();
println!("{bmf_str}");
}

Das benutzerdefinierte Benchmark-Harness ausführen

Alles ist nun bereit. Wir können endlich unser benutzerdefiniertes Benchmark-Harness ausführen.

Terminal window
cargo bench

Die Ausgabe sowohl in der Standardausgabe als auch in einer Datei namens results.json sollte so aussehen:

{
"bench_play_game": {
"Current Blocks": {
"value": 0
},
"Current Bytes": {
"value": 0
},
"Max Blocks": {
"value": 1
},
"Max Bytes": {
"value": 9
},
"Total Blocks": {
"value": 100
},
"Total Bytes": {
"value": 662
}
}
}

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:

Terminal window
bencher run --file results.json "cargo bench"

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

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.

🤖 Dieses Dokument wurde automatisch von OpenAI GPT-4 generiert. Es ist möglicherweise nicht korrekt und kann Fehler enthalten. Wenn Sie Fehler finden, öffnen Sie bitte ein Problem auf GitHub.