Perché si riscontra un enorme aumento delle prestazioni nella moltiplicazione di array 2048x2048 rispetto a 2047x2047?


127

Sto realizzando alcuni benchmark di moltiplicazione di matrici, come precedentemente menzionato in Perché MATLAB è così veloce nella moltiplicazione di matrici?

Ora ho un altro problema, quando si moltiplicano due matrici 2048x2048, c'è una grande differenza tra C # e altri. Quando provo a moltiplicare solo le matrici 2047x2047, sembra normale. Aggiunti altri anche per la comparazione.

1024x1024 - 10 secondi.

1027x1027 - 10 secondi.

2047x2047 - 90 secondi.

2048x2048 - 300 secondi.

2049x2049 - 91 secondi. (aggiornare)

2500x2500 - 166 secondi

Questa è una differenza di tre minuti e mezzo per il caso 2k per 2k.

utilizzando array 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
Questa sarebbe una grande domanda d'esame per una programmazione di livello C avanzata o una classe di progettazione OS ;-)
Dana the Sane,

Hai provato a testare matrici [,] e frastagliate [] [] sia a 32 che a 64 bit? Ho provato solo poche volte ma frastagliato sembrava più in linea con i tuoi risultati, ma i frastagliati a 64 bit erano alti, non so se ci siano euristiche nella jit applicabili a questa situazione o se la sua cache fosse correlata come precedentemente suggerito. Se vuoi una soluzione GPGPU c'è research.microsoft.com/en-us/projects/accelerator che dovrebbe essere competitivo con i tempi negli altri tuoi post.
Kris,

Domanda un po 'ingenua, ma quante operazioni (aggiungendo / moltiplicando) sono coinvolte nella moltiplicazione di due matrici quadrate?
Nick T,

Risposte:


61

Questo probabilmente ha a che fare con conflitti nella cache L2.

La cache mancante su matice1 non è il problema perché si accede in sequenza. Tuttavia per matice2 se una colonna intera si inserisce in L2 (cioè quando si accede a matice2 [0, 0], matice2 [1, 0], matice2 [2, 0] ... ecc., Nulla viene sfrattato) di quanto non vi siano problemi con cache manca anche con matice2.

Ora per approfondire il funzionamento delle cache, se l'indirizzo byte della variabile è X, la riga della cache per essa sarebbe (X >> 6) e (L - 1). Dove L è il numero totale di righe della cache nella cache. L è sempre la potenza di 2. Il sei deriva dal fatto che 2 ^ 6 == 64 byte è la dimensione standard della riga della cache.

Cosa significa questo? Bene significa che se ho l'indirizzo X e l'indirizzo Y e (X >> 6) - (Y >> 6) è divisibile per L (cioè una grande potenza di 2), saranno memorizzati nella stessa cache.

Ora per tornare al tuo problema qual è la differenza tra il 2048 e il 2049,

quando 2048 è la tua taglia:

se prendi & matice2 [x, k] e & matice2 [y, k] la differenza (& matice2 [x, k] >> 6) - (& matice2 [y, k] >> 6) sarà divisibile per 2048 * 4 (dimensione di galleggiante). Quindi una grande potenza di 2.

Pertanto, a seconda delle dimensioni del tuo L2, avrai molti conflitti di linea nella cache e utilizzerai solo una piccola parte del tuo L2 per memorizzare una colonna, quindi non sarai in grado di archiviare l'intera colonna nella tua cache, quindi otterrai prestazioni scadenti .

Quando la dimensione è 2049, la differenza è 2049 * 4 che non è la potenza di 2, quindi avrai meno conflitti e la tua colonna si adatterà tranquillamente alla tua cache.

Ora per testare questa teoria ci sono un paio di cose che puoi fare:

Alloca il tuo array matice2 come questo matice2 [razmor, 4096], ed esegui con razmor = 1024, 1025 o qualsiasi dimensione, e dovresti vedere prestazioni pessime rispetto a quanto avevi prima. Questo perché allinei con forza tutte le colonne in conflitto tra loro.

Quindi prova matice2 [razmor, 4097] ed eseguilo con qualsiasi dimensione e dovresti vedere prestazioni molto migliori.


Hai fatto un errore nei tuoi ultimi 2 paragrafi? Entrambi i trys sono esattamente gli stessi. :)
Xeo,

Anche l'associatività della cache ha un ruolo.
Ben Jackson,

20

Probabilmente un effetto di memorizzazione nella cache. Con dimensioni della matrice che sono grandi potenze di due e una dimensione della cache che è anche una potenza di due, puoi finire solo usando una piccola frazione della tua cache L1, rallentando molto le cose. La moltiplicazione della matrice ingenua è generalmente vincolata dalla necessità di recuperare i dati nella cache. Gli algoritmi ottimizzati che utilizzano la piastrellatura (o algoritmi ignari della cache) si concentrano sull'uso migliore della cache L1.

