2012-07-10 18 views
168

Después de realizar algunos experimentos en matrices cuadradas de diferentes tamaños, surgió un patrón. Invariablemente, transponiendo una matriz de tamaño 2^n es más lento que la transposición de tamaño 2^n+1. Para valores pequeños de n, la diferencia no es importante.¿Por qué la transposición de una matriz de 512x512 es mucho más lenta que la transposición de una matriz de 513x513?

grandes diferencias se producen sin embargo más de un valor de 512. (al menos para mí)

responsabilidad: Sé que la función en realidad no transponer la matriz debido a la doble intercambio de elementos, pero no tiene diferencia.

sigue el código:

#define SAMPLES 1000 
#define MATSIZE 512 

#include <time.h> 
#include <iostream> 
int mat[MATSIZE][MATSIZE]; 

void transpose() 
{ 
    for (int i = 0 ; i < MATSIZE ; i++) 
    for (int j = 0 ; j < MATSIZE ; j++) 
    { 
     int aux = mat[i][j]; 
     mat[i][j] = mat[j][i]; 
     mat[j][i] = aux; 
    } 
} 

int main() 
{ 
    //initialize matrix 
    for (int i = 0 ; i < MATSIZE ; i++) 
    for (int j = 0 ; j < MATSIZE ; j++) 
     mat[i][j] = i+j; 

    int t = clock(); 
    for (int i = 0 ; i < SAMPLES ; i++) 
     transpose(); 
    int elapsed = clock() - t; 

    std::cout << "Average for a matrix of " << MATSIZE << ": " << elapsed/SAMPLES; 
} 

Cambio MATSIZE nos permite modificar el tamaño (la!). He publicado dos versiones sobre Ideone:

En mi ambiente (MSVS 2010, optimizaciones completas), la diferencia es similar:

  • tamaño de 512 - promedio 2.19 ms
  • tamaño 513 - promedio 0.57 ms

Por qué sucede esto?

+7

Tu código se ve poco amigable para la caché. – CodesInChaos

+3

@CodeInChaos y lo es. –

+7

Es más o menos el mismo problema que esta pregunta: http://stackoverflow.com/questions/7905760/matrix-multiplication-small-difference-in-matrix-size-large-difference-in-timi – Mysticial

Respuesta

157

La explicación proviene de Agner Fog en Optimizing software in C++ y se reduce a la forma en que se accede a los datos y se almacenan en la caché.

Para conocer los términos y la información detallada, ver wiki entry on caching, voy a limitarlo aquí.

Un caché está organizado en conjuntos y líneas. En un momento, solo se usa un juego, del cual se puede usar cualquiera de las líneas que contiene. La memoria que una línea puede reflejar multiplicada por el número de líneas nos da el tamaño de la caché.

Para una dirección de memoria particular, podemos calcular que establece que debería ser reflejado en la fórmula:

set = (address/lineSize) % numberOfsets 

Este tipo de fórmula es da distribución idealmente uniforme a través de los conjuntos, porque cada dirección de memoria es tan probable que se lea (dije idealmente).

Está claro que pueden producirse solapamientos.En caso de falta de caché, la memoria se lee en el caché y se reemplaza el valor anterior. Recuerde que cada conjunto tiene varias líneas, de las cuales la que se usó menos recientemente se sobrescribe con la memoria recién leída.

Voy a tratar de seguir un poco el ejemplo de Agner:

Asumir cada conjunto tiene 4 líneas, cada uno con 64 bytes. Primero intentamos leer la dirección 0x2710, que va en el conjunto 28. Y luego también intentamos leer las direcciones 0x2F00, 0x3700, 0x3F00 y 0x4700. Todos estos pertenecen al mismo conjunto. Antes de leer 0x4700, todas las líneas del conjunto se habrían ocupado. La lectura de esa memoria desaloja una línea existente en el conjunto, la línea que inicialmente contenía 0x2710. El problema radica en el hecho de que leemos las direcciones que son (para este ejemplo) 0x800 aparte. Esta es la zancada crítica (nuevamente, para este ejemplo).

El paso crítico puede calcularse también:

criticaStride = numberOfSets * lineSize 

Variables espaciados criticalStride o un múltiplo aparte sostienen por las mismas líneas de caché.

Esta es la parte teórica. A continuación, la explicación (también Agner, la sigo de cerca para evitar cometer errores):

Supongamos una matriz de 64x64 (recuerde, los efectos varían según la caché) con un caché de 8kb, 4 líneas por conjunto * tamaño de línea de 64 bytes. Cada línea puede contener 8 de los elementos en la matriz (64-bit int).

