¿Por qué mulss toma solo 3 ciclos en Haswell, a diferencia de las tablas de instrucciones de Agner? (Desenrollando bucles FP con múltiples acumuladores)

8 minutos de lectura

avatar de usuario
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

  • 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


  • 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++ cuando i es 2^15-1 y i ha sido declarado short no es UB. i++ se expande a i = (short) ((int) i + 1); y el comportamiento definido por la implementación del desbordamiento en la conversión de int para short 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

¿Ha sido útil esta solución?

Esta web utiliza cookies propias y de terceros para su correcto funcionamiento y para fines analíticos y para mostrarte publicidad relacionada con sus preferencias en base a un perfil elaborado a partir de tus hábitos de navegación. Al hacer clic en el botón Aceptar, acepta el uso de estas tecnologías y el procesamiento de tus datos para estos propósitos. Configurar y más información
Privacidad