Se cronometri altre coppie (2 ^ n-1,2 ^ n), mi aspetto che vedrai effetti simili.

Per spiegare in modo più completo, nel ciclo interno, dove accedi a matice2 [m, k], è probabile che matice2 [m, k] e matice2 [m + 1, k] siano sfalsati l'uno dall'altro di 2048 * sizeof (float) e quindi mappare allo stesso indice nella cache L1. Con una cache associativa N-way in genere avrai 1-8 posizioni cache per tutte queste. Quindi quasi tutti questi accessi attiveranno uno sfratto della cache L1 e il recupero dei dati da una cache o memoria principale più lenta.


+1. Sembra probabile. Bisogna stare attenti con l'associatività della cache.
Macke,

16

Ciò potrebbe avere a che fare con la dimensione della cache della CPU. Se 2 righe della matrice matrice non si adattano, perderai tempo a scambiare elementi dalla RAM. Gli elementi 4095 extra potrebbero essere sufficienti per impedire il montaggio delle file.

Nel tuo caso, 2 righe per le matrici 2d 2047 rientrano in 16 KB di memoria (presupponendo tipi a 32 bit). Ad esempio, se si dispone di una cache L1 (la più vicina alla CPU sul bus) di 64 KB, è possibile inserire nella cache almeno 4 righe (di 2047 * 32) contemporaneamente. Con le righe più lunghe se è richiesta un'imbottitura che spinge le coppie di righe oltre i 16 KB, le cose iniziano a diventare confuse. Inoltre, ogni volta che "manchi" la cache, lo scambio di dati da un'altra cache o la memoria principale ritarda le cose.

La mia ipotesi è che la varianza nei tempi di esecuzione che si verificano con matrici di dimensioni diverse è influenzata dall'efficacia con cui il sistema operativo può utilizzare la cache disponibile (e alcune combinazioni sono solo problematiche). Naturalmente questa è tutta una grossa semplificazione da parte mia.


2
ma è molto improbabile che abbia 16,7 MB di cache della CPU
Marino Šimić,

Ho aggiornato i risultati con 2049x2049 - 91 secondi. Se si trattasse di "problema di cache", non dovrebbero essere ancora 300+ s?
Lupo,

@Marino la risposta è stata aggiornata per tenerne conto.
Dana the Sane,

1
Sento che nessuna di queste spiegazioni è in grado di affrontare adeguatamente i nuovi dettagli riguardanti le varie e sparse dimensioni che suscitano il problema, con gli altri che non sono interessati.
Ken Rockot,

2
Non credo che questa spiegazione sia corretta. Il problema risiede nel non utilizzare completamente la capacità della cache a causa di conflitti nella linea della cache quando la dimensione è pari a 2. Inoltre, il sistema operativo non ha davvero nulla a che fare con le cache, perché non è il sistema operativo che decide cosa memorizzare nella cache e cosa sfrattare, è tutto nell'hardware. Il sistema operativo ha qualcosa a che fare con l'allineamento dei dati, ma in questo caso si tratta di come C # decida di allocare i dati e di come rappresentare l'array 2D in memoria, il sistema operativo non ha nulla a che fare con esso.
zviadm,


5

Dato che il tempo sta calando a dimensioni maggiori non sarebbe più probabile che si verifichino conflitti di cache, specialmente con potenze di 2 per le dimensioni problematiche della matrice? Non sono un esperto di problemi di memorizzazione nella cache, ma informazioni eccellenti sui problemi di prestazioni relative alla cache qui .


La sezione 5 del link sull'associatività della cache sembra applicarsi in particolare.
Dana the Sane,

4

Man mano che si accede matice2all'array in verticale, questo verrà scambiato dentro e fuori dalla cache molto di più. Se si esegue il mirroring dell'array in diagonale, in modo da poter accedere utilizzando [k,m]invece di [m,k], il codice verrà eseguito molto più velocemente.

Ho provato questo per matrici 1024x1024 ed è circa il doppio più veloce. Per le matrici 2048x2048 è circa dieci volte più veloce.


Questo non spiega perché il 2049 sia più veloce del 2048.
Macke,

@Macke: questo perché supera alcuni limiti nella memoria cache, in modo che ci siano molti più errori nella cache.
Guffa,

Perché il downvote? Se non dici ciò che pensi sia sbagliato, non può migliorare la risposta.
Guffa,

Un altro downvote senza alcuna spiegazione ... È che la mia risposta ha troppi "probabilmente", "indovinare" e "dovrebbe", come le risposte che ottengono il maggior numero di voti ...?
Guffa,

4

Alias ​​della cache

O cache thrashing , se posso coniare un termine.

Le cache funzionano indicizzando con bit di ordine basso e tag con bit di ordine elevato.

