Perché la trasposizione di una matrice di 512x512 è molto più lenta di una matrice di 513x513?


218

Dopo aver condotto alcuni esperimenti su matrici quadrate di diverse dimensioni, è emerso un modello. Invariabilmente, il recepimento di una matrice di dimensioni 2^nè più lento rispetto al recepimento di una matrice di dimensioni2^n+1 . Per piccoli valori di n, la differenza non è rilevante.

Grandi differenze si verificano tuttavia per un valore di 512. (almeno per me)

Disclaimer: so che la funzione non traspone effettivamente la matrice a causa del doppio scambio di elementi, ma non fa alcuna differenza.

Segue il codice:

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

La modifica MATSIZEci consente di modificare le dimensioni (duh!). Ho pubblicato due versioni su ideone:

Nel mio ambiente (MSVS 2010, ottimizzazioni complete), la differenza è simile:

  • dimensione 512 - media 2,19 ms
  • dimensione 513 - media 0,57 ms

Perché sta succedendo?


9
Il tuo codice mi sembra cache ostile.
CodesInChaos

7
E 'più o meno lo stesso problema di questa domanda: stackoverflow.com/questions/7905760/...
Mysticial

Ti va di assaporare, @CodesInChaos? (O chiunque altro.)
corazza,

@Bane Che ne dici di leggere la risposta accettata?
Codici InCos

4
@nzomkxia È quasi inutile misurare qualsiasi cosa senza ottimizzazioni. Con le ottimizzazioni disabilitate, il codice generato sarà disseminato di immondizia estranea che nasconderà altri colli di bottiglia. (come la memoria)
Mistico il

Risposte:


197

La spiegazione arriva da Agner Fog in Ottimizzazione del software in C ++ e riduce il modo in cui i dati sono accessibili e archiviati nella cache.

Per termini e informazioni dettagliate, vedere la voce wiki sulla memorizzazione nella cache , la restringerò qui.

Una cache è organizzata in set e linee . Alla volta, viene utilizzato solo un set, fuori dal quale è possibile utilizzare una qualsiasi delle righe in essa contenute. La memoria che una riga può rispecchiare per il numero di righe ci dà la dimensione della cache.

Per un determinato indirizzo di memoria, possiamo calcolare quale set dovrebbe rispecchiarlo con la formula:

set = ( address / lineSize ) % numberOfsets

Questo tipo di formula fornisce idealmente una distribuzione uniforme tra i set, perché ogni indirizzo di memoria ha la stessa probabilità di essere letto (ho detto idealmente ).

È chiaro che possono verificarsi sovrapposizioni. In caso di mancata memorizzazione nella cache, la memoria viene letta nella cache e il vecchio valore viene sostituito. Ricorda che ogni set ha un numero di righe, dalle quali l'ultima usata di recente viene sovrascritta con la memoria appena letta.

Proverò a seguire un po 'l'esempio di Agner:

Supponiamo che ogni set abbia 4 righe, ciascuna contenente 64 byte. Per prima cosa proviamo a leggere l'indirizzo 0x2710, che va in set 28. E poi abbiamo anche tentativo di leggere gli indirizzi 0x2F00, 0x3700, 0x3F00e 0x4700. Tutti questi appartengono allo stesso set. Prima di leggere 0x4700, tutte le righe del set sarebbero state occupate. Leggere quella memoria evoca una linea esistente nel set, la linea che inizialmente teneva 0x2710. Il problema sta nel fatto che leggiamo indirizzi che sono (per questo esempio) 0x800separati. Questo è il passo critico (di nuovo, per questo esempio).

Il passo critico può anche essere calcolato:

criticalStride = numberOfSets * lineSize

Variabili distanziate criticalStrideo contrapposte multiple per le stesse linee di cache.

Questa è la parte teorica. Successivamente, la spiegazione (anche Agner, la sto seguendo da vicino per evitare di commettere errori):

