Comment atteindre le maximum théorique de 4 FLOP par cycle?

643

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'alterner addpdet mulpdsi possible. Il en va de même pour gcc-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 trois addpdalternent avec trois mulpd(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. Avec g++ -O2 -march=nocona addmul_unroll.cpp j'obtiens au mieux 0.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
...
user1059432
la source
15
S'appuyer sur l'heure de l'horloge murale est probablement une partie de la cause. En supposant que vous exécutez cela à l'intérieur d'un système d'exploitation comme Linux, il est libre de planifier votre processus à tout moment. Ce type d'événement externe peut avoir un impact sur vos mesures de performances.
tdenniston
Quelle est votre version GCC? Si vous êtes sur un Mac en utilisant la valeur par défaut, vous rencontrerez des problèmes (c'est une ancienne version 4.2).
demi
2
Oui sous Linux mais il n'y a pas de charge sur le système et le répéter plusieurs fois fait peu de différences (par exemple, gammes 4.0-4.2 Gflops pour la version scalaire, mais maintenant avec -funroll-loops). Vous avez essayé avec gcc version 4.4.1 et 4.6.2, mais la sortie asm semble correcte?
user1059432
Avez-vous essayé -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.
Grizzly
4
@Grizzly -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.
Mysticial

Réponses:

518

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:

  • Ce code est optimisé pour x64. x86 n'a pas assez de registres pour que cela compile bien.
  • Ce code a été testé pour fonctionner correctement sur Visual Studio 2010/2012 et GCC 4.6.
    ICC 11 (Intel Compiler 11) a étonnamment du mal à bien le compiler.
  • Ce sont pour les processeurs pré-FMA. Afin d'atteindre des FLOPS de pointe sur les processeurs Intel Haswell et AMD Bulldozer (et versions ultérieures), des instructions FMA (Fused Multiply Add) seront nécessaires. Celles-ci dépassent le cadre de cette référence.

#include <emmintrin.h>
#include <omp.h>
#include <iostream>
using namespace std;

typedef unsigned long long uint64;

double test_dp_mac_SSE(double x,double y,uint64 iterations){
    register __m128d r0,r1,r2,r3,r4,r5,r6,r7,r8,r9,rA,rB,rC,rD,rE,rF;

    //  Generate starting data.
    r0 = _mm_set1_pd(x);
    r1 = _mm_set1_pd(y);

    r8 = _mm_set1_pd(-0.0);

    r2 = _mm_xor_pd(r0,r8);
    r3 = _mm_or_pd(r0,r8);
    r4 = _mm_andnot_pd(r8,r0);
    r5 = _mm_mul_pd(r1,_mm_set1_pd(0.37796447300922722721));
    r6 = _mm_mul_pd(r1,_mm_set1_pd(0.24253562503633297352));
    r7 = _mm_mul_pd(r1,_mm_set1_pd(4.1231056256176605498));
    r8 = _mm_add_pd(r0,_mm_set1_pd(0.37796447300922722721));
    r9 = _mm_add_pd(r1,_mm_set1_pd(0.24253562503633297352));
    rA = _mm_sub_pd(r0,_mm_set1_pd(4.1231056256176605498));
    rB = _mm_sub_pd(r1,_mm_set1_pd(4.1231056256176605498));

    rC = _mm_set1_pd(1.4142135623730950488);
    rD = _mm_set1_pd(1.7320508075688772935);
    rE = _mm_set1_pd(0.57735026918962576451);
    rF = _mm_set1_pd(0.70710678118654752440);

    uint64 iMASK = 0x800fffffffffffffull;
    __m128d MASK = _mm_set1_pd(*(double*)&iMASK);
    __m128d vONE = _mm_set1_pd(1.0);

    uint64 c = 0;
    while (c < iterations){
        size_t i = 0;
        while (i < 1000){
            //  Here's the meat - the part that really matters.

            r0 = _mm_mul_pd(r0,rC);
            r1 = _mm_add_pd(r1,rD);
            r2 = _mm_mul_pd(r2,rE);
            r3 = _mm_sub_pd(r3,rF);
            r4 = _mm_mul_pd(r4,rC);
            r5 = _mm_add_pd(r5,rD);
            r6 = _mm_mul_pd(r6,rE);
            r7 = _mm_sub_pd(r7,rF);
            r8 = _mm_mul_pd(r8,rC);
            r9 = _mm_add_pd(r9,rD);
            rA = _mm_mul_pd(rA,rE);
            rB = _mm_sub_pd(rB,rF);

            r0 = _mm_add_pd(r0,rF);
            r1 = _mm_mul_pd(r1,rE);
            r2 = _mm_sub_pd(r2,rD);
            r3 = _mm_mul_pd(r3,rC);
            r4 = _mm_add_pd(r4,rF);
            r5 = _mm_mul_pd(r5,rE);
            r6 = _mm_sub_pd(r6,rD);
            r7 = _mm_mul_pd(r7,rC);
            r8 = _mm_add_pd(r8,rF);
            r9 = _mm_mul_pd(r9,rE);
            rA = _mm_sub_pd(rA,rD);
            rB = _mm_mul_pd(rB,rC);

            r0 = _mm_mul_pd(r0,rC);
            r1 = _mm_add_pd(r1,rD);
            r2 = _mm_mul_pd(r2,rE);
            r3 = _mm_sub_pd(r3,rF);
            r4 = _mm_mul_pd(r4,rC);
            r5 = _mm_add_pd(r5,rD);
            r6 = _mm_mul_pd(r6,rE);
            r7 = _mm_sub_pd(r7,rF);
            r8 = _mm_mul_pd(r8,rC);
            r9 = _mm_add_pd(r9,rD);
            rA = _mm_mul_pd(rA,rE);
            rB = _mm_sub_pd(rB,rF);

            r0 = _mm_add_pd(r0,rF);
            r1 = _mm_mul_pd(r1,rE);
            r2 = _mm_sub_pd(r2,rD);
            r3 = _mm_mul_pd(r3,rC);
            r4 = _mm_add_pd(r4,rF);
            r5 = _mm_mul_pd(r5,rE);
            r6 = _mm_sub_pd(r6,rD);
            r7 = _mm_mul_pd(r7,rC);
            r8 = _mm_add_pd(r8,rF);
            r9 = _mm_mul_pd(r9,rE);
            rA = _mm_sub_pd(rA,rD);
            rB = _mm_mul_pd(rB,rC);

            i++;
        }

        //  Need to renormalize to prevent denormal/overflow.
        r0 = _mm_and_pd(r0,MASK);
        r1 = _mm_and_pd(r1,MASK);
        r2 = _mm_and_pd(r2,MASK);
        r3 = _mm_and_pd(r3,MASK);
        r4 = _mm_and_pd(r4,MASK);
        r5 = _mm_and_pd(r5,MASK);
        r6 = _mm_and_pd(r6,MASK);
        r7 = _mm_and_pd(r7,MASK);
        r8 = _mm_and_pd(r8,MASK);
        r9 = _mm_and_pd(r9,MASK);
        rA = _mm_and_pd(rA,MASK);
        rB = _mm_and_pd(rB,MASK);
        r0 = _mm_or_pd(r0,vONE);
        r1 = _mm_or_pd(r1,vONE);
        r2 = _mm_or_pd(r2,vONE);
        r3 = _mm_or_pd(r3,vONE);
        r4 = _mm_or_pd(r4,vONE);
        r5 = _mm_or_pd(r5,vONE);
        r6 = _mm_or_pd(r6,vONE);
        r7 = _mm_or_pd(r7,vONE);
        r8 = _mm_or_pd(r8,vONE);
        r9 = _mm_or_pd(r9,vONE);
        rA = _mm_or_pd(rA,vONE);
        rB = _mm_or_pd(rB,vONE);

        c++;
    }

    r0 = _mm_add_pd(r0,r1);
    r2 = _mm_add_pd(r2,r3);
    r4 = _mm_add_pd(r4,r5);
    r6 = _mm_add_pd(r6,r7);
    r8 = _mm_add_pd(r8,r9);
    rA = _mm_add_pd(rA,rB);

    r0 = _mm_add_pd(r0,r2);
    r4 = _mm_add_pd(r4,r6);
    r8 = _mm_add_pd(r8,rA);

    r0 = _mm_add_pd(r0,r4);
    r0 = _mm_add_pd(r0,r8);


    //  Prevent Dead Code Elimination
    double out = 0;
    __m128d temp = r0;
    out += ((double*)&temp)[0];
    out += ((double*)&temp)[1];

    return out;
}

void test_dp_mac_SSE(int tds,uint64 iterations){

    double *sum = (double*)malloc(tds * sizeof(double));
    double start = omp_get_wtime();

#pragma omp parallel num_threads(tds)
    {
        double ret = test_dp_mac_SSE(1.1,2.1,iterations);
        sum[omp_get_thread_num()] = ret;
    }

    double secs = omp_get_wtime() - start;
    uint64 ops = 48 * 1000 * iterations * tds * 2;
    cout << "Seconds = " << secs << endl;
    cout << "FP Ops  = " << ops << endl;
    cout << "FLOPs   = " << ops / secs << endl;

    double out = 0;
    int c = 0;
    while (c < tds){
        out += sum[c++];
    }

    cout << "sum = " << out << endl;
    cout << endl;

    free(sum);
}

int main(){
    //  (threads, iterations)
    test_dp_mac_SSE(8,10000000);

    system("pause");
}

Sortie (1 thread, 10000000 itérations) - Compilé avec Visual Studio 2010 SP1 - Version x64:

Seconds = 55.5104
FP Ops  = 960000000000
FLOPs   = 1.7294e+010
sum = 2.22652

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:

Seconds = 117.202
FP Ops  = 7680000000000
FLOPs   = 6.55279e+010
sum = 17.8122

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

#include <immintrin.h>
#include <omp.h>
#include <iostream>
using namespace std;

typedef unsigned long long uint64;

double test_dp_mac_AVX(double x,double y,uint64 iterations){
    register __m256d r0,r1,r2,r3,r4,r5,r6,r7,r8,r9,rA,rB,rC,rD,rE,rF;

    //  Generate starting data.
    r0 = _mm256_set1_pd(x);
    r1 = _mm256_set1_pd(y);

    r8 = _mm256_set1_pd(-0.0);

    r2 = _mm256_xor_pd(r0,r8);
    r3 = _mm256_or_pd(r0,r8);
    r4 = _mm256_andnot_pd(r8,r0);
    r5 = _mm256_mul_pd(r1,_mm256_set1_pd(0.37796447300922722721));
    r6 = _mm256_mul_pd(r1,_mm256_set1_pd(0.24253562503633297352));
    r7 = _mm256_mul_pd(r1,_mm256_set1_pd(4.1231056256176605498));
    r8 = _mm256_add_pd(r0,_mm256_set1_pd(0.37796447300922722721));
    r9 = _mm256_add_pd(r1,_mm256_set1_pd(0.24253562503633297352));
    rA = _mm256_sub_pd(r0,_mm256_set1_pd(4.1231056256176605498));
    rB = _mm256_sub_pd(r1,_mm256_set1_pd(4.1231056256176605498));

    rC = _mm256_set1_pd(1.4142135623730950488);
    rD = _mm256_set1_pd(1.7320508075688772935);
    rE = _mm256_set1_pd(0.57735026918962576451);
    rF = _mm256_set1_pd(0.70710678118654752440);

    uint64 iMASK = 0x800fffffffffffffull;
    __m256d MASK = _mm256_set1_pd(*(double*)&iMASK);
    __m256d vONE = _mm256_set1_pd(1.0);

    uint64 c = 0;
    while (c < iterations){
        size_t i = 0;
        while (i < 1000){
            //  Here's the meat - the part that really matters.

            r0 = _mm256_mul_pd(r0,rC);
            r1 = _mm256_add_pd(r1,rD);
            r2 = _mm256_mul_pd(r2,rE);
            r3 = _mm256_sub_pd(r3,rF);
            r4 = _mm256_mul_pd(r4,rC);
            r5 = _mm256_add_pd(r5,rD);
            r6 = _mm256_mul_pd(r6,rE);
            r7 = _mm256_sub_pd(r7,rF);
            r8 = _mm256_mul_pd(r8,rC);
            r9 = _mm256_add_pd(r9,rD);
            rA = _mm256_mul_pd(rA,rE);
            rB = _mm256_sub_pd(rB,rF);

            r0 = _mm256_add_pd(r0,rF);
            r1 = _mm256_mul_pd(r1,rE);
            r2 = _mm256_sub_pd(r2,rD);
            r3 = _mm256_mul_pd(r3,rC);
            r4 = _mm256_add_pd(r4,rF);
            r5 = _mm256_mul_pd(r5,rE);
            r6 = _mm256_sub_pd(r6,rD);
            r7 = _mm256_mul_pd(r7,rC);
            r8 = _mm256_add_pd(r8,rF);
            r9 = _mm256_mul_pd(r9,rE);
            rA = _mm256_sub_pd(rA,rD);
            rB = _mm256_mul_pd(rB,rC);

            r0 = _mm256_mul_pd(r0,rC);
            r1 = _mm256_add_pd(r1,rD);
            r2 = _mm256_mul_pd(r2,rE);
            r3 = _mm256_sub_pd(r3,rF);
            r4 = _mm256_mul_pd(r4,rC);
            r5 = _mm256_add_pd(r5,rD);
            r6 = _mm256_mul_pd(r6,rE);
            r7 = _mm256_sub_pd(r7,rF);
            r8 = _mm256_mul_pd(r8,rC);
            r9 = _mm256_add_pd(r9,rD);
            rA = _mm256_mul_pd(rA,rE);
            rB = _mm256_sub_pd(rB,rF);

            r0 = _mm256_add_pd(r0,rF);
            r1 = _mm256_mul_pd(r1,rE);
            r2 = _mm256_sub_pd(r2,rD);
            r3 = _mm256_mul_pd(r3,rC);
            r4 = _mm256_add_pd(r4,rF);
            r5 = _mm256_mul_pd(r5,rE);
            r6 = _mm256_sub_pd(r6,rD);
            r7 = _mm256_mul_pd(r7,rC);
            r8 = _mm256_add_pd(r8,rF);
            r9 = _mm256_mul_pd(r9,rE);
            rA = _mm256_sub_pd(rA,rD);
            rB = _mm256_mul_pd(rB,rC);

            i++;
        }

        //  Need to renormalize to prevent denormal/overflow.
        r0 = _mm256_and_pd(r0,MASK);
        r1 = _mm256_and_pd(r1,MASK);
        r2 = _mm256_and_pd(r2,MASK);
        r3 = _mm256_and_pd(r3,MASK);
        r4 = _mm256_and_pd(r4,MASK);
        r5 = _mm256_and_pd(r5,MASK);
        r6 = _mm256_and_pd(r6,MASK);
        r7 = _mm256_and_pd(r7,MASK);
        r8 = _mm256_and_pd(r8,MASK);
        r9 = _mm256_and_pd(r9,MASK);
        rA = _mm256_and_pd(rA,MASK);
        rB = _mm256_and_pd(rB,MASK);
        r0 = _mm256_or_pd(r0,vONE);
        r1 = _mm256_or_pd(r1,vONE);
        r2 = _mm256_or_pd(r2,vONE);
        r3 = _mm256_or_pd(r3,vONE);
        r4 = _mm256_or_pd(r4,vONE);
        r5 = _mm256_or_pd(r5,vONE);
        r6 = _mm256_or_pd(r6,vONE);
        r7 = _mm256_or_pd(r7,vONE);
        r8 = _mm256_or_pd(r8,vONE);
        r9 = _mm256_or_pd(r9,vONE);
        rA = _mm256_or_pd(rA,vONE);
        rB = _mm256_or_pd(rB,vONE);

        c++;
    }

    r0 = _mm256_add_pd(r0,r1);
    r2 = _mm256_add_pd(r2,r3);
    r4 = _mm256_add_pd(r4,r5);
    r6 = _mm256_add_pd(r6,r7);
    r8 = _mm256_add_pd(r8,r9);
    rA = _mm256_add_pd(rA,rB);

    r0 = _mm256_add_pd(r0,r2);
    r4 = _mm256_add_pd(r4,r6);
    r8 = _mm256_add_pd(r8,rA);

    r0 = _mm256_add_pd(r0,r4);
    r0 = _mm256_add_pd(r0,r8);

    //  Prevent Dead Code Elimination
    double out = 0;
    __m256d temp = r0;
    out += ((double*)&temp)[0];
    out += ((double*)&temp)[1];
    out += ((double*)&temp)[2];
    out += ((double*)&temp)[3];

    return out;
}

void test_dp_mac_AVX(int tds,uint64 iterations){

    double *sum = (double*)malloc(tds * sizeof(double));
    double start = omp_get_wtime();

#pragma omp parallel num_threads(tds)
    {
        double ret = test_dp_mac_AVX(1.1,2.1,iterations);
        sum[omp_get_thread_num()] = ret;
    }

    double secs = omp_get_wtime() - start;
    uint64 ops = 48 * 1000 * iterations * tds * 4;
    cout << "Seconds = " << secs << endl;
    cout << "FP Ops  = " << ops << endl;
    cout << "FLOPs   = " << ops / secs << endl;

    double out = 0;
    int c = 0;
    while (c < tds){
        out += sum[c++];
    }

    cout << "sum = " << out << endl;
    cout << endl;

    free(sum);
}

int main(){
    //  (threads, iterations)
    test_dp_mac_AVX(8,10000000);

    system("pause");
}

Sortie (1 thread, 10000000 itérations) - Compilé avec Visual Studio 2010 SP1 - Version x64:

Seconds = 57.4679
FP Ops  = 1920000000000
FLOPs   = 3.34099e+010
sum = 4.45305

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:

Seconds = 111.119
FP Ops  = 15360000000000
FLOPs   = 1.3823e+011
sum = 35.6244

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:

  • Intel Core i7 920 à 3,5 GHz
  • Windows 7 Ultimate x64
  • Visual Studio 2010 SP1 - Version x64

Fils: 1

Seconds = 72.1116
FP Ops  = 960000000000
FLOPs   = 1.33127e+010
sum = 2.22652

Pic SSE théorique: 4 flops * 3,5 GHz = 14,0 GFlops . La valeur réelle est de 13,3 GFlops .

Fils: 8

Seconds = 149.576
FP Ops  = 7680000000000
FLOPs   = 5.13452e+010
sum = 17.8122

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.


  • 2 x Intel Xeon X5482 Harpertown à 3,2 GHz
  • Ubuntu Linux 10 x64
  • GCC 4.5.2 x64 - (-O2 -msse3 -fopenmp)

Fils: 1

Seconds = 78.3357
FP Ops  = 960000000000
FLOPs   = 1.22549e+10
sum = 2.22652

Pic SSE théorique: 4 flops * 3,2 GHz = 12,8 GFlops . La valeur réelle est de 12,3 GFlops .

Fils: 8

Seconds = 78.4733
FP Ops  = 7680000000000
FLOPs   = 9.78676e+10
sum = 17.8122

Pic SSE théorique: 4 flops * 8 cœurs * 3,2 GHz = 102,4 GFlops . La valeur réelle est de 97,9 GFlops .

Mysticial
la source
13
Vos résultats sont très impressionnants. J'ai compilé votre code avec g ++ sur mon ancien système, mais je n'obtiens pas de résultats aussi bons: 100k itérations, 1.814s, 5.292 Gflops, sum=0.448883sur un pic de 10,68 Gflops ou juste à court de 2,0 flops par cycle. Semble add/ mulne sont pas exécutés en parallèle. Quand je change votre code et que j'ajoute / multiplie toujours avec le même registre, disons rC, il atteint soudainement presque le pic: 0.953s, 10.068 Gflops, sum=0ou 3,8 flops / cycle. Très étrange.
user1059432
11
Oui, puisque je n'utilise pas d'assemblage en ligne, les performances sont en effet très sensibles au compilateur. Le code que j'ai ici a été réglé pour VC2010. Et si je me souviens bien, le compilateur Intel donne des résultats tout aussi bons. Comme vous l'avez remarqué, vous devrez peut-être le modifier un peu pour qu'il soit bien compilé.
Mysticial
8
Je peux confirmer vos résultats sur Windows 7 en utilisant 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. :)
user1059432
6
@Mysticial: Il est apparu sur le subreddit r / codage aujourd'hui.
greyfade
2
@haylem Ça fond ou ça décolle. Jamais les deux. S'il y a suffisamment de refroidissement, il y aura du temps d'antenne. Sinon, ça fond juste. :)
Mysticial
33

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.

