Comment atteindre les performances théoriques maximales de 4 opérations en virgule flottante (double précision) par cycle sur un processeur Intel x86-64 moderne?
Autant que je sache, cela prend trois cycles pour un SSE add
et cinq cycles pour un mul
pour terminer sur la plupart des processeurs Intel modernes (voir par exemple les «Tables d'instructions» d'Agner Fog ). En raison du pipelining, on peut obtenir un débit d'un add
par cycle si l'algorithme a au moins trois sommations indépendantes. Comme cela est vrai pour addpd
les addsd
versions compressées ainsi que les versions scalaires et les registres SSE peuvent en contenir deux double
, le débit peut atteindre deux flops par cycle.
De plus, il semble (bien que je n'aie pas vu de documentation appropriée à ce sujet) que add
les et mul
puissent être exécutés en parallèle donnant un débit théorique maximum de quatre flops par cycle.
Cependant, je n'ai pas pu reproduire ces performances avec un simple programme C / C ++. Ma meilleure tentative a abouti à environ 2,7 flops / cycle. Si quelqu'un peut contribuer à un simple programme C / C ++ ou assembleur qui démontre des performances de pointe, ce serait grandement apprécié.
Ma tentative:
#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;
}
Compilé avec
g++ -O2 -march=native addmul.cpp ; ./a.out 1000
produit la sortie suivante sur un Intel Core i5-750, 2,66 GHz.
addmul: 0.270 s, 3.707 Gflops, res=1.326463
Autrement dit, à peu près 1,4 flops par cycle. Regarder le code assembleur avec
g++ -S -O2 -march=native -masm=intel addmul.cpp
la boucle principale me semble plutôt 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
Modification des versions scalaires avec des versions compressées ( addpd
etmulpd
) doublerait le nombre de flops sans changer le temps d'exécution et j'obtiendrais donc juste 2,8 flops par cycle. Existe-t-il un exemple simple qui réalise quatre flops par cycle?
Joli petit programme de Mysticial; voici mes résultats (exécutez juste pendant quelques secondes):
gcc -O2 -march=nocona
: 5,6 Gflops sur 10,66 Gflops (2,1 flops / cycle)cl /O2
, openmp supprimé: 10,1 Gflops sur 10,66 Gflops (3,8 flops / cycle)
Tout cela semble un peu complexe, mais mes conclusions jusqu'à présent:
gcc -O2
change l'ordre des opérations indépendantes en virgule flottante dans le but d'alterneraddpd
etmulpd
si possible. Il en va de même pourgcc-4.6.2 -O2 -march=core2
.gcc -O2 -march=nocona
semble conserver l'ordre des opérations en virgule flottante tel que défini dans la source C ++.cl /O2
, le compilateur 64 bits du SDK pour Windows 7 effectue automatiquement le déroulement des boucles et semble essayer d'organiser les opérations de sorte que des groupes de troisaddpd
alternent avec troismulpd
(enfin, au moins sur mon système et pour mon programme simple) .Mon Core i5 750 ( architecture Nehalem ) n'aime pas alterner les add et les mul et semble incapable d'exécuter les deux opérations en parallèle. Cependant, s'il est groupé en 3, il fonctionne soudainement comme par magie.
D'autres architectures (peut-être Sandy Bridge et d'autres) semblent pouvoir exécuter add / mul en parallèle sans problème si elles alternent dans le code assembleur.
Bien que difficile à admettre, mais sur mon système
cl /O2
fait un bien meilleur travail à des opérations d'optimisation de bas niveau pour mon système et atteint des performances proches du pic pour le petit exemple C ++ ci-dessus. J'ai mesuré entre 1,85-2,01 flops / cycle (j'ai utilisé horloge () dans Windows qui n'est pas si précis. Je suppose, j'ai besoin d'utiliser un meilleur timer - merci Mackie Messer).Le mieux que j'ai réussi à faire
gcc
était de boucler manuellement le déroulement et d'organiser les ajouts et les multiplications en groupes de trois. Avecg++ -O2 -march=nocona addmul_unroll.cpp
j'obtiens au mieux0.207s, 4.825 Gflops
ce qui correspond à 1,8 flops / cycle dont je suis assez content maintenant.
Dans le code C ++, j'ai remplacé la for
boucle par
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;
}
Et l'assemblage ressemble maintenant à
.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
). Vous avez essayé avec gcc version 4.4.1 et 4.6.2, mais la sortie asm semble correcte?
-O3
pour gcc, qui permet -ftree-vectorize
? Peut-être combiné avec -funroll-loops
si je ne le fais pas si c'est vraiment nécessaire. Après tout, la comparaison semble un peu injuste si l'un des compilateurs effectue la vectorisation / le déroulement, tandis que l'autre ne le fait pas parce qu'il ne le peut pas, mais parce qu'il est dit non pas trop.
-funroll-loops
est probablement quelque chose à essayer. Mais je pense que -ftree-vectorize
c'est d'ailleurs le point. L'OP essaie juste de maintenir 1 mul + 1 instruction / cycle d'ajout. Les instructions peuvent être scalaires ou vectorielles - peu importe puisque la latence et le débit sont les mêmes. Donc, si vous pouvez maintenir 2 / cycle avec SSE scalaire, vous pouvez les remplacer par SSE vectoriel et vous obtiendrez 4 flops / cycle. Dans ma réponse, je l'ai fait en passant de SSE -> AVX. J'ai remplacé tous les SSE par AVX - mêmes latences, mêmes débits, 2x les flops.