C ++: Auf mysteriöse Weise eine enorme Beschleunigung, wenn ein Operand in einem Register gespeichert wird

70

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:

http://codepad.org/agPWItZS

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

Sam Manzer
quelle
12
Und der Compiler war dumm genug, ihn nicht im Register zu halten? Ich bin beeindruckt ...
Mysticial
12
@Mysticial: Um fair zu sein, gcc 4.7.2hält es in einem Register.
NPE
4
@ NPE Ah ok. Das hilft mir, etwas Vertrauen in den Compiler zu haben ...
Mysticial
2
Warum benutzt du so einen alten Compiler?
Leemes
5
Selbst für einen alten Compiler wie gcc 4.2 ist dies überraschend schlecht. Ich denke, es gibt heutzutage einen bevorzugten Compiler auf dem Mac, der wahrscheinlich aktueller ist. Um immer die beste Optimierung zu erzielen, verwenden -O3 -march=nativeSie diese Option , wenn Ihre Version von gcc dies bereits unterstützt.
Jens Gustedt

Antworten:

60

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:

  • Last (2 Zyklen)
  • addiert (3 Zyklen)
  • speichern (3 Zyklen)

Wenn wir uns die optimierte Version ansehen, ist es nur:

  • addiert (3 Zyklen)

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.

Mystisch
quelle
1
stackoverflow.com/q/10274355/56778 enthält einige nützliche Links, die mich zu derselben Schlussfolgerung führen.
Jim Mischel
Es gibt aber auch die Schleife, so dass der Faktor von ca. 3 dient nur zum Hinzufügen zur Ergebnisvariablen. Wenn Sie auch die Sprung- und Schleifenbedingung zählen, beträgt der Faktor ca. 2 Ich denke, aber die Timings zeigen einen Faktor von ca. 3.5 ... Ich denke also, die Anzahl der Zyklen ist nicht der wichtigste Faktor, sondern die Abhängigkeit.
Leemes
Beachten Sie, dass die Last möglicherweise um dieselben Ressourcen wie konkurrieren kann 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.
Mysticial
Kann es einiges davon leiten? Überlappen Sie das Hinzufügen mit nachfolgenden Ladevorgängen?
Sam Manzer
1
@SamManzer Die einzige Last, mit der es sich überschneiden kann, ist 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.
Mysticial
16

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

  • Möglicherweise möchten Sie Anforderungen (durch Abrollen) so früh wie möglich stapeln

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%  
UpAndAdam
quelle
Nur neugierig, woher bekommen Sie Ihre Cache- / Speicherbandbreitendaten?
Sam Manzer
Es wurde für unsere Hardware gemessen, die danach realisiert wurde, also YMMV :-) Für nehalem fand ich diesen stackoverflow.com/questions/2353299/…
UpAndAdam
Das ist interessant; Es werden also nur 40% des Peaks im Cache erreicht. Ziemlich ordentlich; Ich werde diese verschiedenen Optimierungen betrachten.
Sam Manzer
2
@SamManzer Die Additionspipeline kann in beiden Fällen niemals vollständig genutzt werden. Die Latenz beträgt 3 bei Durchsatz 1. Sie müssen also 3 davon gleichzeitig ausführen, um das Maximum zu erreichen. Da es jedoch nur eine einzige Addition gibt und diese sich auf dem kritischen Pfad befindet, können Sie höchstens 1/3 der Additionspipeline nutzen.
Mysticial
1
@ Sam Manzer Auf dem offenen Kurs von Stanford in Performance Computing auf iTunes U finden Sie eine großartige Lektion dazu. Einer der frühen Fälle ist die Punktprodukt- / Matrixmultiplikation, aus der ich einen Teil davon abgeleitet habe.
UpAndAdam
8

Ich kann dies nicht reproduzieren, da mein Compiler (gcc 4.7.2) totalin 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
NPE
quelle
2
Ah, Sie meinen also, dass es warten muss, bis der Laden fertig ist, bevor der Ladevorgang beginnen kann? Ich denke, wenn der Cache durchgeschrieben wird, würde das sehr teuer werden.
Sam Manzer
Dies ist ein Unterschied zwischen gcc 4.2 und späteren Versionen. Ab gcc 4.4 wird die temporäre Stack-Nutzung optimiert.
FrankH.