Immaginando che la cache contenga 4 parole e che la matrice sia 4 x 4. Quando si accede a una colonna e la riga ha una potenza di due in lunghezza, ciascun elemento della colonna in memoria verrà mappato allo stesso elemento cache.

Una potenza di due più uno è in realtà ottimale per questo problema. Ogni nuovo elemento colonna verrà mappato allo slot della cache successivo esattamente come se accedesse per riga.

Nella vita reale, un tag copre più indirizzi in sequenza crescente che memorizzeranno nella cache diversi elementi adiacenti in una riga. Spostando il bucket su cui ogni nuova riga è mappata, attraversare la colonna non sostituisce la voce precedente. Quando viene attraversata la colonna successiva, l'intera cache verrà riempita con righe diverse e ogni sezione di riga che si adatta alla cache verrà visualizzata per diverse colonne.

Poiché la cache è molto più veloce della DRAM (principalmente in virtù del fatto che è su chip), la percentuale di hit è tutto.


2

Sembra che tu abbia raggiunto un limite di dimensione della cache o che tu abbia qualche problema di ripetibilità nei tuoi tempi.

Qualunque sia il problema, semplicemente non dovresti scrivere tu stesso la moltiplicazione di matrice in C # e invece utilizzare una versione ottimizzata di BLAS. Quella dimensione della matrice dovrebbe essere moltiplicata per meno di un secondo su qualsiasi macchina moderna.


1
Sono a conoscenza di BLAS, ma il compito non era quello di renderlo il più veloce possibile, ma di scriverlo e testarlo in varie lingue. Questo è un problema molto strano per me e Iam è davvero curioso di sapere perché i risultati sono come sono.
Lupo,

3
@ Lupo, troverei difficile eccitarmi se qualcosa che dovrebbe richiedere un secondo impiega 90 secondi o 300 secondi.
David Heffernan,

4
Il modo migliore per imparare come funziona qualcosa è scriverlo tu stesso e vedere come puoi migliorare la tua implementazione; questo è (si spera) ciò che Wolf sta facendo.
Callum Rogers,

@Callum Rogers, d'accordo. È così che ho imparato l'importanza delle dimensioni del buffer nelle operazioni di copia dei file.
Kelly S. francese,

1

L'utilizzo efficace della gerarchia della cache è molto importante. È necessario assicurarsi che le matrici multidimensionali dispongano di dati in una buona disposizione, che può essere realizzata mediante affiancamento . Per fare ciò è necessario memorizzare l'array 2D come array 1D insieme a un meccanismo di indicizzazione. Il problema con il metodo tradizionale è che sebbene due elementi di array adiacenti che si trovano nella stessa riga siano uno accanto all'altro in memoria, due elementi adiacenti nella stessa colonna saranno separati da elementi W in memoria, dove W è il numero di colonne . La piastrellatura può fare una differenza prestazionale fino a dieci.


Hmm - eppure un array dichiarato come 2D (float [,] matice = new float [rozmer, rozmer];) è sempre e solo allocato nella RAM come un array monodimensionale e calcoli riga / falcata effettuati sotto il cofano. Quindi perché dichiararlo come 1D e fare calcoli manuali su fila / falcata sarebbe più veloce? Vuoi dire che sol'n è allocare un array grande come array di tessere più piccole ognuna delle quali può essere inserita nella cache dove l'array grande non lo farebbe?
Eric M,

1
Se la tua libreria o qualunque altro strumento tu stia utilizzando, la piastrellatura non è necessaria. Ma se dovessi usare un array 2D tradizionale, per esempio, in C / C ++, la piastrellatura migliorerebbe le prestazioni.
Arlen,

0

Ho il sospetto che sia il risultato di qualcosa chiamato " Inondazioni sequenziali ". Ciò è che stai cercando di scorrere l'elenco di oggetti leggermente più grande della dimensione della cache, quindi ogni singola richiesta a un elenco (array) deve essere fatta dal ram e non otterrai una singola cache colpire.

Nel tuo caso, esegui il ciclo tra gli array 2048 indici 2048 volte, ma hai spazio solo per il 2047 (probabilmente a causa di un sovraccarico dalla struttura dell'array), quindi ogni volta che accedi a una posizione dell'array, devi ottenere questa posizione dell'array da ram. Viene quindi archiviato nella cache, ma appena prima di essere riutilizzato, viene scaricato. Quindi la cache è essenzialmente inutile, portando a tempi di esecuzione molto più lunghi.


1
Non corretto. Il 2049 è più veloce del 2048, il che confuta il tuo reclamo.
Macke,

@Macke: è del tutto possibile. Ma c'è una leggera possibilità che la politica della cache utilizzata nel suo processore possa ancora fare questa decisione. Non è molto probabile, ma non è impensabile.
Automatico,
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.