Zum Inhalt springen

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.

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.

Das ist der wichtigste Performance-Tipp in Rust – und es ist nur ein Flag.

Terminal window
cargo run # Debug-Build: schnell kompiliert, langsam in der Ausführung
cargo run --release # Release-Build: langsamer kompiliert, viel schneller in der Ausführung

Ein 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.83s
release build: took 250ms, 297ms, 265ms

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

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

3
2
the quick brown fox jumps over the lazy dog
▶ Selbst ausprobieren

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

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 gewachsen
Vec::with_capacity(): 0-mal gewachsen
▶ Selbst ausprobieren

Neunmal 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, blau
▶ Selbst ausprobieren

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: 220
Iterator: 220
▶ Selbst ausprobieren

Die 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 passende Datenstruktur bringt oft mehr als jedes Feintuning. Ein klassisches Beispiel: Du prüfst sehr oft „Ist dieses Element in meiner Liste?“.

  • Vec::contains schaut sich jedes Element einzeln an. Je länger die Liste, desto langsamer die Suche.
  • HashSet::contains (und HashMap::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 gefunden
▶ Selbst ausprobieren

Gleiche 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.97ms
HashSet: 6667 hits in 551.60µs

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

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:

Terminal window
# flamegraph: zeichnet ein Bild davon, wo die Zeit verbraucht wird (Linux, macOS)
cargo install flamegraph
cargo flamegraph
# samply: öffnet die Ergebnisse im Firefox Profiler in deinem Browser
cargo install samply
cargo build --release
samply record ./target/release/my_app

Profiler 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 = true

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 = false

Leg 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:

Terminal window
cargo bench

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

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öße
lto = true # Link-Time-Optimierung: über alle Crates hinweg optimieren (langsamerer Build)
codegen-units = 1 # eine Einheit = mehr Optimierungsmöglichkeiten, aber langsamer zu kompilieren
panic = "abort" # kein Unwinding-Code; kleinere Binärdatei, aber Panics lassen sich nicht abfangen

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

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 incorrect

Die 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 1
Zeile 2
Zeile 3
Zeile 4
Zeile 5
▶ Selbst ausprobieren

In 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).

  • 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() und join() vermeiden wiederholtes Wachsen.
  • Iteratoren kosten nichts extra; für viele Suchen nimm ein HashSet oder eine HashMap.
  • Erst profilen, dann die heißen Stellen optimieren, die der Profiler dir zeigt.

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