Supponi una matrice di 64x64 (ricorda, gli effetti variano in base alla cache) con una cache da 8kb, 4 righe per set * dimensioni della riga di 64 byte. Ogni riga può contenere 8 degli elementi nella matrice (64 bit int).

Il passo critico sarebbe di 2048 byte, che corrispondono a 4 righe della matrice (che è continua in memoria).

Supponiamo che stiamo elaborando la riga 28. Stiamo tentando di prendere gli elementi di questa riga e scambiarli con gli elementi della colonna 28. I primi 8 elementi della riga formano una riga cache, ma entreranno in 8 diversi righe della cache nella colonna 28. Ricorda, il passo critico è a 4 righe di distanza (4 elementi consecutivi in ​​una colonna).

Quando viene raggiunto l'elemento 16 nella colonna (4 righe di cache per set e 4 righe di distanza = problema), l'elemento ex-0 verrà espulso dalla cache. Quando raggiungiamo la fine della colonna, tutte le precedenti righe della cache sarebbero andate perse e avrebbero dovuto essere ricaricate all'accesso all'elemento successivo (l'intera riga viene sovrascritta).

Avere una dimensione che non è un multiplo del passo critico rovina questo scenario perfetto per il disastro, dal momento che non abbiamo più a che fare con elementi che sono il passo critico a parte sulla verticale, quindi il numero di ricariche della cache è notevolmente ridotto.

Un altro disclaimer : ho appena avuto la testa sulla spiegazione e spero di averla inchiodata, ma potrei sbagliarmi. Ad ogni modo, sto aspettando una risposta (o conferma) da Mysticial . :)


Oh e la prossima volta. Fammi un rumore metallico direttamente attraverso la Lounge . Non trovo ogni istanza di nome su SO. :) L'ho visto solo attraverso le notifiche periodiche via email.
Mistico il

@Mysticial @Luchian Grigore Uno dei miei amici mi ha detto che il suo Intel core i3PC in esecuzione Ubuntu 11.04 i386mostra quasi le stesse prestazioni con gcc 4.6 . E lo stesso vale per il mio computer Intel Core 2 Duocon mingw gcc4.4 , che è in esecuzione windows 7(32). Dimostra una grande differenza quando Compilo questo segmento con un pc un po 'più vecchio intel centrinocon gcc 4.6 , che è in esecuzione ubuntu 12.04 i386.
Hongxu Chen,

Si noti inoltre che l'accesso alla memoria in cui gli indirizzi differiscono di un multiplo di 4096 hanno una falsa dipendenza dalle CPU della famiglia Intel SnB. (ovvero lo stesso offset all'interno di una pagina). Ciò può ridurre la produttività quando alcune delle operazioni sono negozi, esp. un mix di carichi e negozi.
Peter Cordes,

which goes in set 24intendevi invece "nel set 28 "? E pensi che 32 set?
Ruslan,

Hai ragione, è il 28. :) Ho anche ricontrollato il documento collegato, per la spiegazione originale puoi andare all'organizzazione 9.2 Cache
Luchian Grigore

78

Luchian dà una spiegazione del perché questo comportamento si verifichi, ma ho pensato che sarebbe stata una buona idea mostrare una possibile soluzione a questo problema e allo stesso tempo mostrare un po 'di algoritmi ignari della cache.

Il tuo algoritmo fondamentalmente fa:

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

che è semplicemente orribile per una CPU moderna. Una soluzione è conoscere i dettagli sul sistema cache e modificare l'algoritmo per evitare tali problemi. Funziona alla grande purché tu conosca quei dettagli .. non particolarmente portatile.

Possiamo fare di meglio? Sì, possiamo: un approccio generale a questo problema sono gli algoritmi ignari della cache che, come dice il nome, evitano di dipendere da specifiche dimensioni della cache [1]

La soluzione sarebbe simile a questa:

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

Leggermente più complesso, ma un breve test mostra qualcosa di piuttosto interessante sul mio antico e8400 con rilascio VS2010 x64, codice di test per 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