La zancada crítica sería 2048 bytes, que corresponden a 4 filas de la matriz (que es continua en la memoria).

Supongamos que procesamos la fila 28. Estamos intentando tomar los elementos de esta fila y cambiarlos por los elementos de la columna 28. Los primeros 8 elementos de la fila forman una línea de caché, pero ingrese a 8 líneas de caché diferentes en la columna 28. Recuerde, la zancada crítica está a 4 filas (4 elementos consecutivos en una columna).

Cuando se llega al elemento 16 en la columna (4 líneas de caché por conjunto & 4 filas aparte = problema), el elemento ex-0 será desalojado de la memoria caché. Cuando lleguemos al final de la columna, todas las líneas de caché anteriores se habrían perdido y habría que volver a cargarlas al acceder al siguiente elemento (toda la línea se sobrescribe).

Tener un tamaño que no es un múltiplo del paso crítico meta la pata de esta escenario perfecto para el desastre, ya que ya no estamos tratando con elementos que son paso fundamental, aparte de la vertical, por lo que el número de recargas de caché es severamente reducido.

Otra exención de responsabilidad - Acabo de enterarme de la explicación y espero haberlo solucionado, pero podría estar equivocado. De todos modos, estoy esperando una respuesta (o confirmación) de Mysticial. :)

+0

Ah, y la próxima vez. Solo hazme un ping directamente a través de [Lounge] (http://chat.stackoverflow.com/rooms/10/loungec). No encuentro todas las instancias del nombre en SO. :) Solo vi esto a través de las notificaciones periódicas por correo electrónico. – Mysticial

+0

@Mysticial @Luchian Grigore Uno de mis amigos me dice que su PC 'Intel core i3' que se ejecuta en' Ubuntu 11.04 i386' demuestra casi el mismo rendimiento con * gcc 4.6 *. Y lo mismo ocurre con mi computadora 'Intel Core 2 Duo' con * mingw gcc4.4 *, que se ejecuta en 'windows 7 (32)'. Muestra una gran diferencia cuando compilo este segmento con una pc un poco más vieja 'intel centrino' con * gcc 4.6 *, que se está ejecutando 'ubuntu 12.04 i386'. –

+0

También tenga en cuenta que el acceso a la memoria donde las direcciones difieren en un múltiplo de 4096 tiene una dependencia falsa en las CPU de la familia Intel SnB. (es decir, el mismo desplazamiento dentro de una página). Esto puede reducir el rendimiento cuando algunas de las operaciones son tiendas, esp. una mezcla de cargas y tiendas. –

64

Luchian explica por qué ocurre este comportamiento, pero pensé que sería una buena idea mostrar una posible solución a este problema y, al mismo tiempo, mostrar un poco acerca de los algoritmos de caché inconsistente.

Su algoritmo básicamente hace:

for (int i = 0; i < N; i++) 
    for (int j = 0; j < N; j++) 
     A[j][i] = A[i][j]; 

que es simplemente horrible para una CPU moderna. Una solución es conocer los detalles sobre su sistema de caché y modificar el algoritmo para evitar esos problemas. Funciona muy bien siempre que sepa esos detalles ... no especialmente portátil.

¿Podemos hacer algo mejor que eso? Sí se puede: Un enfoque general para este problema son cache oblivious algorithms que, como su nombre lo dice evita depender de tamaños de caché específicos [1]

La solución se vería así:

void recursiveTranspose(int i0, int i1, int j0, int j1) { 
    int di = i1 - i0, dj = j1 - j0; 
    const int LEAFSIZE = 32; // well ok caching still affects this one here 
    if (di >= dj && di > LEAFSIZE) { 
     int im = (i0 + i1)/2; 
     recursiveTranspose(i0, im, j0, j1); 
     recursiveTranspose(im, i1, j0, j1); 
    } else if (dj > LEAFSIZE) { 
     int jm = (j0 + j1)/2; 
     recursiveTranspose(i0, i1, j0, jm); 
     recursiveTranspose(i0, i1, jm, j1); 
    } else { 
    for (int i = i0; i < i1; i++) 
     for (int j = j0; j < j1; j++) 
      mat[j][i] = mat[i][j]; 
    } 
} 

un poco más compleja, pero una prueba corta muestra algo bastante interesante en mi antigua E8400 con el lanzamiento de VS2010 x64, testcode para MATSIZE 8192

int main() { 
    LARGE_INTEGER start, end, freq; 
    QueryPerformanceFrequency(&freq); 
    QueryPerformanceCounter(&start); 
    recursiveTranspose(0, MATSIZE, 0, MATSIZE); 
    QueryPerformanceCounter(&end); 
    printf("recursive: %.2fms\n", (end.QuadPart - start.QuadPart)/(double(freq.QuadPart)/1000)); 

    QueryPerformanceCounter(&start); 
    transpose(); 
    QueryPerformanceCounter(&end); 
    printf("iterative: %.2fms\n", (end.QuadPart - start.QuadPart)/(double(freq.QuadPart)/1000)); 
    return 0; 
} 

results: 
recursive: 480.58ms 
iterative: 3678.46ms 

edición: Acerca de la influencia del tamaño: es mucho menos pronunciada aunque todavía apreciable en algún grado, eso se debe a que estamos utilizando la solución iterativa como un nodo hoja en lugar de recurrir a 1 (la optimización habitual para los algoritmos recursivos). Si establecemos LEAFSIZE = 1, la memoria caché no tiene ninguna influencia para mí [8193: 1214.06; 8192: 1171.62ms, 8191: 1351.07ms - eso está dentro del margen de error, las fluctuaciones están en el área de 100 ms; este "punto de referencia" no es algo con lo que estaría demasiado cómodo si quisiéramos valores completamente precisos])

