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
...
la source
-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.Réponses:
J'ai déjà fait cette tâche auparavant. Mais c'était surtout pour mesurer la consommation d'énergie et les températures du CPU. Le code suivant (qui est assez long) est presque optimal sur mon Core i7 2600K.
L'essentiel à noter ici est la quantité massive de déroulage de boucle manuelle ainsi que l'entrelacement de multiplications et d'ajouts ...
Le projet complet peut être trouvé sur mon GitHub: https://github.com/Mysticial/Flops
Attention:
Si vous décidez de compiler et d'exécuter ceci, faites attention à la température de votre CPU !!!
Assurez-vous de ne pas le surchauffer. Et assurez-vous que la limitation du processeur n'affecte pas vos résultats!
De plus, je n'assume aucune responsabilité pour tout dommage pouvant résulter de l'exécution de ce code.
Remarques:
ICC 11 (Intel Compiler 11) a étonnamment du mal à bien le compiler.
Sortie (1 thread, 10000000 itérations) - Compilé avec Visual Studio 2010 SP1 - Version x64:
La machine est un Core i7 2600K @ 4,4 GHz. Le pic SSE théorique est de 4 flops * 4,4 GHz = 17,6 GFlops . Ce code atteint 17,3 GFlops - pas mal.
Sortie (8 threads, 10000000 itérations) - Compilé avec Visual Studio 2010 SP1 - Version x64:
Le pic SSE théorique est de 4 flops * 4 cœurs * 4,4 GHz = 70,4 GFlops. La valeur réelle est de 65,5 GFlops .
Allons plus loin. AVX ...
Sortie (1 thread, 10000000 itérations) - Compilé avec Visual Studio 2010 SP1 - Version x64:
Le pic AVX théorique est de 8 flops * 4,4 GHz = 35,2 GFlops . La valeur réelle est de 33,4 GFlops .
Sortie (8 threads, 10000000 itérations) - Compilé avec Visual Studio 2010 SP1 - Version x64:
Le pic AVX théorique est de 8 flops * 4 cœurs * 4,4 GHz = 140,8 GFlops. La valeur réelle est de 138,2 GFlops .
Maintenant, pour quelques explications:
La partie critique de performance est évidemment les 48 instructions à l'intérieur de la boucle intérieure. Vous remarquerez qu'il est divisé en 4 blocs de 12 instructions chacun. Chacun de ces 12 blocs d'instructions est complètement indépendant les uns des autres - et prend en moyenne 6 cycles pour s'exécuter.
Il y a donc 12 instructions et 6 cycles entre l'émission et l'utilisation. La latence de la multiplication est de 5 cycles, donc c'est juste suffisant pour éviter les blocages de latence.
L'étape de normalisation est nécessaire pour éviter que les données ne débordent / ne débordent. Cela est nécessaire car le code de ne rien faire augmentera / diminuera lentement la magnitude des données.
Il est donc possible de faire mieux que cela si vous utilisez simplement tous les zéros et vous débarrassez de l'étape de normalisation. Cependant, depuis que j'ai écrit le benchmark pour mesurer la consommation d'énergie et la température, j'ai dû m'assurer que les flops étaient sur des données "réelles", plutôt que sur des zéros - car les unités d'exécution peuvent très bien avoir une gestion de cas spéciale pour les zéros qui utilisent moins d'énergie et produisent moins de chaleur.
Plus de résultats:
Fils: 1
Pic SSE théorique: 4 flops * 3,5 GHz = 14,0 GFlops . La valeur réelle est de 13,3 GFlops .
Fils: 8
Pic SSE théorique: 4 flops * 4 cœurs * 3,5 GHz = 56,0 GFlops . La valeur réelle est de 51,3 GFlops .
La température de mon processeur a atteint 76 ° C sur la course multi-thread! Si vous les exécutez, assurez-vous que les résultats ne sont pas affectés par la limitation du processeur.
Fils: 1
Pic SSE théorique: 4 flops * 3,2 GHz = 12,8 GFlops . La valeur réelle est de 12,3 GFlops .
Fils: 8
Pic SSE théorique: 4 flops * 8 cœurs * 3,2 GHz = 102,4 GFlops . La valeur réelle est de 97,9 GFlops .
la source
1.814s, 5.292 Gflops, sum=0.448883
sur un pic de 10,68 Gflops ou juste à court de 2,0 flops par cycle. Sembleadd
/mul
ne sont pas exécutés en parallèle. Quand je change votre code et que j'ajoute / multiplie toujours avec le même registre, disonsrC
, il atteint soudainement presque le pic:0.953s, 10.068 Gflops, sum=0
ou 3,8 flops / cycle. Très étrange.cl /O2
(64 bits à partir de Windows SDK) et même mon exemple tourne près du pic pour les opérations scalaires (1,9 flops / cycle) là-bas. Le compilateur déroule et réorganise la boucle, mais ce n'est peut-être pas la raison pour laquelle il faut examiner un peu plus cela. La limitation n'est pas un problème, je suis gentil avec mon processeur et je garde les itérations à 100k. :)Il y a un point dans l'architecture Intel que les gens oublient souvent, les ports de répartition sont partagés entre Int et FP / SIMD. Cela signifie que vous n'obtiendrez qu'une certaine quantité de salves de FP / SIMD avant que la logique de boucle ne crée des bulles dans votre flux à virgule flottante. Mystical a obtenu plus de flops de son code, car il a utilisé des pas plus longs dans sa boucle déroulée.
Si vous regardez l'architecture Nehalem / Sandy Bridge ici http://www.realworldtech.com/page.cfm?ArticleID=RWT091810191937&p=6, il est assez clair ce qui se passe.
En revanche, il devrait être plus facile d'atteindre des performances de pointe sur AMD (Bulldozer) car les canaux INT et FP / SIMD ont des ports d'émission séparés avec leur propre ordonnanceur.
Ce n'est que théorique car je n'ai aucun de ces processeurs à tester.
la source
inc
,cmp
etjl
. Tous ces éléments peuvent aller au port n ° 5 et n'interfèrent pas avec vectoriséfadd
oufmul
. Je préfère soupçonner que le décodeur gêne (parfois). Il doit contenir entre deux et trois instructions par cycle. Je ne me souviens pas des limites exactes mais la longueur des instructions, les préfixes et l'alignement entrent tous en jeu.cmp
etjl
certainement aller au port 5,inc
pas si sûr car il vient toujours en groupe avec les 2 autres. Mais vous avez raison, il est difficile de dire où se trouve le goulot d'étranglement et les décodeurs peuvent également en faire partie.Les succursales peuvent certainement vous empêcher de maintenir des performances théoriques maximales. Voyez-vous une différence si vous effectuez manuellement un déroulement de boucle? Par exemple, si vous mettez 5 ou 10 fois plus d'opérations par itération de boucle:
la source
-funroll-loops
option qui n'est même pas incluse dans-O3
. Tu voisg++ -c -Q -O2 --help=optimizers | grep unroll
.Utilisation d'Intels icc version 11.1 sur un Intel Core 2 Duo à 2,4 GHz, je reçois
C'est très proche des 9,6 Gflops idéaux.
ÉDITER:
Oups, en regardant le code d'assemblage, il semble que l'icc ait non seulement vectorisé la multiplication, mais aussi retiré les ajouts de la boucle. Forçant une sémantique fp plus stricte, le code n'est plus vectorisé:
EDIT2:
Comme demandé:
La boucle interne du code de clang ressemble à ceci:
EDIT3:
Enfin, deux suggestions: Premièrement, si vous aimez ce type de benchmarking, pensez à utiliser l'
rdtsc
instruction au lieu degettimeofday(2)
. Il est beaucoup plus précis et fournit le temps en cycles, ce qui est généralement ce qui vous intéresse de toute façon. Pour gcc et amis, vous pouvez le définir comme ceci:Deuxièmement, vous devez exécuter votre programme de référence plusieurs fois et utiliser uniquement les meilleures performances . Dans les systèmes d'exploitation modernes, beaucoup de choses se produisent en parallèle, le processeur peut être en mode d'économie d'énergie à basse fréquence, etc. L'exécution répétée du programme vous donne un résultat plus proche du cas idéal.
la source
addsd
'et lesmulsd
' ou sont-ils en groupes comme dans ma sortie d'assembly? J'obtiens également environ 1 flop / cycle lorsque le compilateur les mélange (ce que je reçois sans-march=native
). Comment les performances changent-elles si vous ajoutez une ligneadd=mul;
au début de la fonctionaddmul(...)
?addsd
etsubsd
sont en effet mélangées dans la version précise. J'ai également essayé le clang 3.0, il ne mélange pas les instructions et il est très proche de 2 flops / cycle sur le duo core 2. Lorsque j'exécute le même code sur le Core i5 de mon ordinateur portable, mélanger le code ne fait aucune différence. Je reçois environ 3 flops / cycle dans les deux cas.icc
auparavant, pouvez-vous vérifier à nouveau l'assemblage?