Modifica: Informazioni sull'influenza delle dimensioni: è molto meno pronunciato anche se ancora evidente in una certa misura, è perché stiamo usando la soluzione iterativa come nodo foglia invece di ricorrere fino a 1 (la solita ottimizzazione per algoritmi ricorsivi). Se impostiamo LEAFSIZE = 1, la cache non ha influenza per me [ 8193: 1214.06; 8192: 1171.62ms, 8191: 1351.07ms- questo è all'interno del margine di errore, le fluttuazioni sono nell'area di 100ms; questo "benchmark" non è qualcosa con cui mi sentirei troppo a mio agio se volessimo valori completamente precisi])

[1] Fonti per questa roba: beh, se non riesci a ottenere una lezione da qualcuno che ha lavorato con Leiserson e altri su questo ... presumo che i loro documenti siano un buon punto di partenza. Tali algoritmi sono ancora descritti abbastanza raramente: CLR ha una sola nota a piè di pagina. Comunque è un ottimo modo per sorprendere le persone.


Modifica (nota: non sono io quello che ha pubblicato questa risposta; volevo solo aggiungerlo):
ecco una versione C ++ completa del codice sopra:

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
Ciò sarebbe rilevante se si confrontassero i tempi tra matrici di dimensioni diverse, non ricorsive e iterative. Prova la soluzione ricorsiva su una matrice delle dimensioni specificate.
Luchian Grigore,

@Luchian Dato che hai già spiegato perché sta vedendo il comportamento, ho pensato che fosse abbastanza interessante introdurre una soluzione a questo problema in generale.
Voo,

Perché, mi sto chiedendo perché una matrice più grande impiega un tempo più breve per l'elaborazione, non cercando un algoritmo più veloce ...
Luchian Grigore,

@Luchian Le differenze tra 16383 e 16384 sono .. 28 vs 27ms per me qui, o circa il 3,5% - non è davvero significativo. E sarei sorpreso se fosse.
Voo,

3
Potrebbe essere interessante spiegare cosa recursiveTransposefa, cioè che non riempie tanto la cache operando su piccoli riquadri (di LEAFSIZE x LEAFSIZEdimensione).
Matthieu M.

60

Come illustrazione alla spiegazione nella risposta di Luchian Grigore , ecco come appare la presenza della cache della matrice per i due casi di matrici 64x64 e 65x65 (vedere il link sopra per dettagli sui numeri).

I colori nelle animazioni seguenti indicano quanto segue:

  • bianca - non nella cache,
  • verde chiaro - nella cache,
  • verde acceso - hit della cache,
  • arancia - appena letto dalla RAM,
  • rosso - cache miss.

Il caso 64x64:

animazione presenza cache per matrice 64x64

Nota come quasi tutti gli accessi a una nuova riga causano un errore nella cache. E ora come appare il caso normale, una matrice 65x65:

animazione presenza cache per matrice 65x65

Qui puoi vedere che la maggior parte degli accessi dopo il riscaldamento iniziale sono hit della cache. Ecco come si intende che la cache della CPU funzioni in generale.


Il codice che ha generato i frame per le animazioni di cui sopra può essere visto qui .


Perché gli hit della cache di scansione verticale non vengono salvati nel primo caso, ma sono nel secondo caso? Sembra che a un determinato blocco si acceda esattamente una volta per la maggior parte dei blocchi in entrambi gli esempi.
Josiah Yoder,

Dalla risposta di @ LuchianGrigore posso vedere che è perché tutte le righe nella colonna appartengono allo stesso set.
Josiah Yoder,

Sì, ottima illustrazione. Vedo che sono alla stessa velocità. Ma in realtà non lo sono, vero?
Kelalaka,

@kelalaka sì, l'animazione FPS è la stessa. Non ho simulato il rallentamento, solo i colori sono importanti qui.
Ruslan,

Sarebbe interessante avere due immagini statiche che illustrano i diversi set di cache.
Josiah Yoder,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.