Pregunta ¿Cómo logro el máximo teórico de 4 FLOP por ciclo?


¿Cómo se puede lograr el máximo rendimiento teórico de 4 operaciones de coma flotante (precisión doble) por ciclo en una moderna CPU Intel x86-64?

Por lo que yo entiendo, tomar tres ciclos para un SSE  add y cinco ciclos por un mul para completar en la mayoría de las CPU Intel modernas (ver por ejemplo 'Tablas de instrucciones' de Agner Fog ) Debido a la canalización uno puede obtener un rendimiento de uno add por ciclo si el algoritmo tiene al menos tres sumas independientes. Dado que eso es cierto para empacado addpd así como el escalar addsd versiones y registros SSE pueden contener dos doubleEl rendimiento puede ser tanto como dos fracasos por ciclo.

Además, parece (aunque no he visto ninguna documentación adecuada sobre esto) addy mul's se puede ejecutar en paralelo dando un rendimiento máximo teórico de cuatro fracasos por ciclo.

Sin embargo, no he podido replicar ese rendimiento con un simple programa C / C ++. Mi mejor intento resultó en 2.7 flops / ciclo. Si alguien puede contribuir con un simple C / C ++ o programa ensamblador que demuestre un rendimiento máximo que sería muy apreciado.

Mi intento:

#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;
}

Compilado con

g++ -O2 -march=native addmul.cpp ; ./a.out 1000

produce la siguiente salida en un Intel Core i5-750, 2.66 GHz.

addmul:  0.270 s, 3.707 Gflops, res=1.326463

Es decir, solo 1,4 flops por ciclo. Mirando el código ensamblador con g++ -S -O2 -march=native -masm=intel addmul.cpp el ciclo principal parece una especie de óptimo para mí:

.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

Cambiar las versiones escalares con versiones empaquetadas (addpd y mulpd) doblaría el conteo del flop sin cambiar el tiempo de ejecución y me quedaría corto de 2.8 flops por ciclo. ¿Hay un ejemplo simple que logra cuatro fracasos por ciclo?

Bonito y pequeño programa de Mysticial; aquí están mis resultados (aunque solo se ejecutan unos segundos):

  • gcc -O2 -march=nocona: 5.6 Gflops de 10.66 Gflops (2.1 fracasos / ciclo)
  • cl /O2, openmp eliminado: 10.1 Gflops de 10.66 Gflops (3.8 flops / ciclo)

Todo parece un poco complejo, pero mis conclusiones hasta ahora:

  • gcc -O2 cambia el orden de las operaciones independientes de coma flotante con el objetivo de alternar addpd y mulpdEs posible. Lo mismo aplica para gcc-4.6.2 -O2 -march=core2.

  • gcc -O2 -march=nocona parece mantener el orden de las operaciones de punto flotante como se define en la fuente de C ++.

  • cl /O2, el compilador de 64 bits de SDK para Windows 7 se desenrolla automáticamente y parece intentar y organizar las operaciones para que grupos de tres addpdalterna con tres mulpd's (bueno, al menos en mi sistema y para mi programa simple).

  • Mi Core i5 750 (Arquitectura de Nahelem) no le gusta alternar adiciones y mul y parece incapaz para ejecutar ambas operaciones en paralelo. Sin embargo, si se agrupan en 3, de repente funciona como magia.

  • Otras arquitecturas (posiblemente Sandy Bridge y otros) parecen ser capaz de ejecutar add / mul en paralelo sin problemas si se alternan en el código de ensamblaje.

  • Aunque es difícil de admitir, pero en mi sistema cl /O2 hace un trabajo mucho mejor en operaciones de optimización de bajo nivel para mi sistema y logra un rendimiento cercano al máximo para el pequeño ejemplo de C ++ anterior. Medí entre 1.85-2.01 flops / cycle (he usado clock () en Windows que no es tan preciso. Supongo que necesito un temporizador mejor, gracias Mackie Messer).

  • Lo mejor que logré con gcc era desenrollar en bucle manualmente y organizar adiciones y multiplicaciones en grupos de tres. Con g++ -O2 -march=nocona addmul_unroll.cpp Me siento mejor 0.207s, 4.825 Gflops que corresponde a 1.8 flops / ciclo con lo cual estoy bastante feliz ahora.

En el código C ++ he reemplazado el for lazo 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;
   }

Y la asamblea ahora se ve como

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

545
2017-12-05 17:54


origen


Respuestas:


He hecho esta tarea exacta antes. Pero fue principalmente para medir el consumo de energía y las temperaturas de la CPU. El siguiente código (que es bastante largo) logra casi óptimo en mi Core i7 2600K.

La cuestión clave a tener en cuenta aquí es la gran cantidad de desenrollado manual de bucles, así como el entrelazado de multiplicaciones y agrega ...

El proyecto completo se puede encontrar en mi GitHub: https://github.com/Mysticial/Flops

Advertencia:

Si decide compilar y ejecutar esto, ¡preste atención a las temperaturas de su CPU!
Asegúrate de no sobrecalentarlo. ¡Y asegúrese de que la aceleración de la CPU no afecte sus resultados!

Además, no asumo ninguna responsabilidad por el daño que pueda ocasionar la ejecución de este código.

Notas:

  • Este código está optimizado para x64. x86 no tiene suficientes registros para compilar bien.
  • Se ha probado que este código funciona bien en Visual Studio 2010/2012 y GCC 4.6.
    ICC 11 (Intel Compiler 11) sorprendentemente tiene problemas para compilarlo bien.
  • Estos son para procesadores anteriores a FMA. Para alcanzar los FLOPS máximos en los procesadores Intel Haswell y AMD Bulldozer (y más adelante), se necesitarán instrucciones FMA (Fused Multiply Add). Estos están más allá del alcance de este punto de referencia.

