Delantero
Soy un novato en la optimización de instrucciones.
Hice un análisis simple en una función simple dotp que se usa para obtener el producto escalar de dos matrices flotantes.
El código C es el siguiente:
float dotp(
const float x[],
const float y[],
const short n
)
{
short i;
float suma;
suma = 0.0f;
for(i=0; i<n; i++)
{
suma += x[i] * y[i];
}
return suma;
}
Uso el marco de prueba proporcionado por Agner Fog en la web prueba.
Las matrices que se utilizan en este caso están alineadas:
int n = 2048;
float* z2 = (float*)_mm_malloc(sizeof(float)*n, 64);
char *mem = (char*)_mm_malloc(1<<18,4096);
char *a = mem;
char *b = a+n*sizeof(float);
char *c = b+n*sizeof(float);
float *x = (float*)a;
float *y = (float*)b;
float *z = (float*)c;
Luego llamo a la función dotp, n=2048, repeat=100000:
for (i = 0; i < repeat; i++)
{
sum = dotp(x,y,n);
}
Lo compilo con gcc 4.8.3, con la opción de compilación -O3.
Compilo esta aplicación en una computadora que no admite instrucciones FMA, por lo que puede ver que solo hay instrucciones SSE.
El código de montaje:
.L13:
movss xmm1, DWORD PTR [rdi+rax*4]
mulss xmm1, DWORD PTR [rsi+rax*4]
add rax, 1
cmp cx, ax
addss xmm0, xmm1
jg .L13
Hago un análisis:
μops-fused la 0 1 2 3 4 5 6 7
movss 1 3 0.5 0.5
mulss 1 5 0.5 0.5 0.5 0.5
add 1 1 0.25 0.25 0.25 0.25
cmp 1 1 0.25 0.25 0.25 0.25
addss 1 3 1
jg 1 1 1 -----------------------------------------------------------------------------
total 6 5 1 2 1 1 0.5 1.5
Después de ejecutar, obtenemos el resultado:
Clock | Core cyc | Instruct | BrTaken | uop p0 | uop p1
--------------------------------------------------------------------
542177906 |609942404 |1230100389 |205000027 |261069369 |205511063
--------------------------------------------------------------------
2.64 | 2.97 | 6.00 | 1 | 1.27 | 1.00
uop p2 | uop p3 | uop p4 | uop p5 | uop p6 | uop p7
-----------------------------------------------------------------------
205185258 | 205188997 | 100833 | 245370353 | 313581694 | 844
-----------------------------------------------------------------------
1.00 | 1.00 | 0.00 | 1.19 | 1.52 | 0.00
La segunda línea es el valor leído de los registros de Intel; la tercera línea está dividida por el número de sucursal, “BrTaken”.
Entonces podemos ver, en el bucle hay 6 instrucciones, 7 uops, de acuerdo con el análisis.
Los números de uops ejecutados en port0 port1 port5 port6 son similares a lo que dice el análisis. Creo que tal vez el programador de uops hace esto, puede intentar equilibrar las cargas en los puertos, ¿verdad?
Absolutamente no entiendo por qué solo hay alrededor de 3 ciclos por ciclo. Según Agner tabla de instruccionesla latencia de la instrucción mulss
es 5, y hay dependencias entre los bucles, por lo que veo, debería tomar al menos 5 ciclos por bucle.
¿Alguien podría arrojar alguna idea?
================================================== ================
Traté de escribir una versión optimizada de esta función en nasm, desenrollando el ciclo por un factor de 8 y usando el vfmadd231ps
instrucción:
.L2:
vmovaps ymm1, [rdi+rax]
vfmadd231ps ymm0, ymm1, [rsi+rax]
vmovaps ymm2, [rdi+rax+32]
vfmadd231ps ymm3, ymm2, [rsi+rax+32]
vmovaps ymm4, [rdi+rax+64]
vfmadd231ps ymm5, ymm4, [rsi+rax+64]
vmovaps ymm6, [rdi+rax+96]
vfmadd231ps ymm7, ymm6, [rsi+rax+96]
vmovaps ymm8, [rdi+rax+128]
vfmadd231ps ymm9, ymm8, [rsi+rax+128]
vmovaps ymm10, [rdi+rax+160]
vfmadd231ps ymm11, ymm10, [rsi+rax+160]
vmovaps ymm12, [rdi+rax+192]
vfmadd231ps ymm13, ymm12, [rsi+rax+192]
vmovaps ymm14, [rdi+rax+224]
vfmadd231ps ymm15, ymm14, [rsi+rax+224]
add rax, 256
jne .L2
El resultado:
Clock | Core cyc | Instruct | BrTaken | uop p0 | uop p1
------------------------------------------------------------------------
24371315 | 27477805| 59400061 | 3200001 | 14679543 | 11011601
------------------------------------------------------------------------
7.62 | 8.59 | 18.56 | 1 | 4.59 | 3.44
uop p2 | uop p3 | uop p4 | uop p5 | uop p6 | uop p7
-------------------------------------------------------------------------
25960380 |26000252 | 47 | 537 | 3301043 | 10
------------------------------------------------------------------------------
8.11 |8.13 | 0.00 | 0.00 | 1.03 | 0.00
Entonces podemos ver que el caché de datos L1 alcanza 2*256bit/8.59, está muy cerca del pico 2*256/8, el uso es de aproximadamente 93%, la unidad FMA solo usó 8/8.59, el pico es 2*8 /8, el uso es del 47%.
Así que creo que he llegado al cuello de botella L1D como espera Peter Cordes.
================================================== ================
Un agradecimiento especial a Boann, corrige tantos errores gramaticales en mi pregunta.
================================================== ===============
De la respuesta de Peter, entiendo que solo el registro “leído y escrito” sería la dependencia, los registros “solo para escritores” no serían la dependencia.
Así que trato de reducir los registros utilizados en el bucle, y trato de desenrollar por 5, si todo está bien, debería encontrarme con el mismo cuello de botella, L1D.
.L2:
vmovaps ymm0, [rdi+rax]
vfmadd231ps ymm1, ymm0, [rsi+rax]
vmovaps ymm0, [rdi+rax+32]
vfmadd231ps ymm2, ymm0, [rsi+rax+32]
vmovaps ymm0, [rdi+rax+64]
vfmadd231ps ymm3, ymm0, [rsi+rax+64]
vmovaps ymm0, [rdi+rax+96]
vfmadd231ps ymm4, ymm0, [rsi+rax+96]
vmovaps ymm0, [rdi+rax+128]
vfmadd231ps ymm5, ymm0, [rsi+rax+128]
add rax, 160 ;n = n+32
jne .L2
El resultado:
Clock | Core cyc | Instruct | BrTaken | uop p0 | uop p1
------------------------------------------------------------------------
25332590 | 28547345 | 63700051 | 5100001 | 14951738 | 10549694
------------------------------------------------------------------------
4.97 | 5.60 | 12.49 | 1 | 2.93 | 2.07
uop p2 |uop p3 | uop p4 | uop p5 |uop p6 | uop p7
------------------------------------------------------------------------------
25900132 |25900132 | 50 | 683 | 5400909 | 9
-------------------------------------------------------------------------------
5.08 |5.08 | 0.00 | 0.00 |1.06 | 0.00
Podemos ver 5/5.60 = 89.45%, es un poco más pequeño que rodar por 8, ¿hay algo mal?
================================================== ===============
Intento desenrollar el bucle por 6, 7 y 15, para ver el resultado. También desenrollo por 5 y 8 nuevamente, para confirmar doblemente el resultado.
El resultado es el siguiente, podemos ver que esta vez el resultado es mucho mejor que antes.
Aunque el resultado no es estable, el factor de desenrollado es mayor y el resultado es mejor.
| L1D bandwidth | CodeMiss | L1D Miss | L2 Miss
----------------------------------------------------------------------------
unroll5 | 91.86% ~ 91.94% | 3~33 | 272~888 | 17~223
--------------------------------------------------------------------------
unroll6 | 92.93% ~ 93.00% | 4~30 | 481~1432 | 26~213
--------------------------------------------------------------------------
unroll7 | 92.29% ~ 92.65% | 5~28 | 336~1736 | 14~257
--------------------------------------------------------------------------
unroll8 | 95.10% ~ 97.68% | 4~23 | 363~780 | 42~132
--------------------------------------------------------------------------
unroll15 | 97.95% ~ 98.16% | 5~28 | 651~1295 | 29~68
================================================== ===================
Intento compilar la función con gcc 7.1 en la web”https://gcc.godbolt.org“
La opción de compilación es “-O3 -march=haswell -mtune=intel”, que es similar a gcc 4.8.3.
.L3:
vmovss xmm1, DWORD PTR [rdi+rax]
vfmadd231ss xmm0, xmm1, DWORD PTR [rsi+rax]
add rax, 4
cmp rdx, rax
jne .L3
ret
-
Hola, Peter Cordes, muchas gracias, entiendo que la dependencia es el registro xmm0 y el cuello de botella es el adds. Al principio, veo que cmp y add podrían ejecutarse en port0, port1,port5,port5, así que puse un * en cmp y add para mostrar que podría ejecutarse en muchos puertos… bueno, no sé, hay un significado especial sobre “*”, lo he arreglado.
– Delantero
15 de julio de 2017 a las 5:49
-
¿Qué piensas de eso? En realidad, hay 1,19 uops por bucle en el puerto 5, es mucho más de lo esperado 0,5, ¿se trata de que el despachador de uops intente hacer uops en todos los puertos de la misma manera?
– Delantero
15 de julio de 2017 a las 5:52
-
i++
cuandoi
es 2^15-1 yi
ha sido declaradoshort
no es UB.i++
se expande ai = (short) ((int) i + 1);
y el comportamiento definido por la implementación del desbordamiento en la conversión deint
parashort
debe ocurrir. Sin embargo, la transformación del código de GCC es correcta.– Pascal Cuoq
15 de julio de 2017 a las 13:24
-
@Forward: sí, no limité esta respuesta a cosas de nivel principiante: P. Este parecía un buen lugar para tratar de escribir una versión canónica de cómo contar la latencia, los uops de front-end y los uops de puerto de ejecución. Y luego, si voy a vincular aquí desde otras respuestas, también podría entrar en muchos detalles interesantes para cualquier persona de cualquier nivel de experiencia que quiera leerlos. 🙂 Haga más preguntas buenas como esta en el futuro, si todavía está atascado después de leer las guías de Agner Fog (especialmente la de microarch) y buscar en SO. Hay algunas buenas respuestas x86 perf aquí (algunas de ellas son mías 🙂
– Peter Cordes
16 de julio de 2017 a las 0:11
-
@PeterCordes, sí, en mi prueba, 15 es considerablemente más rápido que 8, pero solo un poco, puede ver que el mejor caso en 8 es similar al peor caso en 15.
– Delantero
18 de julio de 2017 a las 6:23
Voto positivo por el esfuerzo de investigación.
– fuz
15 de julio de 2017 a las 1:47
Hay dos unidades de ejecución que pueden realizar multiplicaciones de FP en Haswell, por lo que dos instrucciones MULSS pueden ejecutarse en paralelo. No hay dependencia entre las instrucciones MULSS en cada iteración del ciclo.
– Ross Ridge
15 de julio de 2017 a las 3:41
@Ross Ridge, sí, lo entiendo con la respuesta de Peter Cordes, la dependencia es xmm0, por lo que addss es el cuello de botella.
– Delantero
15 de julio de 2017 a las 5:20
Sí, buen trabajo en el bucle FMA desenrollado. Agregué una sección sobre eso en mi respuesta. Puede reducir el tamaño del código y la cantidad de uops de dominio fusionado, pero probablemente no pueda acercarse mucho más a la saturación del rendimiento de uop p2/p3, lo que lo limita a dos cargas L1D por ciclo que alimentan un promedio de un FMA por ciclo. Actualicé mi respuesta para que quede más claro que la reutilización de registros está bien con instrucciones de solo escritura. Su bucle FMA utiliza muchos registros arquitectónicos como destinos de carga sin ningún beneficio. (Pero solo una desventaja del tamaño del código).
– Peter Cordes
15 de julio de 2017 a las 17:22
Por lo general, desea un compilador más nuevo que el hardware, por lo que han tenido tiempo de actualizar las opciones de ajuste para
-march=native
. Y arregle algunos problemas de creación de códigos lentos que podrían notarse solo después de que AVX2 haya existido por un tiempo. Sin embargo, creo que mucha gente usa compiladores antiguos con buenos resultados. Tal vez le doy demasiada importancia, pero cuando miro la salida del compilador asm, el gcc más nuevo a menudo funciona mejor. Sin embargo, a menudo de maneras que realmente no importarán en general.– Peter Cordes
16 de julio de 2017 a las 0:34