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 mulpour 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 addpar cycle si l'algorithme a au moins trois sommations indépendantes. Comme cela est vrai pour addpdles addsdversions 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 addles et mulpuissent ê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.cppla 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 ( addpdetmulpd ) 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 -O2change l'ordre des opérations indépendantes en virgule flottante dans le but d'alterneraddpdetmulpdsi possible. Il en va de même pourgcc-4.6.2 -O2 -march=core2.gcc -O2 -march=noconasemble 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 troisaddpdalternent 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 /O2fait 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.cppj'obtiens au mieux0.207s, 4.825 Gflopsce qui correspond à 1,8 flops / cycle dont je suis assez content maintenant.
Dans le code C ++, j'ai remplacé la forboucle 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?
-O3pour gcc, qui permet -ftree-vectorize? Peut-être combiné avec -funroll-loopssi 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-loopsest probablement quelque chose à essayer. Mais je pense que -ftree-vectorizec'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.