Patrick Schlüter
la source
2
Il n'y a que trois instructions de boucle en tête: inc, cmpet jl. Tous ces éléments peuvent aller au port n ° 5 et n'interfèrent pas avec vectorisé faddou fmul. 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.
Mackie Messer
cmpet jlcertainement aller au port 5, incpas 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.
Patrick Schlüter
3
J'ai joué un peu avec la boucle de base: l'ordre des instructions est important. Certains arrangements prennent 13 cycles au lieu des 5 cycles minimaux. Il est temps de regarder les compteurs d'événements de performance, je suppose ...
Mackie Messer
16

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:

for(int i=0; i<loops/5; i++) {
      mul1*=mul; mul2*=mul; mul3*=mul; mul4*=mul; mul5*=mul;
      sum1+=add; sum2+=add; sum3+=add; sum4+=add; sum5+=add;
      mul1*=mul; mul2*=mul; mul3*=mul; mul4*=mul; mul5*=mul;
      sum1+=add; sum2+=add; sum3+=add; sum4+=add; sum5+=add;
      mul1*=mul; mul2*=mul; mul3*=mul; mul4*=mul; mul5*=mul;
      sum1+=add; sum2+=add; sum3+=add; sum4+=add; sum5+=add;
      mul1*=mul; mul2*=mul; mul3*=mul; mul4*=mul; mul5*=mul;
      sum1+=add; sum2+=add; sum3+=add; sum4+=add; sum5+=add;
      mul1*=mul; mul2*=mul; mul3*=mul; mul4*=mul; mul5*=mul;
      sum1+=add; sum2+=add; sum3+=add; sum4+=add; sum5+=add;
   }
