Ich habe versucht, eine Vorstellung von den Auswirkungen eines Arrays im L1-Cache im Vergleich zum Speicher zu bekommen, indem ich eine Routine zeitlich festgelegt habe, die die Elemente eines Arrays mithilfe des folgenden Codes skaliert und summiert (mir ist bewusst, dass ich das Ergebnis nur mit 'skalieren sollte). a 'am Ende; der Punkt ist, sowohl ein Multiplizieren als auch ein Addieren innerhalb der Schleife durchzuführen - bisher hat der Compiler nicht herausgefunden, um' a 'herauszufiltern):
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:
Hier wird es interessant. Dies ist die Ausgabe:
Operand size: 2048
Vector size 2048: mflops=588.8, result=-67.8
Dies ist eine völlig nicht zwischengespeicherte Leistung, obwohl alle Elemente von X zwischen Schleifeniterationen im Cache gehalten werden sollten. Betrachten Sie den Assembler-Code, der generiert wurde von:
g++ -O3 -S -fno-asynchronous-unwind-tables register_opt_example.cpp
Ich bemerke eine Kuriosität in der Summenfunktionsschleife:
L55:
movsd (%r12,%rax,8), %xmm0
mulsd %xmm1, %xmm0
addsd -72(%rbp), %xmm0
movsd %xmm0, -72(%rbp)
incq %rax
cmpq $2048, %rax
jne L55
Die Anleitungen:
addsd -72(%rbp), %xmm0
movsd %xmm0, -72(%rbp)
Geben Sie an, dass der Wert von "total" in sum () auf dem Stapel gespeichert und bei jeder Schleifeniteration gelesen und geschrieben wird. Ich habe die Assembly so geändert, dass dieser Operand in einem Register gespeichert wird:
...
addsd %xmm0, %xmm3
...
Diese kleine Änderung sorgt für einen enormen Leistungsschub:
Operand size: 2048
Vector size 2048: mflops=1958.9, result=-67.8
tl; dr Meine Frage lautet: Warum beschleunigt das Ersetzen eines Zugriffs auf einen einzelnen Speicherort durch ein Register den Code so stark, da der einzelne Speicherort im L1-Cache gespeichert werden sollte? Welche architektonischen Faktoren machen dies möglich? Es scheint sehr seltsam, dass das wiederholte Schreiben eines Stack-Speicherorts die Effektivität eines Caches vollständig zerstören würde.
Blinddarm
Meine gcc-Version ist:
Target: i686-apple-darwin10
Configured with: /var/tmp/gcc/gcc-5646.1~2/src/configure --disable-checking --enable-werror --prefix=/usr --mandir=/share/man --enable-languages=c,objc,c++,obj-c++ --program-transform-name=/^[cg][^.-]*$/s/$/-4.2/ --with-slibdir=/usr/lib --build=i686-apple-darwin10 --with-gxx-include-dir=/include/c++/4.2.1 --program-prefix=i686-apple-darwin10- --host=x86_64-apple-darwin10 --target=i686-apple-darwin10
Thread model: posix
gcc version 4.2.1 (Apple Inc. build 5646) (dot 1)
Meine CPU ist:
Intel Xeon X5650
quelle
gcc 4.7.2
hält es in einem Register.-O3 -march=native
Sie diese Option , wenn Ihre Version von gcc dies bereits unterstützt.Antworten:
Es ist wahrscheinlich eine Kombination aus einer längeren Abhängigkeitskette und Load Misprediction *.
Längere Abhängigkeitskette:
Zunächst identifizieren wir die kritischen Abhängigkeitspfade. Dann schauen wir uns die Anweisungslatenzen an, die bereitgestellt werden von: http://www.agner.org/optimize/instruction_tables.pdf (Seite 117)
In der nicht optimierten Version lautet der kritische Abhängigkeitspfad:
addsd -72(%rbp), %xmm0
movsd %xmm0, -72(%rbp)
Intern zerfällt es wahrscheinlich in:
Wenn wir uns die optimierte Version ansehen, ist es nur:
Sie haben also 8 Zyklen gegenüber 3 Zyklen. Fast ein Faktor von 3.
Ich bin mir nicht sicher, wie empfindlich die Nehalem-Prozessorlinie auf Speicherladeabhängigkeiten reagiert und wie gut die Weiterleitung funktioniert . Aber es ist vernünftig zu glauben, dass es nicht Null ist.
Load-Store-Fehleinschätzung:
Moderne Prozessoren verwenden Vorhersagen auf mehr Arten, als Sie sich vorstellen können. Die bekannteste davon ist wahrscheinlich die Zweigvorhersage . Eine der weniger bekannten ist die Lastvorhersage.
Wenn ein Prozessor eine Last sieht, lädt er diese sofort, bevor alle ausstehenden 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 einer Last in Konflikt steht, muss die Last erneut ausgeführt und die Berechnung auf den Punkt der Last zurückgesetzt werden. (Ähnlich wie bei Branchenfehlvorhersagen)
Wie es hier relevant ist:
Es ist unnötig zu erwähnen, dass moderne Prozessoren mehrere Iterationen dieser Schleife gleichzeitig ausführen können. Der Prozessor versucht also, das Laden (
addsd -72(%rbp), %xmm0)
bevor er den Speicher (movsd %xmm0, -72(%rbp)
) beendet ) aus der vorherigen Iteration durchzuführen .Das Ergebnis? Der vorherige Speicher widerspricht der Last - also eine falsche Vorhersage und ein Rollback.
* Beachten Sie, dass ich mir des Namens "Load Prediction" nicht sicher bin. Ich habe nur in den Intel-Dokumenten darüber gelesen und sie schienen ihm keinen Namen zu geben.
quelle
movsd (%r12,%rax,8), %xmm0
. Das könnte also zu zusätzlichen Ständen führen. Ohne einen zyklusgenauen Emulator ist es jedoch schwierig, sicher zu sein.movsd (%r12,%rax,8), %xmm0
. Es kann sich nicht mit der anderen Last überlappen, da es davon abhängig ist. Ebenso können Sie die nächste Iteration nicht frühzeitig laden, da dies von dem Wert abhängt, den Sie in der aktuellen Iteration speichern.Ich würde vermuten, dass das Problem nicht im Cache / Speicherzugriff liegt, sondern im Prozessor (Ausführung Ihres Codes). 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 die Skalarmathematik 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%
Erforderliche Bandbreite: 2 Doppel pro (+) und (x) -> 4 Byte / Flop 4 Byte * 5,4 GFPLOPS = 21,6 GB / 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), damit wir den Cache in Schwarzweiß ausschließen können
Das Speichersystem beträgt 18,9 GB / s, sodass die Spitzenleistung selbst im Hauptspeicher 18,9 / 21,6 GB / s = 87,5% erreichen sollte
Selbst bei spekulativer Ausführung werden tot + = a * X [i] die Adds serialisiert, da tot (n) ausgewertet werden muss, bevor tot (n + 1) gestartet werden kann
Erste Abrollschleife
bewegen i um 8 und tun
{//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 es wird vermieden, dass die Additionspipeline blockiert
{//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
Originalcode:
Operand size: 2048 Vector size 2048: mflops=2206.2, result=61.8 2.206 / 5.4 = 40.8%
Verbesserter Code:
Operand size: 2048 Vector size 2048: mflops=5313.7, result=61.8 5.3137 / 5.4 = 98.4%
quelle
Ich kann dies nicht reproduzieren, da mein Compiler (gcc 4.7.2)
total
in einem Register gespeichert ist.Ich vermute, der Hauptgrund für die Langsamkeit hat nicht mit dem L1-Cache zu tun, sondern liegt in der Datenabhängigkeit zwischen dem Speicher in
movsd %xmm0, -72(%rbp)
und die Belastung der nachfolgenden Iteration:
addsd -72(%rbp), %xmm0
quelle