Performanzvergleich: Schleifen vs. Iteratoren
Um festzustellen, ob man besser Schleifen oder Iteratoren verwendet, solltest
du wissen, welche Implementierung schneller ist: Die Version der Funktion
search
mit einer expliziten for
-Schleife oder die Version mit Iteratoren.
Wir haben einen Benchmark durchführt, der den gesamten Inhalt von The
Adventures of Sherlock Holmes von Sir Arthur Conan Doyle in eine Zeichenkette
(String) lädt und nach dem Wort the im Inhalt sucht. Hier sind die
Ergebnisse des Benchmarks für die Version von search
mit for
-Schleife und
der Version die Iteratoren verwendet:
test bench_search_for ... bench: 19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench: 19,234,900 ns/iter (+/- 657,200)
Die Version mit Iteratoren war ein wenig schneller! Wir werden den Programmcode des Benchmarks hier nicht erläutern, da es nicht darum geht, nachzuweisen, dass die beiden Versionen gleichwertig sind, sondern einen allgemeinen Eindruck davon zu bekommen, wie diese beiden Versionen im Bezug auf Performanz verglichen werden.
Für einen umfassenderen Benchmark würde man verschiedene Texte
unterschiedlicher Größe als contents
, verschiedene Wörter und Wörter
unterschiedlicher Länge als query
verwenden und verschiedene Arten anderer
Variationen verwenden. Der Punkt ist folgender: Obwohl Iteratoren eine
hochrangige Abstraktion sind, werden sie ungefähr auf denselben Programmcode
kompiliert, als hättest du diesen selbst auf niedriger Ebene geschrieben.
Iteratoren sind eine von Rusts Zero-Cost Abstraktionen, damit ist gemeint,
dass die Verwendung keinen zusätzlichen Laufzeitaufwand verursacht. Dies
entspricht der Definition von Zero-Overhead in C++ von Bjarne Stroustrup in
"Foundations of C++" (2012):
Im Allgemeinen folgen C++-Implementierungen dem Zero-Overhead-Prinzip: Was du nicht verwendest, bezahlst du nicht. Und darüber hinaus: Was du verwendest, hättest du von Hand nicht besser programmieren können.
Als anderes Beispiel wird der folgende Programmcode eines Audiodecoders
übernommen. Der Decodierungsalgorithmus verwendet die mathematische Operation
der linearen Vorhersage (linear prediction), um zukünftige Werte aufgrund einer
linearen Funktion der vergangenen Abtastwerte zu schätzen. Der Programmcode
verwendet eine Iteratorkette, die drei Variablen im Gültigkeitsbereich
berechnet, einen Anteilstyp buffer
, ein Array mit 12 coefficients
und einen
Wert um den die Daten die nach glp_shift
verschoben werden sollen. Wir haben
die Variablen in diesem Beispiel deklariert, diesen jedoch keine Werte
zugewiesen, obwohl dieser Programmcode aus seinem Kontext gerissen keine große
Bedeutung hat, ist er dennoch ein gutes Beispiel dafür, wie Rust abstrakte Ideen
im Programmcode auf Code niedriger Ebene übersetzt.
let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;
for i in 12..buffer.len() {
let prediction = coefficients.iter()
.zip(&buffer[i - 12..i])
.map(|(&c, &s)| c * s as i64)
.sum::<i64>() >> qlp_shift;
let delta = buffer[i];
buffer[i] = prediction as i32 + delta;
}
Um den Wert von prediction
zu berechnen, durchläuft dieser Code jeden der 12
Werte in coefficients
und verwendet die Methode zip
, um die Werte der
Koeffizienten mit den vorherigen 12 Werten in buffer
zu paaren. Anschließend
multiplizieren wir die Werte jedes Paars miteinander, summieren alle
Ergebnisse und verschieben die Bits in der Summe um den Wert von glp_shift
nach
rechts.
Bei Berechnungen in Anwendungen wie Audiodecodern wird die Performanz häufig
priorisiert. Hier erstellen wir einen Iterator mit zwei Adaptern und verbrauchen
dann den Wert. Zu welchen Assemblercode würde dieser Rustprogrammcode
kompiliert werden? Er würde auf denselben Programmcode kompiliert werden, als
hättest du das Programm selbst in Assemblersprache geschrieben. Es gibt keine
Schleife, die der Iteration über die Werte von coefficients
entsprechen würde.
Rust weiß, dass es 12 Iterationen gibt und „rollt“ daher die Schleife ab.
Abrollen (unrolling) ist eine Optimierung, die den Mehraufwand (overhead) der
Steuerung der Schleife beseitigt und stattdessen sich wiederholenden
Programmcode für jede Iteration der Schleife generiert.
Alle Koeffizienten werden in Registern gespeichert, das bedeutet, dass der Zugriff auf die Werte sehr schnell ist. Es gibt keine Begrenzungsprüfungen (bounds checks) für den Zugriff auf Arrays zur Laufzeit. Durch diese Optimierungen, die Rust anwenden kann, ist der resultierende Programmcode äußerst effizient. Nun, da du das weißt, kannst du, ohne Angst zu haben, Funktionsabschlüsse und Iteratoren verwenden! Sie lassen den Code abstrakter erscheinen, verursachen aber keine Performanzeinbußen zur Laufzeit.
Zusammenfassung
Funktionsabschlüsse und Iteratoren sind Rust-Funktionalitäten, die von Ideen der funktionalen Programmierung inspiriert sind. Sie tragen zu Rusts Fähigkeit bei, abstrakte Ideen bei guter Performanz zu ermöglichen. Die Implementierungen von Iteratoren und Funktionsabschlüssen sind so, dass die Performanz der Laufzeit nicht beeinträchtigt wird. Dies ist ein Teil von Rusts Ziel, Zero-Cost-Abstraktionen zu ermöglichen.
Nachdem wir die Ausdruckskraft unseres E/A-Projekts verbessert haben, wollen
wir uns nun einige weitere Funktionalitäten von cargo
ansehen, die uns helfen
werden, das Projekt mit der Welt zu teilen.