#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");
}

Salida (1 hilo, 10000000 iteraciones): compilado con Visual Studio 2010 SP1 - Versión x64:

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

La máquina es Core i7 2600K @ 4.4 GHz. El pico teórico de SSE es 4 fracasos * 4.4 GHz = 17.6 GFlops. Este código logra 17,3 GFlops - no está mal.

Salida (8 hilos, 10000000 iteraciones) - Compilado con Visual Studio 2010 SP1 - Versión x64:

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

El pico teórico de SSE es 4 fracasos * 4 núcleos * 4.4 GHz = 70.4 GFlops. Actual es 65.5 GFlops.


Avancemos un paso más. 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");
}

Salida (1 hilo, 10000000 iteraciones): compilado con Visual Studio 2010 SP1 - Versión x64:

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

El pico AVX teórico es 8 flops * 4.4 GHz = 35.2 GFlops. Actual es 33.4 GFlops.

Salida (8 hilos, 10000000 iteraciones) - Compilado con Visual Studio 2010 SP1 - Versión x64:

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

El pico teórico de AVX es de 8 fracasos * 4 núcleos * 4.4 GHz = 140.8 GFlops. Actual es 138.2 GFlops.


Ahora para algunas explicaciones:

La parte crítica del rendimiento es, obviamente, las 48 instrucciones dentro del ciclo interno. Notarás que está dividido en 4 bloques de 12 instrucciones cada uno. Cada uno de estos 12 bloques de instrucciones es completamente independiente el uno del otro y toma un promedio de 6 ciclos para ejecutarse.

Entonces, hay 12 instrucciones y 6 ciclos entre la emisión y el uso. La latencia de la multiplicación es de 5 ciclos, por lo que es suficiente para evitar los puestos de latencia.

El paso de normalización es necesario para mantener los datos de sobre / desbordamiento. Esto es necesario ya que el código de no hacer nada aumentará / disminuirá lentamente la magnitud de los datos.

Entonces, de hecho, es posible hacerlo mejor si solo utiliza todos los ceros y se deshace del paso de normalización. Sin embargo, desde que escribí el punto de referencia para medir el consumo de energía y la temperatura, Tenía que asegurarme de que los flops estaban en datos "reales", en lugar de ceros - dado que las unidades de ejecución pueden tener un manejo de casos especial para ceros que usan menos energía y producen menos calor.


Más resultados:

  • Intel Core i7 920 @ 3.5 GHz
  • Windows 7 Ultimate x64
  • Visual Studio 2010 SP1 - versión x64

Hilos: 1

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

Pico Teórico SSE: 4 fracasos * 3.5 GHz = 14.0 GFlops. Actual es 13.3 GFlops.

Hilos: 8

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

Pico SSE teórico: 4 fracasos * 4 núcleos * 3.5 GHz = 56.0 GFlops. Actual es 51.3 GFlops.

¡Las temperaturas de mi procesador alcanzan 76C en la ejecución de subprocesos múltiples! Si los ejecuta, asegúrese de que los resultados no se vean afectados por la aceleración de la CPU.


444
2017-12-05 20:43



Hay un punto en la arquitectura de Intel que la gente a menudo olvida, los puertos de despacho se comparten entre Int y FP / SIMD. Esto significa que solo obtendrá una cierta cantidad de ráfagas de FP / SIMD antes de que la lógica de bucle cree burbujas en su flujo de punto flotante. Mystical obtuvo más fracasos de su código, porque usó zancadas más largas en su ciclo desenrollado.

Si miras la arquitectura de Nehalem / Sandy Bridge aquí http://www.realworldtech.com/page.cfm?ArticleID=RWT091810191937&p=6 está bastante claro lo que sucede.

Por el contrario, debería ser más fácil alcanzar el rendimiento máximo en AMD (Bulldozer) ya que las tuberías INT y FP / SIMD tienen puertos de emisión separados con su propio programador.

Esto es solo teórico ya que no tengo ninguno de estos procesadores para probar.


29
2017-12-06 16:05



Las sucursales definitivamente pueden evitar que usted mantenga el rendimiento teórico máximo. ¿Ves una diferencia si manualmente se desenrolla? Por ejemplo, si coloca 5 o 10 veces más operaciones por iteración de bucle:

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;
   }

15
2017-12-05 18:04



Usando la versión 11.1 de Intels icc en un Intel Core 2 Duo de 2.4GHz, me sale

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 

Eso está muy cerca del ideal 9.6 Gflops.

EDITAR:

Vaya, mirando el código de ensamblado, parece que icc no solo vectorizó la multiplicación, sino que también extrajo las adiciones del ciclo. Forzando una semántica fp más estricta, el código ya no se vectoriza:

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

EDIT2:

De acuerdo a lo pedido:

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

El ciclo interno del código de clang se ve así:

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

Finalmente, dos sugerencias: Primero, si le gusta este tipo de evaluación comparativa, considere usar rdtsc instrucción en lugar de gettimeofday(2). Es mucho más preciso y ofrece el tiempo en ciclos, que generalmente es lo que le interesa de todos modos. Para gcc y amigos puedes definirlo así:

#include <stdint.h>

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

En segundo lugar, debe ejecutar su programa de referencia varias veces y usar el mejor rendimiento solo. En los sistemas operativos modernos, muchas cosas suceden en paralelo, la CPU puede estar en un modo de ahorro de energía de baja frecuencia, etc. Ejecutar el programa repetidamente le da un resultado que se acerca más al caso ideal.


6
2017-12-05 20:19