web-dev-qa-db-fra.com

Pourquoi transposer une matrice de 512x512 est-il beaucoup plus lent que de transposer une matrice de 513x513?

Après avoir mené des expériences sur des matrices carrées de différentes tailles, un motif s'est présenté. Invariablement, transposant une matrice de taille 2^n est plus lent que la transposition de taille 2^n+1 _ . Pour les petites valeurs de n, la différence n'est pas majeure.

De grandes différences apparaissent cependant sur une valeur de 512. (du moins pour moi)

Disclaimer: Je sais que la fonction ne transpose pas réellement la matrice à cause du double échange d'éléments, mais cela ne fait aucune différence.

Suit le code:

#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;
}

Changer MATSIZE nous permet de modifier la taille (duh!). J'ai posté deux versions sur ideone:

Dans mon environnement (MSVS 2010, optimisations complètes), la différence est similaire:

  • taille 512 - moyenne 2.19 ms
  • taille 513 - moyenne 0.57 ms

Pourquoi cela se produit-il?

203
Luchian Grigore

L'explication vient de Agner Fog dans Optimisation du logiciel en C++ et réduit à la façon dont les données sont accédées et stockées dans le cache.

Pour les termes et les informations détaillées, voir le entrée du wiki sur la mise en cache , je vais le préciser ici.

Un cache est organisé en sets et lignes. À la fois, un seul jeu est utilisé, à partir duquel toutes les lignes qu'il contient peuvent être utilisées. La mémoire qu'une ligne peut mettre en miroir multipliée par le nombre de lignes nous donne la taille du cache.

Pour une adresse mémoire particulière, nous pouvons calculer quel ensemble doit la refléter avec la formule:

set = ( address / lineSize ) % numberOfsets

Ce type de formule donne idéalement une distribution uniforme dans les ensembles, car chaque adresse de mémoire a toutes les chances d'être lue (j'ai dit idéalement).

Il est clair que des chevauchements peuvent se produire. En cas d'échec du cache, la mémoire est lue dans le cache et l'ancienne valeur est remplacée. N'oubliez pas que chaque ensemble comporte un certain nombre de lignes. La dernière mémoire utilisée est remplacée par la dernière mémoire lue.

Je vais essayer de suivre un peu l'exemple d'Agner:

Supposons que chaque ensemble comporte 4 lignes contenant chacune 64 octets. Nous essayons d’abord de lire l’adresse 0x2710, qui va dans le jeu 28. Et puis nous essayons aussi de lire les adresses 0x2F00, 0x3700, 0x3F00 et 0x4700. Tous appartiennent au même ensemble. Avant la lecture 0x4700, toutes les lignes du groupe auraient été occupées. En lisant cette mémoire, une ligne existante du jeu est supprimée, la ligne qui contenait initialement 0x2710. Le problème réside dans le fait que nous lisons des adresses qui sont (pour cet exemple) 0x800 une part. C'est le pas critique (encore une fois, pour cet exemple).

La foulée critique peut également être calculée:

criticalStride = numberOfSets * lineSize

Les variables espacées criticalStride ou un multiple séparé se disputent les mêmes lignes de cache.

Ceci est la partie théorie. Ensuite, l'explication (aussi Agner, je la suis de près pour éviter de faire des erreurs):

Supposons une matrice de 64x64 (n'oubliez pas que les effets varient en fonction du cache) avec un cache de 8 Ko, 4 lignes par jeu * d'une taille de ligne de 64 octets. Chaque ligne peut contenir 8 des éléments de la matrice (int à 64 bits).

La foulée critique serait de 2 048 octets, ce qui correspond à 4 lignes de la matrice (qui est continue en mémoire).

Supposons que nous traitons la ligne 28. Nous essayons de prendre les éléments de cette ligne et de les échanger avec les éléments de la colonne 28. Les 8 premiers éléments de la ligne constituent une ligne de cache, mais ils vont dans 8 lignes de cache dans la colonne 28. N'oubliez pas que la foulée critique est composée de 4 lignes (4 éléments consécutifs dans une colonne).

Lorsque l'élément 16 est atteint dans la colonne (4 lignes de cache par jeu et 4 lignes d'écart = problème), l'élément ex-0 est expulsé du cache. Lorsque nous atteignons la fin de la colonne, toutes les lignes de cache précédentes ont été perdues et doivent être rechargées lors de l'accès à l'élément suivant (la ligne entière est écrasée).

