¿Por qué hay un gran impacto en el rendimiento en 2048x2048 frente a la multiplicación de matriz 2047x2047?


127

Estoy haciendo una evaluación comparativa de multiplicación de matrices, como se mencionó anteriormente en ¿Por qué MATLAB es tan rápido en la multiplicación de matrices?

Ahora tengo otro problema, al multiplicar dos matrices de 2048x2048, hay una gran diferencia entre C # y otras. Cuando intento multiplicar solo matrices 2047x2047, parece normal. También se agregaron algunos otros para la comparación.

1024x1024 - 10 segundos.

1027x1027 - 10 segundos.

2047x2047 - 90 segundos.

2048x2048 - 300 segundos.

2049x2049 - 91 segundos. (actualizar)

2500x2500 - 166 segundos

Esa es una diferencia de tres minutos y medio para el caso de 2k por 2k.

utilizando matrices 2dim

//Array init like this
int rozmer = 2048;
float[,] matice = new float[rozmer, rozmer];

//Main multiply code
for(int j = 0; j < rozmer; j++)
{
   for (int k = 0; k < rozmer; k++)
   {
     float temp = 0;
     for (int m = 0; m < rozmer; m++)
     {
       temp = temp + matice1[j,m] * matice2[m,k];
     }
     matice3[j, k] = temp;
   }
 }

23
Esta sería una gran pregunta de examen para una clase de programación avanzada de nivel C o diseño de sistema operativo ;-)
Dana the Sane

¿Has intentado probar tanto las matrices multidimensionales [,] como dentadas [] [], así como las de 32 y 64 bits? Solo lo probé algunas veces, pero el dentado parecía más en línea con sus resultados, pero el dentado de 64 bits era alto, no sé si hay alguna heurística en el jit que se aplique a esta situación o si su caché está relacionada como se sugirió anteriormente. Si desea una solución GPGPU, hay research.microsoft.com/en-us/projects/accelerator que debería ser competitivo con los tiempos en su otra publicación.
Kris

Pregunta algo ingenua, pero ¿cuántas operaciones (sumar / multiplicar) están involucradas en la multiplicación de dos matrices cuadradas?
Nick T

Respuestas:


61

Esto probablemente tenga que ver con conflictos en su caché L2.

Los errores de caché en matice1 no son el problema porque se accede secuencialmente. Sin embargo, para matice2 si una columna completa encaja en L2 (es decir, cuando accede a matice2 [0, 0], matice2 [1, 0], matice2 [2, 0] ... etc., nada se desaloja) que no hay ningún problema con el caché falla con matice2 tampoco.

Ahora, para profundizar en el funcionamiento de los cachés, si la dirección de byte de su variable es X, la línea de caché sería (X >> 6) y (L - 1). Donde L es el número total de líneas de caché en su caché. L es siempre una potencia de 2. El seis proviene del hecho de que 2 ^ 6 == 64 bytes es el tamaño estándar de la línea de caché.

Ahora, que significa esto? Bueno, significa que si tengo la dirección X y la dirección Y y (X >> 6) - (Y >> 6) es divisible por L (es decir, una gran potencia de 2), se almacenarán en la misma línea de caché.

Ahora, para volver a su problema, ¿cuál es la diferencia entre 2048 y 2049?

cuando 2048 es tu talla:

si toma & matice2 [x, k] y & matice2 [y, k] la diferencia (& matice2 [x, k] >> 6) - (& matice2 [y, k] >> 6) será divisible por 2048 * 4 (tamaño de flotador). Entonces una gran potencia de 2.

Por lo tanto, dependiendo del tamaño de su L2, tendrá muchos conflictos de línea de caché y solo utilizará una pequeña porción de su L2 para almacenar una columna, por lo que no podrá almacenar la columna completa en su caché, por lo que obtendrá un mal rendimiento .

Cuando el tamaño es 2049, la diferencia es 2049 * 4, que no es potencia de 2, por lo que tendrá menos conflictos y su columna se ajustará de forma segura a su caché.

Ahora, para probar esta teoría, hay un par de cosas que puedes hacer:

Asigne su matriz matriz matice2 como esta matice2 [razmor, 4096], y ejecute con razmor = 1024, 1025 o cualquier tamaño, y debería ver un rendimiento muy malo en comparación con lo que tenía antes. Esto se debe a que alinea con fuerza todas las columnas para que entren en conflicto entre sí.

Luego pruebe matice2 [razmor, 4097] y ejecútelo con cualquier tamaño y debería ver un rendimiento mucho mejor.


¿Cometiste un error en tus últimos 2 párrafos? Ambos intentos son exactamente iguales. :)
Xeo

La asociatividad de caché también juega un papel.
Ben Jackson

20

