2011-09-13 11 views
15

mi viene data due funzioni per trovare il prodotto di due matrici:Perché l'ordine dei cicli in un algoritmo moltiplicatore di matrice influisce sulle prestazioni?

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

mi sono imbattuto e sagomati due eseguibili utilizzando gprof, ognuno con il codice identiche tranne che per questa funzione. Il secondo di questi è significativamente (circa 5 volte) più veloce per matrici di dimensioni 2048 x 2048. Qualche idea sul perché?

risposta

30

Credo che quello che stai vedendo sono gli effetti di locality of reference nella gerarchia di memoria del computer.

Tipicamente, la memoria del computer è segregato in diverse tipologie che hanno diverse caratteristiche prestazionali (questo è spesso chiamato il memory hierarchy). La memoria più veloce è nei registri del processore, che possono (solitamente) essere letti e letti in un singolo ciclo di clock. Tuttavia, di solito ci sono solo una manciata di questi registri (di solito non più di 1 KB). La memoria principale del computer, d'altra parte, è enorme (ad esempio, 8 GB), ma è molto più lenta da accedere. Al fine di migliorare le prestazioni, il computer è solitamente costruito fisicamente per avere several levels of caches tra il processore e la memoria principale. Queste cache sono più lente dei registri ma molto più veloci della memoria principale, quindi se si fa un accesso alla memoria che sembra qualcosa nella cache tende ad essere molto più veloce che se si debba andare alla memoria principale (tipicamente, tra 5-25x Più veloce). Quando si accede alla memoria, il processore prima controlla la cache di memoria per quel valore prima di tornare alla memoria principale per leggere il valore. Se si accede costantemente ai valori nella cache, si otterranno prestazioni molto migliori rispetto a quando si salta memoria, accesso casuale ai valori.

La maggior parte dei programmi sono scritti in modo tale che se un singolo byte in memoria viene letto in memoria, il programma in seguito legge più valori diversi da quella regione di memoria. Di conseguenza, queste cache sono in genere progettate in modo che quando si legge un singolo valore dalla memoria, un blocco di memoria (in genere da qualche parte tra 1 KB e 1 MB) di valori attorno a quel singolo valore viene anche inserito nella cache. In questo modo, se il tuo programma legge i valori vicini, sono già nella cache e non devi andare alla memoria principale.

Ora, un ultimo dettaglio - in C/C++, gli array sono memorizzati in ordine di riga maggiore, il che significa che tutti i valori in una singola riga di una matrice sono memorizzati uno accanto all'altro. Così in memoria la matrice appare come la prima riga, quindi la seconda riga, poi la terza riga, ecc.

Dato questo, diamo un'occhiata al tuo codice. La prima versione si presenta così:

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

Ora, diamo un'occhiata a quella linea più interna di codice. Ad ogni iterazione, il valore di k sta cambiando in aumento. Ciò significa che quando si esegue il ciclo più interno, è probabile che ciascuna iterazione del ciclo abbia un errore di cache durante il caricamento del valore di b[k][j].La ragione di ciò è che, poiché la matrice è memorizzata in ordine di riga principale, ogni volta che si incrementa k, si salta su un'intera riga della matrice e si passa molto più in profondità nella memoria, probabilmente oltre i valori che si sono memorizzati nella cache . Tuttavia, non si ha una mancanza quando si guarda su c[i][j] (dal i e j sono gli stessi), né probabilmente si perde a[i][k], perché i valori sono in ordine di riga principale e se il valore di a[i][k] viene memorizzato nella cache dal precedente iterazione, il valore di a[i][k] letto su questa iterazione proviene da una posizione di memoria adiacente. Di conseguenza, in ogni iterazione del ciclo più interno, è probabile che si abbia un errore di cache.

Ma considerare questa seconda versione:

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

