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
...
-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.