Probablemente un efecto de almacenamiento en caché. Con dimensiones de matriz que son grandes potencias de dos, y un tamaño de caché que también es una potencia de dos, puede terminar usando solo una pequeña fracción de su caché L1, lo que ralentiza mucho las cosas. La multiplicación ingenua de matrices generalmente está limitada por la necesidad de recuperar datos en el caché. Los algoritmos optimizados que utilizan mosaico (o algoritmos ajenos al caché) se centran en hacer un mejor uso del caché L1.

Si cronometra otros pares (2 ^ n-1,2 ^ n) espero que vea efectos similares.

Para explicarlo más completamente, en el bucle interno, donde accede a matice2 [m, k], es probable que matice2 [m, k] y matice2 [m + 1, k] se compensen entre sí por 2048 * sizeof (float) y así correlacionar con el mismo índice en el caché L1. Con un caché asociativo N-way, normalmente tendrá 1-8 ubicaciones de caché para todo esto. Por lo tanto, casi todos esos accesos desencadenarán un desalojo de caché L1 y la obtención de datos de un caché más lento o memoria principal.


+1. Suena probable Hay que tener cuidado con la asociatividad de caché.
Macke

16

Esto puede tener que ver con el tamaño de su caché de la CPU. Si 2 filas de la matriz de matriz no encajan, perderá tiempo intercambiando elementos de la RAM. Los 4095 elementos adicionales pueden ser suficientes para evitar que se ajusten las filas.

En su caso, 2 filas para 2047 matrices 2d caen dentro de 16 KB de memoria (suponiendo tipos de 32 bits). Por ejemplo, si tiene un caché L1 (el más cercano a la CPU en el bus) de 64 KB, puede colocar al menos 4 filas (de 2047 * 32) en el caché a la vez. Con las filas más largas si se requiere un relleno que empuje los pares de filas más allá de 16 KB, entonces las cosas comienzan a complicarse. Además, cada vez que 'pierde' el caché, el intercambio de datos de otro caché o memoria principal retrasa las cosas.

Supongo que la variación en los tiempos de ejecución que está viendo con las matrices de diferentes tamaños se ve afectada por la eficacia con la que el sistema operativo puede hacer uso de la memoria caché disponible (y algunas combinaciones son simplemente problemáticas). Por supuesto, esto es una gran simplificación de mi parte.


2
pero es muy poco probable que tenga 16,7 MB de caché de CPU
Marino Šimić

Actualicé los resultados con 2049x2049 - 91 segundos. Si se tratara de un "problema de caché", ¿no deberían ser más de 300 s?
lobo

@Marino, la respuesta se ha actualizado para tener eso en cuenta.
Dana the Sane

1
Siento que ninguna de estas explicaciones puede abordar adecuadamente los nuevos detalles con respecto a los diversos y escasos tamaños que provocan el problema, mientras que otros no se ven afectados.
Ken Rockot

2
No creo que esta explicación sea correcta. El problema radica en no utilizar la capacidad de la memoria caché por completo debido a los conflictos de la línea de la memoria caché cuando el tamaño es de potencia 2. Además, el sistema operativo realmente no tiene nada que ver con las memorias caché, porque no es el sistema operativo el que decide qué almacenar en caché y qué desalojar, es todo en hardware El sistema operativo tiene algo que ver con la alineación de datos, pero en este caso se trata de cómo C # decide asignar datos y cómo representar una matriz 2D en la memoria, el sistema operativo no tiene nada que ver con eso.
zviadm


5

Dado que el tiempo se está reduciendo en tamaños más grandes, ¿no sería más probable que haya conflictos de caché, especialmente con potencias de 2 para los tamaños de matriz problemáticos? No soy experto en problemas de almacenamiento en caché, pero aquí tengo excelente información sobre problemas de rendimiento relacionados con el almacenamiento en caché .


La sección 5 del enlace sobre asociatividad de caché parece aplicarse en particular.
Dana the Sane

4

A medida que accede a la matice2matriz verticalmente, se intercambiará mucho más dentro y fuera de la memoria caché. Si duplica la matriz en diagonal, para que pueda acceder a ella en [k,m]lugar de hacerlo [m,k], el código se ejecutará mucho más rápido.

Probé esto para matrices de 1024x1024, y es aproximadamente el doble de rápido. Para las matrices 2048x2048 es aproximadamente diez veces más rápido.


Esto no explica por qué 2049 es más rápido que 2048.
Macke

@Macke: Eso se debe a que pasa algún límite en el almacenamiento en memoria caché, por lo que hay muchos más errores de caché.
Guffa

¿Por qué el voto negativo? Si no dice lo que cree que está mal, no puede mejorar la respuesta.
Guffa

Otro voto a favor sin ninguna explicación ... ¿Es que mi respuesta tiene muy pocos "probablemente", "adivine" y "debería", como las respuestas que obtienen más votos a favor ...?
Guffa

