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
)
pub fn foo() -> usize {
let arr = [0; 240];
let mut s = 0;
for i in 0..arr.len() {
s += arr[i];
}
s
}
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:
movdqa xmm1, xmmword ptr [rsp + 32]
movdqa xmm0, xmmword ptr [rsp + 48]
paddq xmm1, xmmword ptr [rsp]
paddq xmm0, xmmword ptr [rsp + 16]
paddq xmm1, xmmword ptr [rsp + 64]
; more stuff omitted here ...
paddq xmm0, xmmword ptr [rsp + 1840]
paddq xmm1, xmmword ptr [rsp + 1856]
paddq xmm0, xmmword ptr [rsp + 1872]
paddq xmm0, xmm1
pshufd xmm1, xmm0, 78
paddq xmm1, xmm0
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
und xmm1
) 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:
const CAPACITY: usize = 239;
const IN_LOOPS: usize = 500000;
pub fn foo() -> usize {
let mut arr = [0; CAPACITY];
for i in 0..CAPACITY {
arr[i] = i;
}
let mut sum = 0;
for _ in 0..IN_LOOPS {
let mut s = 0;
for i in 0..arr.len() {
s += arr[i];
}
sum += s;
}
sum
}
( 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):
; at the start of the function, `rbx` was set to 0
movq rax, xmm1 ; result of SIMD summing up stored in `rax`
add rax, 711 ; add up missing terms from loop unrolling
mov ecx, 500000 ; * init loop variable outer loop
.LBB0_1:
add rbx, rax ; * rbx += rax
add rcx, -1 ; * decrement loop variable
jne .LBB0_1 ; * if loop variable != 0 jump to LBB0_1
mov rax, rbx ; move rbx (the sum) back to rax
; two unimportant instructions omitted
ret ; the return value is stored in `rax`
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.