TJD
la source
4
Je peux me tromper, mais je crois que g ++ avec -O2 tentera de dérouler automatiquement la boucle (je pense qu'il utilise le périphérique de Duff).
Weaver
6
Oui, merci en effet, cela s'améliore quelque peu. J'obtiens maintenant environ 4,1-4,3 Gflops, ou 1,55 flops par cycle. Et non, dans cet exemple, -O2 n'a pas déroulé en boucle.
user1059432
1
Weaver a raison au sujet du déroulement de la boucle, je crois. Il n'est donc probablement pas nécessaire de dérouler manuellement
jim mcnamara
5
Voir la sortie de l'assemblage ci-dessus, il n'y a aucun signe de déroulement de la boucle.
user1059432
14
Le déroulement automatique s'améliore également à une moyenne de 4,2 Gflops, mais nécessite une -funroll-loopsoption qui n'est même pas incluse dans -O3. Tu vois g++ -c -Q -O2 --help=optimizers | grep unroll.
user1059432
7

Utilisation d'Intels icc version 11.1 sur un Intel Core 2 Duo à 2,4 GHz, je reçois

Macintosh:~ mackie$ icc -O3 -mssse3 -oaddmul addmul.cc && ./addmul 1000
addmul:  0.105 s, 9.525 Gflops, res=0.000000
Macintosh:~ mackie$ icc -v
Version 11.1 

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é:

Macintosh:~ mackie$ icc -O3 -mssse3 -oaddmul addmul.cc -fp-model precise && ./addmul 1000
addmul:  0.516 s, 1.938 Gflops, res=1.326463

EDIT2:

Comme demandé:

Macintosh:~ mackie$ clang -O3 -mssse3 -oaddmul addmul.cc && ./addmul 1000
addmul:  0.209 s, 4.786 Gflops, res=1.326463
Macintosh:~ mackie$ clang -v
Apple clang version 3.0 (tags/Apple/clang-211.10.1) (based on LLVM 3.0svn)
Target: x86_64-apple-darwin11.2.0
Thread model: posix

La boucle interne du code de clang ressemble à ceci:

        .align  4, 0x90
LBB2_4:                                 ## =>This Inner Loop Header: Depth=1
        addsd   %xmm2, %xmm3
        addsd   %xmm2, %xmm14
        addsd   %xmm2, %xmm5
        addsd   %xmm2, %xmm1
        addsd   %xmm2, %xmm4
        mulsd   %xmm2, %xmm0
        mulsd   %xmm2, %xmm6
        mulsd   %xmm2, %xmm7
        mulsd   %xmm2, %xmm11
        mulsd   %xmm2, %xmm13
        incl    %eax
        cmpl    %r14d, %eax
        jl      LBB2_4

EDIT3:

Enfin, deux suggestions: Premièrement, si vous aimez ce type de benchmarking, pensez à utiliser l' rdtscinstruction au lieu de gettimeofday(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:

#include <stdint.h>

static __inline__ uint64_t rdtsc(void)
{
        uint64_t rval;
        __asm__ volatile ("rdtsc" : "=A" (rval));
        return rval;
}

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.

Mackie Messer
la source
2
et à quoi ressemble le démontage?
Bahbar
1
Intéressant, c'est moins d'un flop / cycle. Le compilateur mélange-t-il les addsd'et les mulsd' 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 ligne add=mul;au début de la fonction addmul(...)?
user1059432
1
@ user1059432: Les instructions addsdet subsdsont 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.
Mackie Messer
1
@ user1059432: En fin de compte, il s'agit de faire en sorte que le compilateur génère du code "significatif" pour un benchmark synthétique. C'est plus difficile qu'il n'y paraît à première vue. (c.-à-d. icc surclasse votre benchmark) Si tout ce que vous voulez est d'exécuter du code à 4 flops / cycle, la chose la plus simple est d'écrire une petite boucle d'assemblage. Beaucoup moins de tête. :-)
Mackie Messer
1
Ok, donc vous obtenez près de 2 flops / cycle avec un code assembleur similaire à ce que j'ai cité ci-dessus? À quelle distance de 2? Je reçois seulement 1,4, ce qui est important. Je ne pense pas que vous obtenez 3 flops / cycle sur votre ordinateur portable à moins que le compilateur n'optimise comme vous l'avez vu iccauparavant, pouvez-vous vérifier à nouveau l'assemblage?
user1059432