Ora, dal momento che si sta sempre più j ad ogni iterazione, pensiamo a quante di cache manca è probabile che sia sulla dichiarazione più interno. Poiché i valori sono in ordine di riga principale, è probabile che il valore di c[i][j] sia in-cache, poiché il valore di c[i][j] dall'iterazione precedente è probabilmente memorizzato nella cache e pronto per essere letto. Analogamente, b[k][j] è probabilmente memorizzato nella cache e poiché i e k non cambiano, è probabile che lo stesso sia a[i][k]. Ciò significa che su ogni iterazione del ciclo interno, è probabile che non ci siano errori di cache.

Nel complesso, questo significa che è improbabile che la seconda versione del codice abbia errori di cache su ogni iterazione del ciclo, mentre la prima versione quasi certamente lo farà. Di conseguenza, è probabile che il secondo ciclo sia più veloce del primo, come hai visto.

È interessante notare che molti compilatori stanno iniziando ad avere il supporto del prototipo per rilevare che la seconda versione del codice è più veloce della prima. Alcuni cercheranno di riscrivere automaticamente il codice per massimizzare il parallelismo. Se si dispone di una copia di Purple Dragon Book, il capitolo 11 illustra come funzionano questi compilatori.

Inoltre, è possibile ottimizzare ulteriormente le prestazioni di questo ciclo utilizzando loop più complessi. Una tecnica denominata blocking, ad esempio, può essere utilizzata per aumentare notevolmente le prestazioni suddividendo l'array in sottoregioni che possono essere trattenute più a lungo nella cache, quindi utilizzando più operazioni su questi blocchi per calcolare il risultato complessivo.

Spero che questo aiuti!

+1

+1 Ottima spiegazione! Anche il suggerimento @Kerrek SB fatto sul debug della cache aggiunge molti più dettagli tecnici. – rbaleksandar

0

Probabilmente il secondo deve saltare in memoria di più per accedere agli elementi dell'array. Potrebbe essere anche qualcos'altro - puoi controllare il codice compilato per vedere cosa sta realmente accadendo.

5

Questa potrebbe essere la località di memoria. Quando riordini il ciclo, la memoria necessaria nel ciclo più interno è più vicina e può essere memorizzata nella cache, mentre nella versione inefficiente è necessario accedere alla memoria dall'intero set di dati.

Il modo per verificare questa ipotesi è eseguire un debugger di cache (come cachegrind) sui due pezzi di codice e vedere quanti errori di cache si verificano.

+0

+1 per aver menzionato il cumulo della cache –

0

Oltre alla località di memoria è presente anche l'ottimizzazione del compilatore. Uno fondamentale per le operazioni vettoriali e matriciali è lo srotolamento del ciclo.

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

Si può vedere in questo ciclo interno i e j non cambiano. Ciò significa che può essere riscritta come

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

Si può vedere ci saranno

  • quattro volte meno loop e gli accessi al c [i] [j]
  • a [i] [k] si accede continuamente alla memoria
  • gli accessi di memoria e i multipli possono essere pipeline (quasi simultaneamente) nella CPU.

Cosa succede se n non è un multiplo di 4 o 6 o 8? (o qualunque cosa il compilatore decida di srotolare a) Il compilatore gestisce questo ordine per te.;)

Per accelerare più velocemente questa soluzione, è possibile provare a trasporre prima la matrice b. Questo è un piccolo lavoro in più e la codifica, ma significa che gli accessi a B-trasposti sono anche continui in memoria. (Mentre stai scambiando [k] con [j])

Un'altra cosa che puoi fare per migliorare le prestazioni è la multi-thread della moltiplicazione. Questo può migliorare le prestazioni di un fattore 3 su una CPU a 4 core.

Infine si potrebbe considerare l'utilizzo float o double Si potrebbe pensare int sarebbe più veloce, tuttavia, che non è sempre il caso come operazioni in virgola mobile possono essere più pesantemente ottimizzato (sia a livello hardware e il compilatore)

Il secondo esempio ha c [i] [j] sta cambiando su ogni iterazione che rende più difficile da ottimizzare.

Problemi correlati