Avoir une taille qui n'est pas un multiple de la foulée critique gâche ce scénario parfait , car nous ne traitons plus d'éléments critiques. écartés à la verticale, le nombre de rechargements de cache est donc considérablement réduit.

Une autre clause de non-responsabilité - Je viens de comprendre l'explication et j'espère pouvoir la comprendre, mais je peux me tromper. Quoi qu'il en soit, j'attends une réponse (ou une confirmation) de Mysticial . :)

184
Luchian Grigore

Luchian donne une explication de pourquoi ce problème se produit, mais j’ai pensé que ce serait une bonne idée de montrer une solution possible à ce problème et en même temps de montrer quelques algorithmes d’oubli de mémoire cache.

Votre algorithme fait essentiellement:

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

ce qui est horrible pour un processeur moderne. Une solution consiste à connaître les détails de votre système de cache et à modifier l’algorithme pour éviter ces problèmes. Fonctionne très bien tant que vous connaissez ces détails .. pas spécialement portable.

Peut-on faire mieux que ça? Oui, nous pouvons: Une approche générale à ce problème est algorithmes de cache inconscients que, comme son nom l'indique, cela évite de dépendre de tailles de cache spécifiques [1]

La solution ressemblerait à ceci:

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 peu plus complexe, mais un court test montre quelque chose d'assez intéressant sur mon ancien e8400 avec la version VS2010 x64, le code de test pour 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

Edit: À propos de l’influence de la taille: elle est beaucoup moins prononcée bien que toujours perceptible dans une certaine mesure, car nous utilisons la solution itérative comme nœud feuille au lieu de revenir à 1 (optimisation habituelle des algorithmes récursifs). Si nous définissons LEAFSIZE = 1, le cache n’a aucune influence pour moi [8193: 1214.06; 8192: 1171.62ms, 8191: 1351.07ms _ c'est à l'intérieur de la marge d'erreur, les fluctuations sont dans la zone des 100 ms; cette "référence" n'est pas quelque chose avec laquelle je serais trop à l'aise si nous voulions des valeurs parfaitement précises])

[1] Sources pour ce genre de choses: Eh bien, si vous ne pouvez pas obtenir une conférence de quelqu'un qui a travaillé avec Leiserson et ses collègues à ce sujet. Je suppose que leurs papiers sont un bon point de départ. Ces algorithmes sont encore assez rarement décrits - CLR a une seule note de bas de page à leur sujet. C'est quand même un bon moyen de surprendre les gens.


Modifier (note: ce n'est pas moi qui ai posté cette réponse; je voulais juste ajouter ceci):
Voici une version complète en C++ du code ci-dessus:

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];
            }
        }
    }
}
75
Voo

Pour illustrer l'explication de réponse de Luchian Grigore , voici à quoi ressemble la présence du cache de matrice pour les deux cas de matrices 64x64 et 65x65 (voir le lien ci-dessus pour plus de détails sur les nombres).

Les couleurs dans les animations ci-dessous signifient ce qui suit:

  • white - pas dans le cache,
  • light-green - en cache,
  • bright green - succès de cache,
  • orange - il suffit de lire dans la RAM,
  • red - cache miss.

Le cas 64x64:

cache presence animation for 64x64 matrix

Remarquez comment presque chaque accès à une nouvelle ligne entraîne l’absence de mémoire cache. Et maintenant, comment se présente le cas normal, une matrice 65x65:

cache presence animation for 65x65 matrix

Ici, vous pouvez voir que la plupart des accès après le préchauffage initial sont des accès cachés. C’est ainsi que le cache CPU est censé fonctionner en général.

47
Ruslan