Von der Compiler Optimierung O3 habe ich mir keine weiteren Geschwindigkeitsgewinn erhofft, aber dass das Programm doppelt so langsam! wurde, da stand mir dann doch der Mund offen ;)
Das ist btw. auch das erste mal in meinem Programmiererleben, dass O3 die Laufzeit wirklich deutlich verschlechtert. Bisher hatte ich nur davon gehört. Ein schneller Blick in den Profiler zeigt auch warum. Gab es bei O2 und der innersten Schleife ein paar 100k Branch Mispredicts, waren es bei O3 auf einmal ein paar Millionen! Die Stelle im Code war auch im ersten Teil schon etwas verwirrend. Anscheinend gibt es eine Code Optimierung, welche die Verzweigung in der min() Funktion entfernt, ganz ohne Bithacks.
Nun war es schon spät am Abend, aber die Sache musste unbedingt untersucht werden! Also habe ich dem vom Compiler erzeugten Assembler Code studiert und konnte die Sache auf ein Minimalbeispiel runter brechen.
Betrachtet werden muss nur die innerste Schleife, da nur hier die min() Funktion vorkommt.
#include <string>
#include <vector>
void func(size_t N) {
std::vector<int> v0(N+1), v1(N+1);
for(size_t i=0; i < N; ++i) {
v1[i+1] = std::min(v0[i], v0[i+1]);
}
}
Es wird paarweise das Minimum gesucht. Wird der Eingangs Array mit Zufallszahlen gefüllt, so muss die spekulative Ausführung der min() Funktion, also Branch Mispredict 50% betragen. Der Compiler erkennt dies irgendwie, und produziert folgenden Assembler Code ohne spekulative min() Funktion.
mov ecx, DWORD PTR [rbx] ecx = v0[0]
xor edx, edx i = 0
.L10: for()
add rdx, 1 ++i
mov esi, DWORD PTR [rbx+rdx*4] esi = v0[i+1]
cmp esi, ecx v0[i+1] <= ecx ?
cmovle ecx, esi Wenn true: ecx = esi Hier passiert die Magie.
cmp rdx, rbp i < N
mov DWORD PTR [rdi+rdx*4], ecx v1[i+1] = ecx
mov ecx, esi ecx = v0[i+1]
jb .L10
Der Code ist nicht weiter schwer. Ich habe eine grobe Übersetzung in die rechte Spalte geschrieben. In der ersten Zeile wird das erste Element von v0 in das Register ecx kopiert. Und in der zweiten Zeile das Register edx, also die Laufvariable i, auf 0 gesetzt. Danach beginnt in Zeile 3 der Schleifenkörper.
Variable i wird um eins erhöht und Zeile fünf erfolgt der lesende Zugriff auf v0[i+1] und der Wert wird in das Register esi geladen.
In den Register esi und ecx stehen jetzt die beiden Werte die verglichen werden sollen. Dabei steht in ecx immer der Wert der Vorherigen Schleifeniteration. Damit wird nur ein RAM Zugiff pro Iteration benötigt.
Verglichen werden die Werte in Zeile 6 und das Ergebnis des Vergleichs wird in Zeile 7 ausgewertet. Nur wenn der neue Wert an Stelle i+1 kleiner oder gleich als der alte ist, wird mit dem neuen Wert der alte überschrieben.
Das Ergebnis wird in Zeile 9 nach v1[i+1] kopiert und in Zeile 10 wird ecx für die nächste Iteration vorbereitet. Zeile 8 und 11 entspricht noch dem Schleifenkopf mit dem Rücksprung zum Label L10.
Die Magie passiert bei der Assembler Instruktion cmovle. Hier wird ein Kopiervorgang nur dann ausgeführt, wenn das Ergebnis des Vergleichs eine Zeile weiter oben ergeben hat, dass die Zahl kleiner ist. Es gibt also keine Verzweigung im Programmablauf und keine spekulative Ausführung. Daher auch kein Branch Mispredict. Der Code kann mit voller CPU Power laufen. Besser geht es nur noch mit SSE und prefetch.
Dies erklärt also warum die min() Funktion keine Branch Mispredicts erzeugt. Aber warum läuft der O3 Optimierte Code nicht schneller. Auch die Assembler Code hab ich untersucht und der Grund ist unter anderem tree vectorize und andere Schleifen Optimierungen die der Compiler vornimmt. Aber die Schleife ist so super einfach, dass jegliche Optimierung nur eine Verschlechterung verursacht. Auch Loop unroll, welches per default nicht mit O3 aktiviert wird, wird die Laufzeit nicht verbessern. Da der Compiler nicht wissen kann, wie oft die innerste Schleife laufen wird. Daher müssen alle Möglichkeiten betrachtet werden und dies führt zu ein paar mehr Jumps im Code. Und schon führt die verlangsamte spekulative Code Ausführung wieder zu.
Was haben wir gelernt? Optimierung O2 ist gut. O3 bringt in der Summe genauso viel wie sie wieder zerstört. Bei HPC macht es keinen Sinn immer mit O3 zu kompilieren. Vielmehr muss der Algorithmus und die Arbeitsweise der Ziel CPU bis ins Detail verstanden werden. Danach kann die passende Compiler Optimierung ausgewählt werden.
Getestet mit GNU GCC 7.3