Performance in Rust
Rust hat den Ruf, schnell zu sein – und das zu Recht. Aber „Rust ist schnell“ heißt nicht, dass jedes Rust-Programm schnell ist. Ein langsamer Algorithmus ist in jeder Sprache langsam. Und ein paar Angewohnheiten können dich still und leise viel Zeit kosten.
Die gute Nachricht: Die meisten großen Verbesserungen sind einfach. Baue im Release-Modus. Miss, bevor du etwas änderst. Kopiere keine Daten, die du nur lesen willst. Wähle die passende Collection. Diese Seite geht jeden Punkt durch, mit echten Messwerten, wo sie helfen. Stell es dir vor wie beim Fahrrad: Erst prüfst du, ob die Reifen aufgepumpt sind (Release-Modus), dann kümmerst du dich um leichtere Teile.
Schritt null: messen mit Instant
Abschnitt betitelt „Schritt null: messen mit Instant“Bevor du etwas schneller machst, musst du wissen, wie langsam es ist. std::time::Instant ist eine Stoppuhr, die in der Standardbibliothek steckt. Ruf Instant::now() vor der Arbeit auf und .elapsed() danach:
use std::time::Instant;
fn main() { let start = Instant::now();
// 5 Millionen Pseudozufallszahlen mit einem einfachen Xorshift erzeugen. let mut x: u64 = 12345; let mut numbers = Vec::with_capacity(5_000_000); for _ in 0..5_000_000 { x ^= x << 13; x ^= x >> 7; x ^= x << 17; numbers.push(x % 1_000_000); }
numbers.sort(); let sum: u64 = numbers.iter().take(1000).sum();
println!("die kleinsten 1000 ergeben zusammen {sum}"); println!("Dauer: {:?}", start.elapsed());}Das Format {:?} gibt eine Duration gut lesbar aus, etwa 250.271745ms. Hier gibt es absichtlich keinen „Selbst ausprobieren“-Button: Zeiten ändern sich bei jedem Lauf und auf jedem Rechner, deshalb kann man sie nicht mit einer festen Ausgabe vergleichen. Starte das Programm stattdessen auf deinem eigenen Computer mit cargo run.
Debug- und Release-Builds
Abschnitt betitelt „Debug- und Release-Builds“Das ist der wichtigste Performance-Tipp in Rust – und es ist nur ein Flag.
cargo run # Debug-Build: schnell kompiliert, langsam in der Ausführungcargo run --release # Release-Build: langsamer kompiliert, viel schneller in der AusführungEin Debug-Build lässt die meisten Optimierungen weg. So kompiliert er schnell und lässt sich gut debuggen. Außerdem prüft er auf Integer-Überlauf. Ein Release-Build schaltet den Optimierer voll ein.
Hier ist eine Beispielmessung: Wir haben das Programm oben je dreimal auf dem Rechner laufen lassen, auf dem wir diese Seite geschrieben haben (ein Cloud-Server mit Intel-Xeon-CPU). Deine Zahlen werden anders sein – es zählt nur der Abstand zwischen ihnen:
debug build: took 5.02s, 4.27s, 4.83srelease build: took 250ms, 297ms, 265msDas ist ungefähr 15- bis 20-mal schneller, durch ein einziges Flag. Wie groß der Unterschied ist, hängt stark vom Code ab – Schleifen und Iteratoren profitieren am meisten. Klein genug zum Ignorieren ist er aber nie.
Die Regel ist also einfach: Beurteile die Geschwindigkeit nie anhand eines Debug-Builds. Wenn jemand nach einer Messung mit cargo run sagt „Rust ist langsam“, liegt es fast immer daran.
Ausleihen statt klonen
Abschnitt betitelt „Ausleihen statt klonen“.clone() macht eine vollständige Kopie. Bei einem String oder Vec heißt das: eine neue Speicherreservierung auf dem Heap und jedes Byte wird kopiert. Manchmal brauchst du eine Kopie. Oft willst du die Daten aber nur lesen – und ein Borrow (Ausleihen) kostet nichts.
Am häufigsten betrifft das Funktionsparameter. Wenn eine Funktion eine Zeichenkette nur liest, nimm &str statt String:
fn count_long_words(text: &str, min_len: usize) -> usize { text.split_whitespace().filter(|w| w.len() >= min_len).count()}
fn main() { let owned = String::from("the quick brown fox jumps over the lazy dog");
// &String wird automatisch zu &str, keine Kopie nötig. println!("{}", count_long_words(&owned, 5));
// Ein String-Literal ist schon ein &str. println!("{}", count_long_words("borrowing is cheap and cloning is not", 6));
// Wir haben `owned` nie weggegeben, also können wir es noch benutzen. println!("{owned}");}Ausgabe:
32the quick brown fox jumps over the lazy dogDie Sätze bleiben hier auf Englisch, weil das Programm Wortlängen in Bytes zählt (w.len()) – Umlaute würden das Ergebnis verändern.
Ein &str-Parameter nimmt sowohl einen String (mit & ausgeliehen) als auch ein String-Literal an. Ein String-Parameter würde die Aufrufer zwingen, ihre Zeichenkette herzugeben oder zu klonen. Dieselbe Idee funktioniert bei Listen: Nimm &[T] statt Vec<T>. Die Details findest du unter Borrowing und Slices.
Achte auch auf Clones in Schleifen. for name in names.clone() kopiert die ganze Liste, nur um sie anzuschauen. for name in &names leiht sie kostenlos aus.
Einmal reservieren statt immer wieder
Abschnitt betitelt „Einmal reservieren statt immer wieder“Wenn einem Vec der Platz ausgeht, reserviert er einen größeren Speicherblock und verschiebt alles dorthin. Wenn du die Größe vorher kennst, reserviert Vec::with_capacity sie auf einen Schlag. Dieses Programm zählt, wie oft jeder Vec beim Einfügen von 1000 Zahlen wachsen musste:
fn count_growths(mut v: Vec<u32>, n: u32) -> u32 { let mut growths = 0; let mut cap = v.capacity(); for i in 0..n { v.push(i); if v.capacity() != cap { growths += 1; cap = v.capacity(); } } growths}
fn main() { println!("Vec::new(): {}-mal gewachsen", count_growths(Vec::new(), 1000)); println!("Vec::with_capacity(): {}-mal gewachsen", count_growths(Vec::with_capacity(1000), 1000));}Ausgabe:
Vec::new(): 9-mal gewachsenVec::with_capacity(): 0-mal gewachsenNeunmal wachsen ist keine Katastrophe – der Vec verdoppelt sich jedes Mal, deshalb bleibt es günstig. Aber in einer heißen Schleife, die Millionen von Vektoren baut, summiert es sich. (Das genaue Wachstumsmuster ist ein internes Detail der Standardbibliothek und kann sich ändern.)
Noch einfacher: Lass collect() und join() die Arbeit machen. Sie kennen oft die endgültige Größe und reservieren nur einmal:
fn main() { // collect() baut den Vec in einem Rutsch. let squares: Vec<u64> = (1..=5).map(|n| n * n).collect(); println!("{squares:?}");
// join() berechnet erst die Gesamtlänge und kopiert dann einmal. let colors = ["rot", "grün", "blau"]; println!("{}", colors.join(", "));}Ausgabe:
[1, 4, 9, 16, 25]rot, grün, blauIteratoren kosten nichts extra
Abschnitt betitelt „Iteratoren kosten nichts extra“Vielleicht befürchtest du, dass .iter().filter().map().sum() langsamer ist als eine einfache Schleife, weil es nach vielen Funktionsaufrufen aussieht. Ist es nicht. In einem Release-Build fügt der Compiler all diese kleinen Closures direkt ein (Inlining), und du bekommst denselben kompakten Maschinencode wie bei einer handgeschriebenen Schleife. Rust nennt das eine Zero-Cost Abstraction (Abstraktion ohne Zusatzkosten): Für den schöneren Stil zahlst du nichts.
fn sum_even_squares_loop(nums: &[u64]) -> u64 { let mut total = 0; for i in 0..nums.len() { if nums[i] % 2 == 0 { total += nums[i] * nums[i]; } } total}
fn sum_even_squares_iter(nums: &[u64]) -> u64 { nums.iter().filter(|&&n| n % 2 == 0).map(|&n| n * n).sum()}
fn main() { let nums: Vec<u64> = (1..=10).collect(); println!("Schleife: {}", sum_even_squares_loop(&nums)); println!("Iterator: {}", sum_even_squares_iter(&nums));}Ausgabe:
Schleife: 220Iterator: 220Die Iterator-Version kann sogar ein bisschen schneller sein. Bei nums[i] prüft Rust bei jedem Zugriff, ob i innerhalb des Slices liegt. Der Iterator weiß schon, dass er nicht über die Grenzen hinauslaufen kann, also fallen diese Prüfungen weg. Nimm das, was sich besser liest – meistens den Iterator.
Die richtige Collection wählen
Abschnitt betitelt „Die richtige Collection wählen“Die passende Datenstruktur bringt oft mehr als jedes Feintuning. Ein klassisches Beispiel: Du prüfst sehr oft „Ist dieses Element in meiner Liste?“.
Vec::containsschaut sich jedes Element einzeln an. Je länger die Liste, desto langsamer die Suche.HashSet::contains(undHashMap::get) springt fast direkt zur Antwort. Die Suche bleibt schnell, egal wie groß die Menge ist.
use std::collections::HashSet;
fn main() { let banned: Vec<u32> = (0..20_000).map(|n| n * 3).collect(); let queries: Vec<u32> = (0..20_000).collect();
// Langsam bei großen Listen: Jedes contains() durchsucht den ganzen Vec. let hits_vec = queries.iter().filter(|q| banned.contains(q)).count();
// Schnell: Einmal ein HashSet bauen, dann ist jede Suche flott. let banned_set: HashSet<u32> = banned.iter().copied().collect(); let hits_set = queries.iter().filter(|q| banned_set.contains(q)).count();
println!("Vec hat {hits_vec} gefunden, HashSet hat {hits_set} gefunden");}Ausgabe:
Vec hat 6667 gefunden, HashSet hat 6667 gefundenGleiche Antwort, ganz unterschiedlicher Aufwand. Wir haben Instant-Stoppuhren um beide Teile gelegt und einen Release-Build laufen lassen. Das ist eine Beispielmessung von unserem Rechner:
Vec: 6667 hits in 27.97msHashSet: 6667 hits in 551.60µsEtwa 50-mal schneller – und das inklusive der Zeit, um das Set zu bauen. Für eine Liste mit 10 Elementen ist ein Vec aber völlig in Ordnung und kann sogar gewinnen. Mehr dazu auf den Seiten HashSet und HashMap.
Erst profilen, dann optimieren
Abschnitt betitelt „Erst profilen, dann optimieren“Programmierer sind bekanntlich schlecht darin, zu erraten, wo die Zeit verloren geht. Also rate nicht: Profile. Ein Profiler beobachtet dein Programm beim Laufen und zeigt dir, welche Funktionen am meisten Zeit brauchen. Meist sind es ein oder zwei heiße Stellen, und der Rest deines Codes spielt überhaupt keine Rolle.
Zwei beliebte, kostenlose Profiler, die mit Rust funktionieren:
# flamegraph: zeichnet ein Bild davon, wo die Zeit verbraucht wird (Linux, macOS)cargo install flamegraphcargo flamegraph
# samply: öffnet die Ergebnisse im Firefox Profiler in deinem Browsercargo install samplycargo build --releasesamply record ./target/release/my_appProfiler brauchen Debug-Symbole, um Funktionsnamen anzuzeigen. Füge das zu deiner Cargo.toml hinzu, damit Release-Builds sie behalten (der Code wird dadurch nicht langsamer, nur die Datei größer):
[profile.release]debug = trueBenchmarks mit criterion
Abschnitt betitelt „Benchmarks mit criterion“Etwas einmal mit Instant zu messen, ist ungenau. Ein Benchmark führt den Code viele Male aus und liefert mit Statistik eine verlässliche Zahl. Rusts eingebautes #[bench] funktioniert nur mit Nightly, deshalb nehmen die meisten das Crate criterion. So richtest du es ein:
[dev-dependencies]criterion = "0.8"
[[bench]]name = "fib_bench"harness = falseLeg den Code, den du testen willst, in deine Bibliothek (src/lib.rs) und schreib den Benchmark in benches/fib_bench.rs:
use criterion::{criterion_group, criterion_main, Criterion};use my_crate::fibonacci;use std::hint::black_box;
fn bench_fib(c: &mut Criterion) { c.bench_function("fibonacci 20", |b| b.iter(|| fibonacci(black_box(20))));}
criterion_group!(benches, bench_fib);criterion_main!(benches);black_box hindert den Compiler daran, schlau zu sein und das Ergebnis schon vorab auszurechnen – dann wäre der Benchmark wertlos. Starte ihn mit:
cargo benchEin Beispielergebnis von unserem Rechner (criterion zeigt eine untere Schätzung, die beste Schätzung und eine obere Schätzung):
fibonacci 20 time: [24.417 µs 25.079 µs 25.737 µs]Lass ihn nach einer Änderung noch einmal laufen. criterion sagt dir dann, ob der Code schneller oder langsamer geworden ist – und ob der Unterschied echt ist oder nur Rauschen.
Einstellungen für das Release-Profil
Abschnitt betitelt „Einstellungen für das Release-Profil“Die Standardeinstellungen für Release sind gut. Wenn du ein bisschen mehr brauchst, sind das die üblichen Stellschrauben in der Cargo.toml:
[profile.release]opt-level = 3 # Standard für Release; "s" oder "z" optimieren stattdessen auf Größelto = true # Link-Time-Optimierung: über alle Crates hinweg optimieren (langsamerer Build)codegen-units = 1 # eine Einheit = mehr Optimierungsmöglichkeiten, aber langsamer zu kompilierenpanic = "abort" # kein Unwinding-Code; kleinere Binärdatei, aber Panics lassen sich nicht abfangenDas bringt meist ein paar Prozent, keine Wunder, und die Builds werden langsamer. Probiere die Einstellungen einzeln aus und miss jede. lto = "thin" ist ein beliebter Mittelweg, der schneller baut als lto = true.
Häufige Fehler
Abschnitt betitelt „Häufige Fehler“Einen Debug-Build messen. Das hatten wir schon, aber es ist der Fehler Nummer eins: Miss immer mit --release.
Klonen, um den Compiler zufriedenzustellen. Angenommen, eine Funktion nimmt einen String, und du rufst sie mit einer Referenz auf:
fn shout(text: String) -> String { text.to_uppercase()}
fn main() { let name = String::from("ferris"); let loud = shout(&name); println!("{loud} {name}");}Der Compiler meldet:
error[E0308]: mismatched types --> src/main.rs:7:22 |7 | let loud = shout(&name); | ----- ^^^^^ expected `String`, found `&String` | | | arguments to this function are incorrectDie Meldung bedeutet: Die Typen passen nicht – erwartet wird ein String, übergeben wurde aber ein &String. Es ist verlockend, shout(name.clone()) zu schreiben. Das funktioniert, kopiert die Zeichenkette aber bei jedem Aufruf. Besser ist es, den Parameter in text: &str zu ändern – dann kompiliert shout(&name), und nichts wird kopiert.
println! in einer heißen Schleife. Jedes println! sperrt stdout und schreibt die Zeile sofort. Für ein paar Zeilen ist das in Ordnung. Für eine Million ist es langsam. Pack stdout in einen BufWriter, damit er in großen Blöcken schreibt:
use std::io::{self, BufWriter, Write};
fn main() { let mut out = BufWriter::new(io::stdout().lock()); for i in 1..=5 { writeln!(out, "Zeile {i}").unwrap(); }}Ausgabe:
Zeile 1Zeile 2Zeile 3Zeile 4Zeile 5In unserer Beispielmessung dauerte es etwa 570–670 ms, eine Million Zeilen mit println! in eine Datei zu schreiben, und etwa 40–60 ms mit BufWriter (Release-Build).
Zusammenfassung
Abschnitt betitelt „Zusammenfassung“- Miss die Geschwindigkeit immer mit
cargo run --release– Debug-Builds können um ein Vielfaches langsamer sein. - Nimm
Instant::now()und.elapsed()für schnelle Zeitmessungen und criterion für verlässliche Benchmarks. - Leih aus (
&str,&[T]), statt zu klonen, wenn du Daten nur lesen musst. - Reserviere einmal:
Vec::with_capacity,collect()undjoin()vermeiden wiederholtes Wachsen. - Iteratoren kosten nichts extra; für viele Suchen nimm ein
HashSetoder eineHashMap. - Erst profilen, dann die heißen Stellen optimieren, die der Profiler dir zeigt.
Kurzer Check
Abschnitt betitelt „Kurzer Check“Kurzer Check
1. Dein Programm fühlt sich langsam an, wenn du es mit cargo run startest. Was probierst du zuerst?
2. Eine Funktion liest eine Zeichenkette nur. Welcher Parametertyp vermeidet unnötige Kopien?
3. Du musst prüfen, ob 50.000 IDs in einer Liste von 50.000 gesperrten IDs stehen. Was ist die bessere Wahl für die Sperrliste?
4. Was bedeutet „Iteratoren sind eine Zero-Cost Abstraction“?
5. Warum benutzt ein criterion-Benchmark black_box?
Punkte: 0 / 5
Weiterlesen
Abschnitt betitelt „Weiterlesen“- The Rust Performance Book (auf Englisch): ein kurzer, praktischer Leitfaden zu Build-Einstellungen, Profiling und typischen Beschleunigungen.
- Performance-Vergleich: Schleifen vs. Iteratoren – Das Rust-Buch: warum Iteratoren zu schnellem Code kompiliert werden.
- Profiles – The Cargo Book (auf Englisch): jede
[profile]-Einstellung mit ihrem Standardwert. std::time::Instant(auf Englisch): die Stoppuhr der Standardbibliothek.- criterion-Dokumentation (auf Englisch): das oben verwendete Benchmark-Crate.