2011-09-13 15 views
15

me dan dos funciones para encontrar el producto de dos matrices:¿Por qué el orden de los bucles en un algoritmo de multiplicación de matriz afecta el rendimiento?

void MultiplyMatrices_1(int **a, int **b, int **c, int n){ 
     for (int i = 0; i < n; i++) 
      for (int j = 0; j < n; j++) 
       for (int k = 0; k < n; k++) 
        c[i][j] = c[i][j] + a[i][k]*b[k][j]; 
    } 

void MultiplyMatrices_2(int **a, int **b, int **c, int n){ 
     for (int i = 0; i < n; i++) 
      for (int k = 0; k < n; k++) 
       for (int j = 0; j < n; j++) 
        c[i][j] = c[i][j] + a[i][k]*b[k][j]; 
} 

Corrí y perfilado dos ejecutables utilizando gprof, cada uno con código idénticos excepto por esta función. El segundo de estos es significativamente (aproximadamente 5 veces) más rápido para matrices de tamaño 2048 x 2048. ¿Alguna idea de por qué?

Respuesta

30

Creo que lo que está viendo son los efectos de locality of reference en la jerarquía de memoria de la computadora.

Típicamente, la memoria del ordenador está segregada en diferentes tipos que tienen diferentes características de rendimiento (esto se llama a menudo el memory hierarchy). La memoria más rápida se encuentra en los registros del procesador, que (generalmente) se puede acceder y leer en un solo ciclo de reloj. Sin embargo, generalmente hay solo un puñado de estos registros (por lo general, no más de 1 KB). La memoria principal de la computadora, por otro lado, es enorme (digamos, 8GB), pero es mucho más lenta de acceder. Para mejorar el rendimiento, la computadora generalmente se construye físicamente para tener several levels of caches entre el procesador y la memoria principal. Estas memorias caché son más lentas que los registros pero mucho más rápidas que la memoria principal, por lo que si haces un acceso a la memoria que busca algo en la memoria caché, tiende a ser mucho más rápido que si tienes que ir a la memoria principal (por lo general, entre 5-25x) Más rápido). Al acceder a la memoria, el procesador comprueba primero el caché de la memoria para ese valor antes de volver a la memoria principal para leer el valor. Si accedes constantemente a los valores en el caché, terminarás con un rendimiento mucho mejor que si te estás salteando memoria, accediendo aleatoriamente a los valores.

La mayoría de los programas están escritos de forma que si se lee un solo byte en la memoria en la memoria, el programa leerá también múltiples valores diferentes de esa región de memoria. En consecuencia, estas memorias caché generalmente están diseñadas para que cuando lea un único valor de la memoria, un bloque de memoria (generalmente entre 1 KB y 1 MB) de valores alrededor de ese valor individual también se arrastre a la memoria caché. De esta forma, si su programa lee los valores cercanos, ya están en la caché y no tiene que ir a la memoria principal.

Ahora, un último detalle: en C/C++, las matrices se almacenan en orden de fila mayor, lo que significa que todos los valores en una sola fila de una matriz se almacenan uno al lado del otro. Por lo tanto, en la memoria la matriz se ve como la primera fila, luego la segunda fila, luego la tercera fila, etc.

Dado esto, veamos su código. La primera versión se ve así:

for (int i = 0; i < n; i++) 
     for (int j = 0; j < n; j++) 
      for (int k = 0; k < n; k++) 
       c[i][j] = c[i][j] + a[i][k]*b[k][j]; 

Ahora, veamos la línea más interna de código. En cada iteración, el valor de k está cambiando en aumento. Esto significa que cuando se ejecuta el bucle más interno, es probable que cada iteración del bucle tenga una falta de caché al cargar el valor de b[k][j].La razón de esto es que debido a que la matriz está almacenada en orden mayor de fila, cada vez que incrementa k, se salta una fila entera de la matriz y se adentra mucho más en la memoria, posiblemente mucho más allá de los valores que guardó en caché . Sin embargo, no se lo pierda al buscar c[i][j] (ya que i y j son lo mismo), ni probablemente perderá a[i][k], porque los valores están en orden mayor de fila y si el valor de a[i][k] está en caché del anterior iteración, el valor de a[i][k] leído en esta iteración proviene de una ubicación de memoria adyacente. En consecuencia, en cada iteración del ciclo más interno, es probable que tenga una falla en la caché.

Pero considerar esta segunda versión:

for (int i = 0; i < n; i++) 
     for (int k = 0; k < n; k++) 
      for (int j = 0; j < n; j++) 
       c[i][j] = c[i][j] + a[i][k]*b[k][j]; 

Ahora, ya que está aumentando j en cada iteración, vamos a pensar en la cantidad de caché echa de menos es probable que tenga en la cuenta interna. Debido a que los valores están en orden mayor de fila, es probable que el valor de c[i][j] esté en la memoria caché, porque el valor de c[i][j] de la iteración anterior probablemente también esté en la memoria caché y listo para ser leído. Del mismo modo, b[k][j] probablemente está almacenado en la memoria caché, y dado que i y k no están cambiando, es probable que a[i][k] esté almacenado en la memoria caché. Esto significa que en cada iteración del ciclo interno, es probable que no tenga errores de caché.

En general, esto significa que es improbable que la segunda versión del código tenga omisiones de caché en cada iteración del ciclo, mientras que la primera versión casi seguro lo hará. En consecuencia, es probable que el segundo ciclo sea más rápido que el primero, como has visto.

Curiosamente, muchos compiladores están comenzando a tener soporte de prototipo para detectar que la segunda versión del código es más rápida que la primera. Algunos intentarán reescribir automáticamente el código para maximizar el paralelismo. Si tiene una copia del Purple Dragon Book, el Capítulo 11 explica cómo funcionan estos compiladores.

Además, puede optimizar el rendimiento de este bucle aún más utilizando bucles más complejos. Una técnica llamada blocking, por ejemplo, puede usarse para aumentar notablemente el rendimiento dividiendo la matriz en subregiones que pueden mantenerse en caché durante más tiempo, y luego usando múltiples operaciones en estos bloques para calcular el resultado global.

Espero que esto ayude!

+1

+1 ¡Gran explicación! También la sugerencia @Kerrek SB sobre la depuración de la memoria caché agrega muchos más detalles técnicos. – rbaleksandar

0

Probablemente, el segundo tiene que saltar más en la memoria para acceder a los elementos de la matriz. También podría ser otra cosa: podría verificar el código compilado para ver qué está sucediendo realmente.

5

Esta puede ser la localidad de memoria. Cuando reordena el bucle, la memoria que se necesita en el bucle más interno está más cerca y puede almacenarse en caché, mientras que en la versión ineficiente necesita acceder a la memoria desde todo el conjunto de datos.

La manera de probar esta hipótesis es ejecutar un depurador de caché (como cachegrind) en las dos partes de código y ver cuántas faltas de caché tienen.

+0

+1 por mencionar cachegrind –

0

Aparte de la localidad de memoria, también hay optimización del compilador. Una clave para las operaciones vectoriales y matriciales es el despliegue de bucles.

for (int k = 0; k < n; k++) 
    c[i][j] = c[i][j] + a[i][k]*b[k][j]; 

Se puede ver en este bucle interno ij y no cambian. Esto significa que puede ser reescrito como

for (int k = 0; k < n; k+=4) { 
    int * aik = &a[i][k]; 
    c[i][j] += 
     + aik[0]*b[k][j] 
     + aik[1]*b[k+1][j] 
     + aik[2]*b[k+2][j] 
     + aik[3]*b[k+3][j]; 
} 

Se puede ver que habrá

  • cuatro veces menos bucles y accesos a c [i] [j]
  • a [i] [k] se está accediendo continuamente en la memoria
  • los accesos a la memoria y las multiplicaciones se pueden canalizar (casi al mismo tiempo) en la CPU.

¿Qué pasa si n no es un múltiplo de 4 o 6 u 8? (o lo que sea que el compilador decida desenrollar) El compilador maneja este orden para usted.;)

Para acelerar esta solución más rápido, primero puede intentar transponer la matriz b. Esto es un poco más de trabajo y codificación, pero significa que los accesos a b-transposed también son continuos en la memoria. (Como está intercambiando [k] con [j])

Otra cosa que puede hacer para mejorar el rendimiento es multiplicar la multiplicación. Esto puede mejorar el rendimiento en un factor de 3 en una CPU de 4 núcleos.

Por último es posible considerar el uso de float o double Se podría pensar int sería más rápido, sin embargo, que no es siempre el caso, como operaciones de punto flotante puede ser más pesadamente optimizado (tanto en hardware como el compilador)

La segunda el ejemplo tiene c [i] [j] cambiando en cada iteración, lo que dificulta la optimización.

Cuestiones relacionadas