Beim Ausführen einer Summenschleife über ein Array in Rust habe ich einen enormen Leistungsabfall festgestellt, wenn CAPACITY
> = 240. CAPACITY
= 239 etwa 80-mal schneller ist.
Gibt es eine spezielle Kompilierungsoptimierung, die Rust für "kurze" Arrays durchführt?
Zusammengestellt mit rustc -C opt-level=3
.
use std::time::Instant;
const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;
fn main() {
let mut arr = [0; CAPACITY];
for i in 0..CAPACITY {
arr[i] = i;
}
let mut sum = 0;
let now = Instant::now();
for _ in 0..IN_LOOPS {
let mut s = 0;
for i in 0..arr.len() {
s += arr[i];
}
sum += s;
}
println!("sum:{} time:{:?}", sum, now.elapsed());
}
arrays
performance
rust
llvm-codegen
Guy Korland
quelle
quelle
Antworten:
Zusammenfassung : Unter 240 rollt LLVM die innere Schleife vollständig ab und stellt so fest, dass die Wiederholungsschleife optimiert werden kann, wodurch Ihr Benchmark gebrochen wird.
Sie haben einen magischen Schwellenwert gefunden, oberhalb dessen LLVM bestimmte Optimierungen nicht mehr ausführt . Der Schwellenwert beträgt 8 Bytes * 240 = 1920 Bytes (Ihr Array ist ein Array von
usize
s, daher wird die Länge mit 8 Bytes multipliziert, unter der Annahme einer x86-64-CPU). In diesem Benchmark ist eine bestimmte Optimierung - nur für die Länge 239 durchgeführt - für den enormen Geschwindigkeitsunterschied verantwortlich. Aber fangen wir langsam an:(Der gesamte Code in dieser Antwort wird mit kompiliert.
-C opt-level=3
)Dieser einfache Code erzeugt ungefähr die Baugruppe, die man erwarten würde: eine Schleife, die Elemente addiert. Wenn Sie jedoch zu wechseln
240
,239
unterscheidet sich die emittierte Baugruppe erheblich. Sehen Sie es im Godbolt Compiler Explorer . Hier ist ein kleiner Teil der Versammlung:Dies wird als Schleifen-Abrollen bezeichnet : LLVM fügt den Schleifenkörper einige Zeit ein, um zu vermeiden, dass alle diese "Schleifenverwaltungsanweisungen" ausgeführt werden müssen, dh die Schleifenvariable wird inkrementiert, überprüft, ob die Schleife beendet wurde, und der Sprung zum Anfang der Schleife .
Falls Sie sich fragen: Die
paddq
und ähnliche Anweisungen sind SIMD-Anweisungen, mit denen mehrere Werte parallel summiert werden können. Darüber hinaus werden zwei 16-Byte-SIMD-Register (xmm0
undxmm1
) parallel verwendet, so dass die Parallelität auf Befehlsebene der CPU grundsätzlich zwei dieser Befehle gleichzeitig ausführen kann. Immerhin sind sie unabhängig voneinander. Am Ende werden beide Register addiert und dann horizontal zum skalaren Ergebnis summiert.Moderne Mainstream-x86-CPUs (nicht Atom mit geringem Stromverbrauch) können tatsächlich 2 Vektorladevorgänge pro Takt ausführen, wenn sie im L1d-Cache getroffen werden, und der
paddq
Durchsatz beträgt mindestens 2 pro Takt, wobei die meisten CPUs 1 Zyklus Latenz haben. Siehe https://agner.org/optimize/ und auch diese Fragen und Antworten zu mehreren Akkumulatoren, um die Latenz (von FP FMA für ein Punktprodukt) und den Engpass beim Durchsatz zu verbergen.LLVM rollt einige kleine Schleifen ab, wenn es nicht vollständig entrollt ist, und verwendet immer noch mehrere Akkumulatoren. In der Regel sind Engpässe bei der Front-End-Bandbreite und der Back-End-Latenz kein großes Problem für LLVM-generierte Schleifen, auch ohne vollständige Abwicklung.
Das Abrollen der Schleife ist jedoch nicht für einen Leistungsunterschied von Faktor 80 verantwortlich! Zumindest nicht alleine abrollen. Werfen wir einen Blick auf den tatsächlichen Benchmarking-Code, der die eine Schleife in eine andere versetzt:
( Im Godbolt Compiler Explorer )
Die Assembly für
CAPACITY = 240
sieht normal aus: zwei verschachtelte Schleifen. (Zu Beginn der Funktion gibt es nur zum Initialisieren einen Code, den wir ignorieren werden.) Für 239 sieht es jedoch ganz anders aus! Wir sehen, dass die Initialisierungsschleife und die innere Schleife abgewickelt wurden: soweit so erwartet.Der wichtige Unterschied besteht darin, dass LLVM für 239 herausfinden konnte, dass das Ergebnis der inneren Schleife nicht von der äußeren Schleife abhängt! Infolgedessen gibt LLVM Code aus, der im Grunde nur die innere Schleife ausführt (Berechnung der Summe) und dann die äußere Schleife durch mehrmaliges Addieren simuliert
sum
!Zuerst sehen wir fast dieselbe Baugruppe wie oben (die Baugruppe, die die innere Schleife darstellt). Danach sehen wir dies (ich habe kommentiert, um die Versammlung zu erklären; die Kommentare mit
*
sind besonders wichtig):Wie Sie hier sehen können, wird das Ergebnis der inneren Schleife so oft addiert, wie die äußere Schleife gelaufen wäre, und dann zurückgegeben. LLVM kann diese Optimierung nur durchführen, weil es verstanden hat, dass die innere Schleife unabhängig von der äußeren ist.
Dies bedeutet, dass sich die Laufzeit von
CAPACITY * IN_LOOPS
nach ändertCAPACITY + IN_LOOPS
. Und das ist verantwortlich für den enormen Leistungsunterschied.Ein zusätzlicher Hinweis: Können Sie etwas dagegen tun? Nicht wirklich. LLVM muss solche magischen Schwellenwerte haben, dass LLVM-Optimierungen ohne sie für bestimmte Codes ewig dauern können. Wir können uns aber auch darauf einigen, dass dieser Code sehr künstlich war. In der Praxis bezweifle ich, dass ein so großer Unterschied eintreten würde. Der Unterschied aufgrund des vollständigen Abrollens der Schleife beträgt in diesen Fällen normalerweise nicht einmal Faktor 2. Sie müssen sich also keine Gedanken über echte Anwendungsfälle machen.
Als letzte Anmerkung zum idiomatischen Rust-Code:
arr.iter().sum()
ist eine bessere Möglichkeit, alle Elemente eines Arrays zusammenzufassen. Eine Änderung im zweiten Beispiel führt zu keinen nennenswerten Unterschieden bei der emittierten Baugruppe. Sie sollten kurze und idiomatische Versionen verwenden, es sei denn, Sie haben gemessen, dass dies die Leistung beeinträchtigt.quelle
sum
direkt auf keinem lokalen aktualisierts
wurde, viel langsamer lief.for i in 0..arr.len() { sum += arr[i]; }
Instant
verhindert?Wenn Sie zusätzlich zu Lukas 'Antwort einen Iterator verwenden möchten, versuchen Sie Folgendes:
Vielen Dank an Chris Morgan für den Vorschlag zum Bereichsmuster.
Das optimierte Montage ist recht gut:
quelle
(0..CAPACITY).sum::<usize>() * IN_LOOPS
, was das gleiche Ergebnis liefert.rustc
die Gelegenheit verpasse, diese Kraftreduzierung durchzuführen. In diesem speziellen Kontext scheint dies jedoch eine Zeitschleife zu sein, und Sie möchten absichtlich, dass sie nicht optimiert wird. Der springende Punkt ist, die Berechnung so oft von Grund auf zu wiederholen und durch die Anzahl der Wiederholungen zu dividieren. In C besteht die (inoffizielle) Redewendung dafür darin, den Schleifenzähler alsvolatile
z. B. den BogoMIPS-Zähler im Linux-Kernel zu deklarieren . Gibt es eine Möglichkeit, dies in Rust zu erreichen? Es mag sein, aber ich weiß es nicht. Ein Anruf bei einem externenfn
Mitarbeiter kann hilfreich sein.volatile
die Synchronisierung dieses Speichers. Durch Anwenden auf den Schleifenzähler wird nur das tatsächliche Neuladen / Speichern des Schleifenzählerwerts erzwungen. Es wirkt sich nicht direkt auf den Schleifenkörper aus. Aus diesem Grund ist es normalerweise besser, das tatsächlich wichtige Ergebnisvolatile int sink
oder etwas entweder nach der Schleife (wenn eine schleifenübertragene Abhängigkeit besteht) oder nach jeder Iteration zuzuweisen , damit der Compiler den Schleifenzähler nach Belieben optimieren kann, ihn aber erzwingt um das gewünschte Ergebnis in einem Register zu materialisieren, damit es es speichern kann.