¿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
...
fuente
-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.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.
Lo clave a tener en cuenta aquí es la enorme cantidad de desenrollado manual de bucles, así como el intercalado de multiplicaciones y sumas ...
El proyecto completo se puede encontrar en mi GitHub: https://github.com/Mysticial/Flops
Advertencia:
¡Si decides compilar y ejecutar esto, presta atención a las temperaturas de tu CPU!
Asegúrate de no sobrecalentarlo. ¡Y asegúrese de que la aceleración de la CPU no afecte sus resultados!
Además, no me responsabilizo por el daño que pueda resultar de ejecutar este código.
Notas:
ICC 11 (Intel Compiler 11) sorprendentemente tiene problemas para compilarlo bien.
Salida (1 subproceso, 10000000 iteraciones) - Compilada con Visual Studio 2010 SP1 - Versión x64:
La máquina es un Core i7 2600K @ 4.4 GHz. El pico teórico de SSE es de 4 flops * 4.4 GHz = 17.6 GFlops . Este código logra 17.3 GFlops , no está mal.
Salida (8 hilos, 10000000 iteraciones) - Compilada con Visual Studio 2010 SP1 - Versión x64:
El pico de SSE teórico es de 4 flops * 4 núcleos * 4.4 GHz = 70.4 GFlops. Real es 65.5 GFlops .
Vayamos un paso más allá. AVX ...
Salida (1 subproceso, 10000000 iteraciones) - Compilada con Visual Studio 2010 SP1 - Versión x64:
El pico AVX teórico es de 8 flops * 4.4 GHz = 35.2 GFlops . Real es 33.4 GFlops .
Salida (8 hilos, 10000000 iteraciones) - Compilada con Visual Studio 2010 SP1 - Versión x64:
El pico AVX teórico es de 8 flops * 4 núcleos * 4.4 GHz = 140.8 GFlops. Real es 138.2 GFlops .
Ahora para algunas explicaciones:
La parte crítica del rendimiento es obviamente las 48 instrucciones dentro del bucle 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.
Así que hay 12 instrucciones y 6 ciclos entre problemas de uso. La latencia de la multiplicación es de 5 ciclos, por lo que es suficiente para evitar los bloqueos de latencia.
El paso de normalización es necesario para evitar que los datos se desborden / desborden. Esto es necesario ya que el código de no hacer nada aumentará / disminuirá lentamente la magnitud de los datos.
Por lo tanto, en realidad es posible hacerlo mejor si solo usa 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, tuve que asegurarme de que los flops estuvieran en datos "reales", en lugar de ceros , ya 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:
Hilos: 1
Pico teórico de SSE: 4 flops * 3.5 GHz = 14.0 GFlops . Real es 13.3 GFlops .
Subprocesos: 8
Pico teórico de SSE: 4 flops * 4 núcleos * 3.5 GHz = 56.0 GFlops . Real es 51.3 GFlops .
¡Las temperaturas de mi procesador alcanzaron los 76 ° C en la ejecución de subprocesos múltiples! Si ejecuta estos, asegúrese de que los resultados no se vean afectados por la aceleración de la CPU.
Hilos: 1
Pico SSE teórico: 4 flops * 3.2 GHz = 12.8 GFlops . Real es 12.3 GFlops .
Subprocesos: 8
Pico SSE teórico: 4 flops * 8 núcleos * 3.2 GHz = 102.4 GFlops . Real es 97.9 GFlops .
fuente
1.814s, 5.292 Gflops, sum=0.448883
de un máximo de 10.68 Gflops o apenas menos de 2.0 flops por ciclo. Pareceadd
/mul
no se ejecuta en paralelo. Cuando cambio su código y siempre agrego / multiplico con el mismo registro, digamosrC
, de repente alcanza casi el pico:0.953s, 10.068 Gflops, sum=0
o 3.8 flops / ciclo. Muy extraño.cl /O2
(64 bits desde Windows SDK) e incluso mi ejemplo se ejecuta cerca del pico para operaciones escalares (1.9 flops / ciclo) allí. El compilador se desenrolla y reordena, pero esa podría no ser la razón por la que debe analizar esto un poco más. Acelerar no es un problema. Soy amable con mi CPU y mantengo las iteraciones a 100k. :)Hay un punto en la arquitectura de Intel que la gente suele olvidar, los puertos de envío 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 del bucle cree burbujas en su flujo de coma flotante. Mystical obtuvo más fracasos de su código, porque usó zancadas más largas en su bucle desenrollado.
Si observa 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 máximo rendimiento en AMD (Bulldozer) ya que las tuberías INT y FP / SIMD tienen puertos de problema separados con su propio programador.
Esto es solo teórico ya que no tengo ninguno de estos procesadores para probar.
fuente
inc
,cmp
, yjl
. Todos estos pueden ir al puerto # 5 y no interferir con ni vectorizadofadd
nifmul
. Prefiero sospechar que el decodificador (a veces) se interpone en el camino. Necesita mantener entre dos y tres instrucciones por ciclo. No recuerdo las limitaciones exactas, pero la duración de la instrucción, los prefijos y la alineación entran en juego.cmp
yjl
ciertamente vaya al puerto 5,inc
no tan seguro como siempre viene en grupo con los otros 2. Pero tienes razón, es difícil saber dónde está el cuello de botella y los decodificadores también pueden ser parte de él.Las sucursales definitivamente pueden evitar que mantenga un rendimiento teórico máximo. ¿Ves alguna diferencia si realizas manualmente un ciclo de desenrollado? Por ejemplo, si coloca 5 o 10 veces más operaciones por iteración de bucle:
fuente
-funroll-loops
opción que ni siquiera está incluida-O3
. Verg++ -c -Q -O2 --help=optimizers | grep unroll
.Usando Intels icc Versión 11.1 en un Intel Core 2 Duo de 2.4GHz obtengo
Eso está muy cerca de los 9.6 Gflops ideales.
EDITAR:
Oops, mirando el código de ensamblaje parece que icc no solo vectorizó la multiplicación, sino que también eliminó las adiciones del bucle. Al forzar una semántica fp más estricta, el código ya no está vectorizado:
EDIT2:
De acuerdo a lo pedido:
El bucle interno del código de clang se ve así:
EDITAR3:
Finalmente, dos sugerencias: Primero, si le gusta este tipo de evaluación comparativa, considere usar la
rdtsc
instrucción en lugar degettimeofday(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, puede definirlo así:En segundo lugar, debe ejecutar su programa de referencia varias veces y usar solo el mejor rendimiento . En los sistemas operativos modernos suceden muchas cosas 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 más cercano al caso ideal.
fuente
addsd
'symulsd
' o están en grupos como en mi salida de ensamblaje? También obtengo aproximadamente 1 flop / ciclo cuando el compilador los mezcla (sin el cual lo hago-march=native
). ¿Cómo cambia el rendimiento si agrega una líneaadd=mul;
al comienzo de la funciónaddmul(...)
?addsd
ysubsd
se mezclan en la versión precisa. También probé el clang 3.0, no combina instrucciones y se acerca mucho a 2 flops / ciclo en el núcleo 2 duo. Cuando ejecuto el mismo código en mis computadoras portátiles Core i5, mezclar el código no hace ninguna diferencia. Tengo alrededor de 3 flops / ciclo en cualquier caso.icc
antes, ¿puede verificar el ensamblaje?