C++: Mysteriöserweise enorme Beschleunigung durch das Halten eines Operanden in einem Register
Lesezeit: 9 Minuten
Sam Manzer
Ich habe versucht, eine Vorstellung von den Auswirkungen eines Arrays im L1-Cache im Vergleich zum Arbeitsspeicher zu bekommen, indem ich eine Routine getaktet habe, die die Elemente eines Arrays mit dem folgenden Code skaliert und summiert (ich bin mir bewusst, dass ich das Ergebnis einfach skalieren sollte durch ‘ a’ am Ende; es geht darum, sowohl eine Multiplikation als auch eine Addition innerhalb der Schleife durchzuführen – bisher hat der Compiler nicht herausgefunden, ‘a’ auszuklammern):
double sum(double a,double* X,int size)
{
double total = 0.0;
for(int i = 0; i < size; ++i)
{
total += a*X[i];
}
return total;
}
#define KB 1024
int main()
{
//Approximately half the L1 cache size of my machine
int operand_size = (32*KB)/(sizeof(double)*2);
printf("Operand size: %d\n", operand_size);
double* X = new double[operand_size];
fill(X,operand_size);
double seconds = timer();
double result;
int n_iterations = 100000;
for(int i = 0; i < n_iterations; ++i)
{
result = sum(3.5,X,operand_size);
//result += rand();
}
seconds = timer() - seconds;
double mflops = 2e-6*double(n_iterations*operand_size)/seconds;
printf("Vector size %d: mflops=%.1f, result=%.1f\n",operand_size,mflops,result);
return 0;
}
Beachten Sie, dass die Routinen timer() und fill() der Kürze halber nicht enthalten sind; Die vollständige Quelle finden Sie hier, wenn Sie den Code ausführen möchten:
Dies ist eine völlig ungecachte Leistung, trotz der Tatsache, dass alle Elemente von X zwischen Schleifeniterationen im Cache gehalten werden sollten. Betrachten Sie den Assembler-Code, der generiert wurde von:
zeigen an, dass es den Wert von “total” in sum() auf dem Stack speichert und ihn bei jedem Schleifendurchlauf liest und schreibt. Ich habe die Assembly so geändert, dass dieser Operand in einem Register gespeichert wird:
...
addsd %xmm0, %xmm3
...
Diese kleine Änderung schafft a riesig Leistungssteigerung:
tl;dr
Meine Frage ist: Warum beschleunigt das Ersetzen eines einzelnen Speicherortzugriffs durch ein Register den Code so sehr, da der einzelne Ort im L1-Cache gespeichert werden sollte? Welche architektonischen Faktoren machen dies möglich? Es scheint sehr seltsam, dass das wiederholte Schreiben einer Stack-Position die Effektivität eines Caches vollständig zerstören würde.
Und der Compiler war dumm genug, es nicht im Register zu halten? Ich bin beeindruckt…
– Mystisch
27. März 2013 um 17:33 Uhr
@Mystcial: Fairerweise gegenüber den GCC-Leuten, gcc 4.7.2 führt es in einem Register.
– NPE
27. März 2013 um 17:35 Uhr
@NPE Ah ok. Das hilft mir zu halten etwas Glaube an den Compiler…
– Mystisch
27. März 2013 um 17:36 Uhr
Warum verwenden Sie einen so alten Compiler?
– leemes
27. März 2013 um 17:38 Uhr
Selbst für einen so alten Compiler wie gcc 4.2 ist das überraschend schlecht. Ich denke, der bevorzugte Compiler auf dem Mac ist heutzutage Clang, der wahrscheinlich aktueller ist. Verwenden Sie außerdem, um immer die beste Optimierung zu haben -O3 -march=native wenn Ihre Version von gcc dies bereits unterstützt.
– Jens Gustedt
27. März 2013 um 18:16 Uhr
Mystisch
Es ist wahrscheinlich eine Kombination aus einer längeren Abhängigkeitskette und Load Misprediction*.
In der nicht optimierten Version lautet der kritische Abhängigkeitspfad:
addsd -72(%rbp), %xmm0
movsd %xmm0, -72(%rbp)
Intern zerfällt es wahrscheinlich in:
Belastung (2 Zyklen)
addd (3 Zyklen)
speichern (3 Zyklen)
Wenn wir uns die optimierte Version ansehen, ist es nur:
addd (3 Zyklen)
Sie haben also 8 Zyklen gegenüber 3 Zyklen. Fast Faktor 3.
Ich bin mir nicht sicher, wie empfindlich die Nehalem-Prozessorlinie auf Speicher-Lade-Abhängigkeiten reagiert und wie gut sie funktioniert Weiterleitung. Aber es ist vernünftig zu glauben, dass es nicht null ist.
Load-Store-Fehlvorhersage:
Moderne Prozessoren verwenden die Vorhersage auf mehr Arten, als Sie sich vorstellen können. Das bekannteste davon ist wahrscheinlich Branch Prediction. Eine der weniger bekannten ist Load Prediction.
Wenn ein Prozessor eine Last sieht, lädt er sie sofort, bevor alle anstehenden Schreibvorgänge abgeschlossen sind. Es wird davon ausgegangen, dass diese Schreibvorgänge nicht mit den geladenen Werten in Konflikt stehen.
Wenn sich herausstellt, dass ein früherer Schreibvorgang mit einem Ladevorgang kollidiert, muss der Ladevorgang erneut ausgeführt und die Berechnung bis zum Ladepunkt zurückgesetzt werden. (ähnlich wie falsche Vorhersagen von Verzweigungen rückgängig gemacht werden)
Wie es hier relevant ist:
Natürlich können moderne Prozessoren mehrere Iterationen dieser Schleife gleichzeitig ausführen. Der Prozessor versucht also, den Ladevorgang durchzuführen (addsd -72(%rbp), %xmm0) bevor es den Laden beendet (movsd %xmm0, -72(%rbp)) aus der vorherigen Iteration.
Das Ergebnis? Der vorherige Speicher kollidiert mit der Last – daher eine Fehlvorhersage und ein Rollback.
*Beachten Sie, dass ich mir beim Namen “Load Prediction” nicht sicher bin. Ich habe nur in den Intel-Dokumenten darüber gelesen und sie schienen ihm keinen Namen zu geben.
stackoverflow.com/q/10274355/56778 enthält einige nützliche Links, die mich zu derselben Schlussfolgerung führen.
– Jim Mischel
27. März 2013 um 17:46 Uhr
Aber da ist auch der Loop, also der Faktor von ca. 3 dient nur zum Addieren zur Ergebnisvariablen. Zählt man die Sprung- und Schleifenbedingung dazu, beträgt der Faktor ca. 2 schätze ich, aber die Timings zeigen einen Faktor von ca. 3.5 … Also denke ich, dass die Anzahl der Zyklen nicht der wichtigste Faktor ist, sondern die Abhängigkeit.
– leemes
27. März 2013 um 17:47 Uhr
Beachten Sie, dass die Last potenziell um dieselben Ressourcen konkurrieren könnte wie movsd (%r12,%rax,8), %xmm0. Das könnte also zusätzliche Stände erzeugen. Aber ohne einen zyklusgenauen Emulator ist es schwer, sicher zu sein.
– Mystisch
27. März 2013 um 17:49 Uhr
Es kann aber etwas davon leiten? Das Hinzufügen mit nachfolgenden Ladevorgängen überlappen?
– Sam Manzer
27. März 2013 um 17:52 Uhr
@SamManzer Die einzige Ladung, mit der es sich überschneiden kann, ist movsd (%r12,%rax,8), %xmm0. Sie kann sich nicht mit der anderen Last überschneiden, da sie von ihr abhängig ist. Ebenso können Sie die nächste Iteration nicht vorzeitig laden, da dies von dem Wert abhängt, den Sie in der aktuellen Iteration darin speichern.
– Mystisch
27. März 2013 um 17:53 Uhr
UpAndAdam
Ich würde vermuten, dass das Problem nicht im Cache-/Speicherzugriff, sondern im Prozessor (Ausführung Ihres Codes) liegt. Hier gibt es mehrere sichtbare Engpässe.
Die Leistungszahlen hier basierten auf den von mir verwendeten Boxen (entweder Sandybridge oder Westmere)
Die Spitzenleistung für skalare Mathematik beträgt 2,7 GHz x2 FLOPS/Clock x2, da der Prozessor gleichzeitig addieren und multiplizieren kann. Die theoretische Effizienz des Codes beträgt 0,6/(2,7*2) = 11 %
Benötigte Bandbreite: 2 Doubles pro (+) und (x) -> 4Bytes/Flop 4 Bytes * 5,4GFLOPS = 21,6GB/s
Wenn Sie wissen, dass es kürzlich gelesen wurde, ist es wahrscheinlich in L1 (89 GB/s), L2 (42 GB/s) oder L3 (24 GB/s), sodass wir Cache-B/W ausschließen können
Das Speicher-Subsystem beträgt 18,9 GB/s, sodass selbst im Hauptspeicher die Spitzenleistung bei 18,9/21,6 GB/s = 87,5 % liegen sollte.
Möglicherweise möchten Sie Anfragen so früh wie möglich bündeln (durch Entrollen).
Auch bei spekulativer Ausführung ist tot += a *X[i] Die Hinzufügungen werden serialisiert, da tot(n) ausgewertet werden müssen, bevor tot(n+1) gestartet werden kann
Erste Abrollschlaufe
bewege i um 8er und tue
{//your func
for( int i = 0; i < size; i += 8 ){
tot += a * X[i];
tot += a * X[i+1];
...
tot += a * X[i+7];
}
return tot
}
Verwenden Sie mehrere Akkumulatoren
Dadurch werden Abhängigkeiten aufgehoben und ein Blockieren der Additionspipeline vermieden
{//your func//
int tot,tot2,tot3,tot4;
tot = tot2 = tot3 = tot4 = 0
for( int i = 0; i < size; i += 8 )
tot += a * X[i];
tot2 += a * X[i+1];
tot3 += a * X[i+2];
tot4 += a * X[i+3];
tot += a * X[i+4];
tot2 += a * X[i+5];
tot3 += a * X[i+6];
tot4 += a * X[i+7];
}
return tot + tot2 + tot3 + tot4;
}
UPDATE Nachdem ich dies auf einer SandyBridge-Box ausgeführt habe, habe ich Zugriff auf: (2,7 GHz SandyBridge mit -O2 -march=native -mtune=native
Nur neugierig, woher bekommen Sie Ihre Cache-/Speicherbandbreitendaten?
– Sam Manzer
27. März 2013 um 18:04 Uhr
Es wurde für unsere nachträglich realisierte Hardware gemessen, also YMMV 🙂 Für nehalem habe ich dies gefunden stackoverflow.com/questions/2353299/…
– UpAndAdam
27. März 2013 um 19:12 Uhr
Das ist interessant; Daher werden im Cache nur 40 % des Spitzenwerts erreicht. Ziemlich ordentlich; Ich werde mir diese verschiedenen Optimierungen ansehen.
– Sam Manzer
27. März 2013 um 20:17 Uhr
@SamManzer Die Additionspipeline kann in beiden Fällen nie vollständig genutzt werden. Die Latenz beträgt 3 bei Durchsatz 1. Sie müssen also 3 davon gleichzeitig ausführen, um das Maximum herauszuholen. Aber da es nur eine einzige Addition gibt und diese sich auf dem kritischen Pfad befindet, können Sie höchstens 1/3 der Additionspipeline nutzen.
– Mystisch
27. März 2013 um 20:31 Uhr
@Sam Manzer Sie können eine großartige Lektion dazu im offenen Kurs von Stanford in Performance Computing auf itunes U finden. Einer der frühen Fälle ist Punktprodukt- / Matrixmultiplikation, von der ich einiges davon abgeleitet habe.
– UpAndAdam
27. März 2013 um 21:20 Uhr
NPE
Ich kann das nicht wirklich reproduzieren, weil mein Compiler (gcc 4.7.2) hält total in einem Register.
Ich vermute, dass der Hauptgrund für die Langsamkeit nicht mit dem L1-Cache zu tun hat, sondern eher in der Datenabhängigkeit zwischen den Speichern liegt
movsd %xmm0, -72(%rbp)
und die Belastung der nachfolgenden Iteration:
addsd -72(%rbp), %xmm0
Ah, Sie meinen also, dass es warten muss, bis der Speicher abgeschlossen ist, bevor der Ladevorgang beginnen kann? Ich denke, wenn der Cache Write-Through ist, würde das sehr teuer werden.
– Sam Manzer
27. März 2013 um 17:46 Uhr
Dies ist ein Unterschied zwischen gcc 4.2 und späteren Versionen; gcc 4.4 und höher optimiert die temporäre Stack-Nutzung.
– FrankH.
27. März 2013 um 18:35 Uhr
14161600cookie-checkC++: Mysteriöserweise enorme Beschleunigung durch das Halten eines Operanden in einem Registeryes
Und der Compiler war dumm genug, es nicht im Register zu halten? Ich bin beeindruckt…
– Mystisch
27. März 2013 um 17:33 Uhr
@Mystcial: Fairerweise gegenüber den GCC-Leuten,
gcc 4.7.2
führt es in einem Register.– NPE
27. März 2013 um 17:35 Uhr
@NPE Ah ok. Das hilft mir zu halten etwas Glaube an den Compiler…
– Mystisch
27. März 2013 um 17:36 Uhr
Warum verwenden Sie einen so alten Compiler?
– leemes
27. März 2013 um 17:38 Uhr
Selbst für einen so alten Compiler wie gcc 4.2 ist das überraschend schlecht. Ich denke, der bevorzugte Compiler auf dem Mac ist heutzutage Clang, der wahrscheinlich aktueller ist. Verwenden Sie außerdem, um immer die beste Optimierung zu haben
-O3 -march=native
wenn Ihre Version von gcc dies bereits unterstützt.– Jens Gustedt
27. März 2013 um 18:16 Uhr