Wie kann die theoretische Spitzenleistung von 4 Gleitkommaoperationen (doppelte Genauigkeit) pro Zyklus auf einer modernen x86-64 Intel-CPU erreicht werden?
Soweit ich weiß, dauert es drei Zyklen für eine SSE add
und fünf Zyklen, mul
bis eine SSE auf den meisten modernen Intel-CPUs abgeschlossen ist (siehe zum Beispiel die 'Instruction Tables' von Agner Fog ). Aufgrund von Pipelining kann ein Durchsatz von einem add
pro Zyklus erzielt werden, wenn der Algorithmus mindestens drei unabhängige Summierungen aufweist. Da dies sowohl für gepackte addpd
als auch für skalare addsd
Versionen gilt und SSE-Register zwei enthalten double
können, kann der Durchsatz bis zu zwei Flops pro Zyklus betragen.
Darüber hinaus scheinen (obwohl ich keine ordnungsgemäße Dokumentation dazu gesehen habe) add
und mul
können parallel ausgeführt werden, was einen theoretischen maximalen Durchsatz von vier Flops pro Zyklus ergibt.
Ich konnte diese Leistung jedoch nicht mit einem einfachen C / C ++ - Programm replizieren. Mein bester Versuch ergab ungefähr 2,7 Flops / Zyklus. Wenn jemand ein einfaches C / C ++ - oder Assembler-Programm beisteuern kann, das Spitzenleistungen demonstriert, wäre er sehr dankbar.
Mein Versuch:
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <sys/time.h>
double stoptime(void) {
struct timeval t;
gettimeofday(&t,NULL);
return (double) t.tv_sec + t.tv_usec/1000000.0;
}
double addmul(double add, double mul, int ops){
// Need to initialise differently otherwise compiler might optimise away
double sum1=0.1, sum2=-0.1, sum3=0.2, sum4=-0.2, sum5=0.0;
double mul1=1.0, mul2= 1.1, mul3=1.2, mul4= 1.3, mul5=1.4;
int loops=ops/10; // We have 10 floating point operations inside the loop
double expected = 5.0*add*loops + (sum1+sum2+sum3+sum4+sum5)
+ pow(mul,loops)*(mul1+mul2+mul3+mul4+mul5);
for (int i=0; i<loops; i++) {
mul1*=mul; mul2*=mul; mul3*=mul; mul4*=mul; mul5*=mul;
sum1+=add; sum2+=add; sum3+=add; sum4+=add; sum5+=add;
}
return sum1+sum2+sum3+sum4+sum5+mul1+mul2+mul3+mul4+mul5 - expected;
}
int main(int argc, char** argv) {
if (argc != 2) {
printf("usage: %s <num>\n", argv[0]);
printf("number of operations: <num> millions\n");
exit(EXIT_FAILURE);
}
int n = atoi(argv[1]) * 1000000;
if (n<=0)
n=1000;
double x = M_PI;
double y = 1.0 + 1e-8;
double t = stoptime();
x = addmul(x, y, n);
t = stoptime() - t;
printf("addmul:\t %.3f s, %.3f Gflops, res=%f\n", t, (double)n/t/1e9, x);
return EXIT_SUCCESS;
}
Zusammengestellt mit
g++ -O2 -march=native addmul.cpp ; ./a.out 1000
erzeugt die folgende Ausgabe auf einem Intel Core i5-750 mit 2,66 GHz.
addmul: 0.270 s, 3.707 Gflops, res=1.326463
Das sind nur etwa 1,4 Flops pro Zyklus. Das Betrachten des Assembler-Codes mit
g++ -S -O2 -march=native -masm=intel addmul.cpp
der Hauptschleife erscheint mir irgendwie optimal:
.L4:
inc eax
mulsd xmm8, xmm3
mulsd xmm7, xmm3
mulsd xmm6, xmm3
mulsd xmm5, xmm3
mulsd xmm1, xmm3
addsd xmm13, xmm2
addsd xmm12, xmm2
addsd xmm11, xmm2
addsd xmm10, xmm2
addsd xmm9, xmm2
cmp eax, ebx
jne .L4
Das Ändern der Skalarversionen mit gepackten Versionen ( addpd
und mulpd
) würde die Anzahl der Flops verdoppeln, ohne die Ausführungszeit zu ändern, und so würde ich nur knapp 2,8 Flops pro Zyklus erhalten. Gibt es ein einfaches Beispiel, das vier Flops pro Zyklus erzielt?
Nettes kleines Programm von Mysticial; Hier sind meine Ergebnisse (nur für ein paar Sekunden):
gcc -O2 -march=nocona
: 5,6 Gflops von 10,66 Gflops (2,1 Flops / Zyklus)cl /O2
, openmp entfernt: 10,1 Gflops von 10,66 Gflops (3,8 Flops / Zyklus)
Es scheint alles ein bisschen komplex, aber meine bisherigen Schlussfolgerungen:
gcc -O2
ändert die Reihenfolge der unabhängigen Gleitkommaoperationen mit dem Ziel des Wechselsaddpd
undmulpd
wenn möglich. Gleiches gilt fürgcc-4.6.2 -O2 -march=core2
.gcc -O2 -march=nocona
scheint die in der C ++ - Quelle definierte Reihenfolge der Gleitkommaoperationen beizubehalten.cl /O2
Der 64-Bit-Compiler aus dem SDK für Windows 7 führt das automatische Abrollen der Schleife durch und scheint zu versuchen, Vorgänge so anzuordnen, dass Dreiergruppen mit Dreiergruppenaddpd
abwechselnmulpd
(zumindest auf meinem System und für mein einfaches Programm). .Mein Core i5 750 ( Nehalem-Architektur ) mag keine abwechselnden Adds und Mul's und scheint nicht in der Lage zu sein, beide Operationen parallel auszuführen. Wenn es jedoch in 3er gruppiert ist, funktioniert es plötzlich wie Magie.
Andere Architekturen (möglicherweise Sandy Bridge und andere) scheinen add / mul problemlos parallel ausführen zu können, wenn sie sich im Assemblycode abwechseln.
Es ist zwar schwer zuzugeben, aber auf meinem System
cl /O2
macht es einen viel besseren Job bei Optimierungsvorgängen auf niedriger Ebene für mein System und erzielt für das kleine C ++ - Beispiel oben eine nahezu maximale Leistung. Ich habe zwischen 1,85 und 2,01 Flops / Zyklus gemessen (habe Clock () in Windows verwendet, was nicht so genau ist. Ich denke, ich muss einen besseren Timer verwenden - danke Mackie Messer).Das Beste, was ich geschafft habe,
gcc
war das manuelle Abrollen und Anordnen von Additionen und Multiplikationen in Dreiergruppen. Mitg++ -O2 -march=nocona addmul_unroll.cpp
bekomme ich bestenfalls0.207s, 4.825 Gflops
1,8 Flops / Zyklus, mit denen ich jetzt ziemlich zufrieden bin.
Im C ++ - Code habe ich die for
Schleife durch ersetzt
for (int i=0; i<loops/3; i++) {
mul1*=mul; mul2*=mul; mul3*=mul;
sum1+=add; sum2+=add; sum3+=add;
mul4*=mul; mul5*=mul; mul1*=mul;
sum4+=add; sum5+=add; sum1+=add;
mul2*=mul; mul3*=mul; mul4*=mul;
sum2+=add; sum3+=add; sum4+=add;
mul5*=mul; mul1*=mul; mul2*=mul;
sum5+=add; sum1+=add; sum2+=add;
mul3*=mul; mul4*=mul; mul5*=mul;
sum3+=add; sum4+=add; sum5+=add;
}
Und die Montage sieht jetzt so aus
.L4:
mulsd xmm8, xmm3
mulsd xmm7, xmm3
mulsd xmm6, xmm3
addsd xmm13, xmm2
addsd xmm12, xmm2
addsd xmm11, xmm2
mulsd xmm5, xmm3
mulsd xmm1, xmm3
mulsd xmm8, xmm3
addsd xmm10, xmm2
addsd xmm9, xmm2
addsd xmm13, xmm2
...
quelle
-funroll-loops
). Versucht mit gcc Version 4.4.1 und 4.6.2, aber asm Ausgabe sieht in Ordnung aus?-O3
für gcc versucht , was ermöglicht-ftree-vectorize
? Vielleicht kombiniert mit-funroll-loops
obwohl ich nicht nicht, wenn das wirklich notwendig ist. Immerhin scheint der Vergleich irgendwie unfair zu sein, wenn einer der Compiler Vektorisierung / Abrollen durchführt, während der andere dies nicht tut, weil er es nicht kann, sondern weil es nicht auch gesagt wird.-funroll-loops
ist wahrscheinlich etwas zu versuchen. Aber ich denke-ftree-vectorize
ist neben dem Punkt. Das OP versucht nur, 1 Mul + 1 Add-Anweisung / Zyklus aufrechtzuerhalten. Die Anweisungen können skalar oder vektoriell sein - dies spielt keine Rolle, da Latenz und Durchsatz gleich sind. Wenn Sie also 2 / Zyklus mit skalarer SSE aufrechterhalten können, können Sie sie durch Vektor-SSE ersetzen und Sie erhalten 4 Flops / Zyklus. In meiner Antwort habe ich genau das von SSE -> AVX aus gemacht. Ich habe alle SSE durch AVX ersetzt - gleiche Latenzen, gleiche Durchsätze, 2x die Flops.Antworten:
Ich habe genau diese Aufgabe schon einmal erledigt. Es ging aber hauptsächlich darum, den Stromverbrauch und die CPU-Temperaturen zu messen. Der folgende Code (der ziemlich lang ist) erreicht auf meinem Core i7 2600K nahezu das Optimum.
Das Wichtigste dabei ist die enorme Menge an manuellem Abrollen von Schleifen sowie das Verschachteln von Multiplikationen und Adds ...
Das vollständige Projekt finden Sie auf meinem GitHub: https://github.com/Mysticial/Flops
Warnung:
Wenn Sie dies kompilieren und ausführen möchten, achten Sie auf Ihre CPU-Temperaturen !!!
Stellen Sie sicher, dass Sie es nicht überhitzen. Und stellen Sie sicher, dass die CPU-Drosselung Ihre Ergebnisse nicht beeinflusst!
Darüber hinaus übernehme ich keine Verantwortung für Schäden, die durch das Ausführen dieses Codes entstehen können.
Anmerkungen:
ICC 11 (Intel Compiler 11) hat überraschenderweise Probleme, es gut zu kompilieren.
Ausgabe (1 Thread, 10000000 Iterationen) - Kompiliert mit Visual Studio 2010 SP1 - x64 Release:
Die Maschine ist ein Core i7 2600K bei 4,4 GHz. Der theoretische SSE-Peak beträgt 4 Flops * 4,4 GHz = 17,6 GFlops . Dieser Code erreicht 17.3 GFlops - nicht schlecht.
Ausgabe (8 Threads, 10000000 Iterationen) - Kompiliert mit Visual Studio 2010 SP1 - x64 Release:
Der theoretische SSE-Peak beträgt 4 Flops * 4 Kerne * 4,4 GHz = 70,4 GFlops. Tatsächlich sind es 65,5 GFlops .
Gehen wir noch einen Schritt weiter. AVX ...
Ausgabe (1 Thread, 10000000 Iterationen) - Kompiliert mit Visual Studio 2010 SP1 - x64 Release:
Der theoretische AVX-Peak beträgt 8 Flops * 4,4 GHz = 35,2 GFlops . Tatsächlich sind es 33,4 GFlops .
Ausgabe (8 Threads, 10000000 Iterationen) - Kompiliert mit Visual Studio 2010 SP1 - x64 Release:
Der theoretische AVX-Peak beträgt 8 Flops * 4 Kerne * 4,4 GHz = 140,8 GFlops. Tatsächlich sind 138,2 GFlops .
Nun zu einigen Erklärungen:
Der leistungskritische Teil sind offensichtlich die 48 Anweisungen innerhalb der inneren Schleife. Sie werden feststellen, dass es in 4 Blöcke mit jeweils 12 Anweisungen unterteilt ist. Jeder dieser 12 Befehlsblöcke ist völlig unabhängig voneinander - und die Ausführung dauert durchschnittlich 6 Zyklen.
Es gibt also 12 Anweisungen und 6 Zyklen zwischen der Ausgabe. Die Multiplikationslatenz beträgt 5 Zyklen, sodass es gerade ausreicht, um Latenzstillstände zu vermeiden.
Der Normalisierungsschritt ist erforderlich, um ein Über- / Unterlaufen der Daten zu verhindern. Dies ist erforderlich, da der Nichtstun-Code die Größe der Daten langsam erhöht / verringert.
Es ist also tatsächlich möglich, es besser zu machen, wenn Sie nur alle Nullen verwenden und den Normalisierungsschritt loswerden. Da ich jedoch den Benchmark zur Messung des Stromverbrauchs und der Temperatur geschrieben habe, musste ich sicherstellen, dass sich die Flops auf "echten" Daten und nicht auf Nullen befanden - da die Ausführungseinheiten möglicherweise eine spezielle Fallbehandlung für Nullen haben, die weniger Strom verbrauchen und produzieren weniger Wärme.
Mehr Ergebnisse:
Themen: 1
Theoretischer SSE-Peak: 4 Flops * 3,5 GHz = 14,0 GFlops . Tatsächlich sind es 13,3 GFlops .
Themen: 8
Theoretischer SSE-Peak: 4 Flops * 4 Kerne * 3,5 GHz = 56,0 GFlops . Tatsächlich sind 51,3 GFlops .
Meine Prozessortemperaturen erreichten beim Multithread-Lauf 76 ° C! Wenn Sie diese ausführen, stellen Sie sicher, dass die Ergebnisse nicht durch CPU-Drosselung beeinflusst werden.
Themen: 1
Theoretischer SSE-Peak: 4 Flops * 3,2 GHz = 12,8 GFlops . Tatsächlich sind es 12,3 GFlops .
Themen: 8
Theoretischer SSE-Peak: 4 Flops * 8 Kerne * 3,2 GHz = 102,4 GFlops . Tatsächlich sind 97,9 GFlops .
quelle
1.814s, 5.292 Gflops, sum=0.448883
von einem Spitzenwert von 10,68 Gflops oder nur knapp 2,0 Flops pro Zyklus. Scheintadd
/mul
werden nicht parallel ausgeführt. Wenn ich Ihren Code ändere und immer mit demselben Register addiere / multipliziererC
, erreicht er plötzlich fast einen Spitzenwert:0.953s, 10.068 Gflops, sum=0
oder 3,8 Flops / Zyklus. Sehr eigenartig.cl /O2
(64-Bit von Windows SDK) bestätigen, und selbst mein Beispiel läuft dort nahe an der Spitze für skalare Operationen (1,9 Flops / Zyklus). Die Compiler-Schleife rollt ab und ordnet neu, aber das ist möglicherweise nicht der Grund, warum Sie sich etwas mehr damit befassen müssen. Drosselung kein Problem Ich bin nett zu meiner CPU und halte die Iterationen bei 100k. :)Es gibt einen Punkt in der Intel-Architektur, den die Leute oft vergessen: Die Dispatch-Ports werden von Int und FP / SIMD gemeinsam genutzt. Dies bedeutet, dass Sie nur eine bestimmte Anzahl von FP / SIMD-Bursts erhalten, bevor die Schleifenlogik Blasen in Ihrem Gleitkomma-Stream erzeugt. Mystical hat mehr Flops aus seinem Code herausgeholt, weil er in seiner abgewickelten Schleife längere Schritte gemacht hat.
Wenn Sie sich die Nehalem / Sandy Bridge-Architektur hier ansehen, http://www.realworldtech.com/page.cfm?ArticleID=RWT091810191937&p=6 , ist ziemlich klar, was passiert.
Im Gegensatz dazu sollte es einfacher sein, mit AMD (Bulldozer) Spitzenleistungen zu erzielen, da die INT- und FP / SIMD-Pipes separate Issue-Ports mit einem eigenen Scheduler haben.
Dies ist nur theoretisch, da ich keinen dieser Prozessoren testen muss.
quelle
inc
,cmp
, undjl
. Alle diese Faktoren können zu Port # 5 gehen und nicht stören entweder vektorisiertfadd
oderfmul
. Ich würde eher vermuten, dass der Decoder (manchmal) in die Quere kommt. Es müssen zwischen zwei und drei Anweisungen pro Zyklus aufrechterhalten werden. Ich erinnere mich nicht an die genauen Einschränkungen, aber Befehlslänge, Präfixe und Ausrichtung spielen eine Rolle.cmp
und aufjl
jeden Fall zu Port 5 gehen,inc
nicht so sicher, da es immer in Gruppe mit den 2 anderen kommt. Aber Sie haben Recht, es ist schwer zu sagen, wo der Engpass liegt, und die Decoder können auch Teil davon sein.Zweige können Sie definitiv davon abhalten, die theoretische Spitzenleistung aufrechtzuerhalten. Sehen Sie einen Unterschied, wenn Sie das Schleifen manuell abrollen? Wenn Sie beispielsweise 5 oder 10 Mal so viele Operationen pro Schleifeniteration ausführen:
quelle
-funroll-loops
Option, die nicht einmal in enthalten ist-O3
. Sieheg++ -c -Q -O2 --help=optimizers | grep unroll
.Mit Intel icc Version 11.1 auf einem 2,4 GHz Intel Core 2 Duo bekomme ich
Das kommt den idealen 9,6 Gflops sehr nahe.
BEARBEITEN:
Hoppla, wenn man sich den Assembler-Code ansieht, scheint es, dass icc nicht nur die Multiplikation vektorisiert, sondern auch die Additionen aus der Schleife gezogen hat. Durch Erzwingen einer strengeren fp-Semantik wird der Code nicht mehr vektorisiert:
EDIT2:
Wie gewünscht:
Die innere Schleife von Clangs Code sieht folgendermaßen aus:
EDIT3:
Abschließend zwei Vorschläge: Wenn Sie diese Art des Benchmarking mögen, sollten Sie zunächst die
rdtsc
Anweisung anstelle von verwendengettimeofday(2)
. Es ist viel genauer und liefert die Zeit in Zyklen, woran Sie normalerweise sowieso interessiert sind. Für gcc und Freunde können Sie es folgendermaßen definieren:Zweitens sollten Sie Ihr Benchmark-Programm mehrmals ausführen und nur die beste Leistung verwenden . In modernen Betriebssystemen passieren viele Dinge parallel, die CPU befindet sich möglicherweise in einem Niederfrequenz-Energiesparmodus usw. Wenn Sie das Programm wiederholt ausführen, erhalten Sie ein Ergebnis, das dem Idealfall näher kommt.
quelle
addsd
's undmulsd
' s oder sind sie in Gruppen wie in meiner Assembly-Ausgabe? Ich bekomme auch nur ungefähr 1 Flop / Zyklus, wenn der Compiler sie mischt (was ich ohne bekomme-march=native
). Wie ändert sich die Leistung, wenn Sieadd=mul;
am Anfang der Funktion eine Zeile hinzufügenaddmul(...)
?addsd
undsubsd
Anweisungen sind in der Tat in der genauen Version gemischt. Ich habe auch Clang 3.0 ausprobiert, es mischt keine Anweisungen und es kommt 2 Flops / Zyklus auf dem Core 2 Duo sehr nahe. Wenn ich denselben Code auf meinem Laptop Core i5 ausführe, macht das Mischen des Codes keinen Unterschied. Ich bekomme in jedem Fall ungefähr 3 Flops / Zyklus.icc
die Baugruppe überprüfen?