¿Cómo se puede lograr el máximo rendimiento teórico de 4 operaciones de punto flotante (doble precisión) por ciclo en una CPU Intel x86-64 moderna?
Según tengo entendido, toma tres ciclos para un SSE add
y cinco ciclos para mul
completar en la mayoría de las CPU Intel modernas (ver, por ejemplo, las 'Tablas de instrucciones' de Agner Fog ). Debido a la canalización, se puede obtener un rendimiento de uno add
por ciclo si el algoritmo tiene al menos tres sumas independientes. Como eso es cierto tanto para addpd
las addsd
versiones empaquetadas como para las escalares y los registros SSE pueden contener dos double
, el rendimiento puede ser de hasta dos flops por ciclo.
Además, parece (aunque no he visto ninguna documentación adecuada sobre esto) add
's y mul
' s pueden ejecutarse en paralelo dando un rendimiento máximo teórico de cuatro flops por ciclo.
Sin embargo, no he podido replicar ese rendimiento con un simple programa C / C ++. Mi mejor intento resultó en alrededor de 2.7 flops / ciclo. Si alguien puede contribuir con un simple programa C / C ++ o 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, alrededor de 1.4 flops por ciclo. Mirar el código del ensamblador con
g++ -S -O2 -march=native -masm=intel addmul.cpp
el bucle principal me parece óptimo:
.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
ymulpd
) duplicaría el conteo de flops sin cambiar el tiempo de ejecución y, por lo tanto, obtendría poco menos de 2.8 flops por ciclo. ¿Hay un ejemplo simple que logre cuatro fracasos por ciclo?
Pequeño y agradable programa de Mysticial; Aquí están mis resultados (ejecute solo por unos segundos):
gcc -O2 -march=nocona
: 5.6 Gflops de 10.66 Gflops (2.1 flops / 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 alternaraddpd
ymulpd
's si es posible. Lo mismo se aplica agcc-4.6.2 -O2 -march=core2
.gcc -O2 -march=nocona
parece mantener el orden de las operaciones de coma flotante como se define en la fuente de C ++.cl /O2
, el compilador de 64 bits del SDK para Windows 7 se desenrolla en bucle automáticamente y parece intentar organizar las operaciones para que grupos de tres seaddpd
alternen con tresmulpd
(bueno, al menos en mi sistema y para mi programa simple) .Mi Core i5 750 ( arquitectura Nehalem ) no le gusta alternar add's y mul's y parece incapaz de ejecutar ambas operaciones en paralelo. Sin embargo, si se agrupa en 3, de repente funciona como magia.
Parece que otras arquitecturas (posiblemente Sandy Bridge y otras) pueden 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 pico para el pequeño ejemplo de C ++ anterior. Medí entre 1.85-2.01 flops / ciclo (he usado clock () en Windows, lo cual no es tan preciso. Supongo que necesito usar un mejor temporizador, gracias Mackie Messer).Lo mejor que logré
gcc
fue desenrollar manualmente el bucle y organizar adiciones y multiplicaciones en grupos de tres. Con log++ -O2 -march=nocona addmul_unroll.cpp
que obtengo en el mejor de los casos,0.207s, 4.825 Gflops
que corresponde a 1.8 flops / ciclo con el que estoy bastante contento ahora.
En el código C ++ he reemplazado el for
bucle 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 parece
.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
). Intenté con gcc versión 4.4.1 y 4.6.2, pero la salida de asm parece estar bien?
-O3
con gcc, que permite -ftree-vectorize
? Quizás combinado con -funroll-loops
aunque no lo hago si eso es realmente necesario. Después de todo, la comparación parece un poco injusta si uno de los compiladores realiza la vectorización / desenrollado, mientras que el otro no lo hace porque no puede, sino porque también se le dice que no.
-funroll-loops
es probablemente algo para probar. Pero creo que -ftree-vectorize
está más allá del punto. El OP está tratando de sostener solo 1 mul + 1 agregar instrucción / ciclo. Las instrucciones pueden ser escalares o vectoriales; no importa, ya que la latencia y el rendimiento son los mismos. Entonces, si puede mantener 2 / ciclo con SSE escalar, puede reemplazarlos con SSE vectorial y obtendrá 4 flops / ciclo. En mi respuesta, hice exactamente eso desde SSE -> AVX. Reemplacé todos los SSE con AVX: las mismas latencias, el mismo rendimiento, el doble de los flops.