[1] Fuentes para esto: Bueno, si no puede obtener una conferencia de alguien que trabajó con Leiserson y co en esto ... Supongo que sus documentos son un buen punto de partida. Esos algoritmos todavía se describen muy raramente: CLR tiene una sola nota al pie sobre ellos. Aún así es una gran manera de sorprender a la gente.


Editar (nota: yo no soy el que ha escrito esta respuesta, sólo quería añadir este):
Aquí hay un completo en C++ versión del código anterior:

template<class InIt, class OutIt> 
void transpose(InIt const input, OutIt const output, 
    size_t const rows, size_t const columns, 
    size_t const r1 = 0, size_t const c1 = 0, 
    size_t r2 = ~(size_t) 0, size_t c2 = ~(size_t) 0, 
    size_t const leaf = 0x20) 
{ 
    if (!~c2) { c2 = columns - c1; } 
    if (!~r2) { r2 = rows - r1; } 
    size_t const di = r2 - r1, dj = c2 - c1; 
    if (di >= dj && di > leaf) 
    { 
     transpose(input, output, rows, columns, r1, c1, (r1 + r2)/2, c2); 
     transpose(input, output, rows, columns, (r1 + r2)/2, c1, r2, c2); 
    } 
    else if (dj > leaf) 
    { 
     transpose(input, output, rows, columns, r1, c1, r2, (c1 + c2)/2); 
     transpose(input, output, rows, columns, r1, (c1 + c2)/2, r2, c2); 
    } 
    else 
    { 
     for (ptrdiff_t i1 = (ptrdiff_t) r1, i2 = (ptrdiff_t) (i1 * columns); 
      i1 < (ptrdiff_t) r2; ++i1, i2 += (ptrdiff_t) columns) 
     { 
      for (ptrdiff_t j1 = (ptrdiff_t) c1, j2 = (ptrdiff_t) (j1 * rows); 
       j1 < (ptrdiff_t) c2; ++j1, j2 += (ptrdiff_t) rows) 
      { 
       output[j2 + i1] = input[i2 + j1]; 
      } 
     } 
    } 
} 
+2

Esto sería relevante si compara los tiempos entre matrices de diferentes tamaños, no recursivo e iterativo . Pruebe la solución recursiva en una matriz de los tamaños especificados. –

+0

@Luchian Dado que ya explicó * por qué * está viendo el comportamiento, me pareció bastante interesante presentar una solución a este problema en general. – Voo

+0

Porque, me pregunto por qué una matriz más grande requiere menos tiempo para procesar, no busca un algoritmo más rápido ... –

8

Como ilustración de la explicación en Luchian Grigore's answer, aquí se muestra la apariencia de la memoria caché de la matriz para los dos casos de matrices de 64x64 y 65x65 (consulte el enlace anterior para obtener detalles sobre los números).

Los colores de las animaciones a continuación tienen el siguiente significado:

  • white - no en caché,
  • light-green - en la memoria caché,
  • bright green - acierto de caché,
  • orange - acaba de leer de la RAM ,
  • red - falta de memoria caché.

El caso 64x64:

cache presence animation for 64x64 matrix

Observe cómo casi todos los acceso a unos nuevos resultados de fila en un error de caché.Y ahora cómo se ve en el caso normal, una matriz de 65x65:

cache presence animation for 65x65 matrix

Aquí se puede ver que la mayoría de los accesos después de la inicial de calentamiento son aciertos de caché. Así es como la memoria caché de la CPU está diseñada para funcionar en general.

+0

¡Gran ilustración! –

Cuestiones relacionadas