Come è possibile ottenere le massime prestazioni teoriche di 4 operazioni in virgola mobile (doppia precisione) per ciclo su una moderna CPU Intel x86-64?
Per quanto ne so, sono necessari tre cicli per un SSE add
e cinque cicli per un mul
completamento sulla maggior parte delle moderne CPU Intel (vedere ad esempio le "Tabelle di istruzioni" di Agner Fog ). A causa del pipelining si può ottenere un throughput di uno add
per ciclo se l'algoritmo ha almeno tre sommazioni indipendenti. Dal momento che questo è vero sia per addpd
le addsd
versioni impacchettate che per quelle scalari e i registri SSE possono contenere due double
, il throughput può arrivare a due flop per ciclo.
Inoltre, sembra (anche se non ho visto alcuna documentazione adeguata su questo) add
, mul
è possibile eseguire in parallelo, dando un throughput massimo teorico di quattro flop per ciclo.
Tuttavia, non sono stato in grado di replicare quella prestazione con un semplice programma C / C ++. Il mio miglior tentativo ha portato a circa 2,7 flop / ciclo. Se qualcuno può contribuire con un semplice programma C / C ++ o assemblatore che dimostra le massime prestazioni che sarebbero molto apprezzate.
Il mio tentativo:
#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;
}
Compilato con
g++ -O2 -march=native addmul.cpp ; ./a.out 1000
produce il seguente output su un Intel Core i5-750, 2,66 GHz.
addmul: 0.270 s, 3.707 Gflops, res=1.326463
Cioè, solo circa 1,4 flop per ciclo. Guardare il codice assembler con
g++ -S -O2 -march=native -masm=intel addmul.cpp
il ciclo principale mi sembra un po 'ottimale:
.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
Cambiare le versioni scalari con versioni impacchettate ( addpd
e mulpd
) raddoppierebbe il conteggio dei flop senza cambiare i tempi di esecuzione e quindi otterrei appena 2,8 flop per ciclo. C'è un semplice esempio che realizza quattro flop per ciclo?
Bel piccolo programma di Mysticial; ecco i miei risultati (esegui solo per pochi secondi):
gcc -O2 -march=nocona
: 5.6 Gflop su 10.66 Gflop (2.1 flop / ciclo)cl /O2
, openmp rimosso: 10.1 Gflop su 10.66 Gflop (3.8 flop / ciclo)
Sembra tutto un po 'complesso, ma le mie conclusioni finora:
gcc -O2
cambia l'ordine delle operazioni indipendenti in virgola mobile con l'obiettivo di alternareaddpd
emulpd
'se possibile. Lo stesso vale pergcc-4.6.2 -O2 -march=core2
.gcc -O2 -march=nocona
sembra mantenere l'ordine delle operazioni in virgola mobile come definito nell'origine C ++.cl /O2
, il compilatore a 64 bit dell'SDK per Windows 7 esegue automaticamente lo srotolamento del ciclo e sembra tentare di organizzare le operazioni in modo che gruppi di tre siaddpd
alternino a tremulpd
(beh, almeno sul mio sistema e per il mio semplice programma) .Il mio Core i5 750 ( architettura Nehalem ) non ama alternare add e mul e sembra incapace di eseguire entrambe le operazioni in parallelo. Tuttavia, se raggruppato in 3, all'improvviso funziona come per magia.
Altre architetture (possibilmente Sandy Bridge e altre) sembrano essere in grado di eseguire add / mul in parallelo senza problemi se si alternano nel codice assembly.
Anche se difficile da ammettere, ma sul mio sistema
cl /O2
fa un lavoro molto migliore con operazioni di ottimizzazione a basso livello per il mio sistema e raggiunge prestazioni quasi al massimo per il piccolo esempio C ++ sopra. Ho misurato tra 1,85-2,01 flop / ciclo (ho usato clock () in Windows che non è così preciso. Immagino, devo usare un timer migliore - grazie Mackie Messer).Il migliore con cui sono riuscito è
gcc
stato quello di srotolare manualmente e organizzare aggiunte e moltiplicazioni in gruppi di tre. Cong++ -O2 -march=nocona addmul_unroll.cpp
ottengo nella migliore delle ipotesi0.207s, 4.825 Gflops
che corrisponde a 1,8 flop / ciclo di cui ora sono abbastanza contento.
Nel codice C ++ ho sostituito il for
ciclo con
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;
}
E l'assemblaggio ora sembra
.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
). Provato con gcc versione 4.4.1 e 4.6.2, ma l'output di ASM sembra ok?
-O3
per gcc, che abilita -ftree-vectorize
? Forse combinato con -funroll-loops
sebbene non lo faccia se ciò è veramente necessario. Dopotutto il confronto sembra un po 'ingiusto se uno dei compilatori esegue la vettorializzazione / srotolamento, mentre l'altro non lo fa perché non può, ma perché non viene detto anche.
-funroll-loops
è probabilmente qualcosa da provare. Ma penso che -ftree-vectorize
sia oltre il punto. L'OP sta solo cercando di sostenere 1 mul + 1 aggiungere istruzioni / ciclo. Le istruzioni possono essere scalari o vettoriali - non importa poiché la latenza e la velocità effettiva sono le stesse. Quindi se puoi sostenere 2 / ciclo con SSE scalare, puoi sostituirli con SSE vettoriale e otterrai 4 flop / ciclo. Nella mia risposta ho fatto proprio questo andando da SSE -> AVX. Ho sostituito tutto l'SSE con AVX: stesse latenze, stessi throughput, 2x i flop.