4

Aliasing de caché

O la caché golpeando , si puedo acuñar un término.

Las memorias caché funcionan indexando con bits de orden inferior y etiquetando con bits de orden superior.

Imagine que su caché tiene 4 palabras y su matriz es 4 x 4. Cuando se accede a una columna y la fila tiene una potencia de dos de longitud, cada elemento de columna en la memoria se asignará al mismo elemento de caché.

Un poder de dos más uno es realmente óptimo para este problema. Cada nuevo elemento de columna se asignará a la siguiente ranura de caché exactamente como si se accediera por fila.

En la vida real, una etiqueta cubre múltiples direcciones que aumentan secuencialmente y que almacenarán en caché varios elementos adyacentes en una fila. Al compensar el depósito al que se asigna cada nueva fila, atravesar la columna no reemplaza la entrada anterior. Cuando se atraviesa la siguiente columna, todo el caché se llenará con diferentes filas y cada sección de fila que se ajuste al caché alcanzará varias columnas.

Dado que el caché es mucho más rápido que la DRAM (principalmente en virtud de estar en el chip), la tasa de aciertos es todo.


2

Parece que has alcanzado un límite de tamaño de caché, o tal vez tienes algunos problemas de repetibilidad en tus tiempos.

Cualquiera sea el problema, simplemente no debe escribir la multiplicación de matrices usted mismo en C # y en su lugar usar una versión optimizada de BLAS. Ese tamaño de matriz debe multiplicarse en menos de un segundo en cualquier máquina moderna.


1
Soy consciente de BLAS, pero la tarea no era hacerlo lo más rápido posible, sino escribirlo y probarlo en varios idiomas. Este es un problema muy extraño para mí y estoy realmente curioso por qué los resultados son como son.
Wolf

3
@Wolf Me resultaría difícil entusiasmarme si algo que debería tomar un segundo está tomando 90 segundos o 300 segundos.
David Heffernan

44
La mejor manera de aprender cómo funciona algo es escribirlo usted mismo y ver cómo puede mejorar su implementación; esto es (con suerte) lo que está haciendo Wolf.
Callum Rogers

@ Callum Rogers, de acuerdo. Así es como aprendí la importancia de los tamaños de búfer en las operaciones de copia de archivos.
Kelly S. French

1

La utilización efectiva de la jerarquía de caché es muy importante. Debe asegurarse de que las matrices multidimensionales tengan datos en una buena disposición, lo que se puede lograr mediante el mosaico . Para hacer esto, necesitará almacenar la matriz 2D como una matriz 1D junto con un mecanismo de indexación. El problema con el método tradicional es que, aunque dos elementos de matriz adyacentes que están en la misma fila están uno al lado del otro en la memoria, dos elementos adyacentes en la misma columna estarán separados por elementos W en la memoria, donde W es el número de columnas . El mosaico puede marcar una diferencia de rendimiento de un factor de diez.


Hmm: sin embargo, una matriz declarada como 2D (float [,] matice = new float [rozmer, rozmer];) solo se asigna en la RAM como una matriz unidimensional y los cálculos de fila / zancada se realizan bajo el capó. Entonces, ¿por qué declararlo como 1D y hacer cálculos manuales de fila / zancada sería más rápido? ¿Quiere decir que sol'n es asignar una gran matriz como una matriz de mosaicos más pequeños, cada uno de los cuales puede caber en la caché donde la gran matriz no lo haría?
Eric M

1
Si su biblioteca o cualquier herramienta que esté utilizando tiene mosaico, entonces no es necesario. Pero si tuviera que usar una matriz 2D tradicional en, por ejemplo, C / C ++, el mosaico mejoraría el rendimiento.
Arlen

0

Sospecho que es el resultado de algo llamado " Inundación secuencial ". Lo que es esto es que está intentando recorrer la lista de objetos que es un poco más grande que el tamaño del caché, por lo tanto, cada solicitud a la lista (matriz) debe hacerse desde el ram, y no obtendrá un solo caché golpear.

En su caso, está recorriendo sus matrices 2048 índices 2048 veces, pero solo tiene espacio para 2047 (posiblemente debido a cierta sobrecarga de la estructura de la matriz), por lo que cada vez que accede a una posición de la matriz, necesita obtener esta posición de la matriz. de carnero. Luego se almacena en la memoria caché, pero justo antes de volver a usarse, se descarga. Por lo tanto, el caché es esencialmente inútil, lo que lleva a un tiempo de ejecución mucho más largo.


1
Incorrecto. 2049 es más rápido que 2048, lo que refuta su reclamo.
Macke

@Macke: Eso es muy posible. Pero hay una pequeña posibilidad de que la política de caché utilizada en su procesador aún pueda tomar esta decisión. No es muy probable, pero no es impensable.
Automatico
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.