Che cos'è un codice "cache-friendly"?


739

Qual è la differenza tra il " codice ostile della cache " e il codice " amico della cache "?

Come posso assicurarmi di scrivere codice efficiente nella cache?


28
Questo potrebbe darti un suggerimento: stackoverflow.com/questions/9936132/…
Robert Martin

4
Inoltre, tenere presente la dimensione di una riga della cache. Su processori moderni, è spesso 64 byte.
John Dibling,

3
Ecco un altro ottimo articolo. I principi si applicano ai programmi C / C ++ su qualsiasi sistema operativo (Linux, MaxOS o Windows): lwn.net/Articles/255364
paulsm4


Risposte:


966

Preliminari

Sui computer moderni, solo le strutture di memoria di livello più basso (i registri ) possono spostare i dati in singoli cicli di clock. Tuttavia, i registri sono molto costosi e la maggior parte dei core di computer ha meno di una dozzina di registri (da poche centinaia a forse un totale di mille byte ). All'altra estremità dello spettro di memoria ( DRAM ), la memoria è molto economica (cioè letteralmente milioni di volte più economica ) ma richiede centinaia di cicli dopo una richiesta per ricevere i dati. Per colmare questo divario tra super veloce e costoso e super lento ed economico ci sono le memorie cache, denominato L1, L2, L3 in termini di velocità e costi decrescenti. L'idea è che la maggior parte del codice in esecuzione colpirà spesso un piccolo insieme di variabili e il resto (un insieme molto più ampio di variabili) raramente. Se il processore non riesce a trovare i dati nella cache L1, cerca nella cache L2. Se non presente, quindi cache L3 e, se non presente, memoria principale. Ognuna di queste "mancanze" è costosa nel tempo.

(L'analogia è che la memoria cache è la memoria di sistema, poiché la memoria di sistema è troppo memoria del disco rigido. La memoria del disco rigido è super economica ma molto lenta).

La memorizzazione nella cache è uno dei metodi principali per ridurre l'impatto della latenza . Per parafrasare Herb Sutter (cfr. Link sotto): aumentare la larghezza di banda è facile, ma non possiamo fare a meno della latenza .

I dati vengono sempre recuperati attraverso la gerarchia di memoria (dal più piccolo == dal più veloce al più lento). Un hit / miss della cache di solito si riferisce a un hit / miss nel livello più alto di cache nella CPU - per livello più alto intendo il più grande == il più lento. Il tasso di hit della cache è cruciale per le prestazioni poiché ogni mancanza di cache comporta il recupero di dati dalla RAM (o peggio ...) che richiede molto tempo (centinaia di cicli per la RAM, decine di milioni di cicli per l'HDD). In confronto, la lettura dei dati dalla cache (di livello più alto) richiede in genere solo una manciata di cicli.

Nelle architetture informatiche moderne, il collo di bottiglia delle prestazioni sta lasciando morire la CPU (es. Accesso alla RAM o superiore). Questo peggiorerà nel tempo. L'aumento della frequenza del processore non è attualmente rilevante per aumentare le prestazioni. Il problema è l'accesso alla memoria. Pertanto, attualmente gli sforzi di progettazione dell'hardware nelle CPU si concentrano fortemente sull'ottimizzazione di cache, prefetch, pipeline e concorrenza. Ad esempio, le moderne CPU spendono circa l'85% dei die nelle cache e fino al 99% per l'archiviazione / lo spostamento dei dati!

C'è molto da dire sull'argomento. Ecco alcuni grandi riferimenti su cache, gerarchie di memoria e corretta programmazione:

Concetti principali per codice compatibile con cache

Un aspetto molto importante del codice compatibile con la cache riguarda il principio di localizzazione , il cui obiettivo è quello di posizionare i dati correlati in memoria per consentire una cache efficiente. In termini di cache della CPU, è importante essere consapevoli delle linee della cache per capire come funziona: come funzionano le linee della cache?

I seguenti aspetti particolari sono di grande importanza per ottimizzare la memorizzazione nella cache:

  1. Località temporale : quando si accede a una determinata posizione di memoria, è probabile che si acceda di nuovo alla stessa posizione nel prossimo futuro. Idealmente, questa informazione verrà comunque memorizzata nella cache a quel punto.
  2. Località spaziale : si riferisce alla collocazione di dati correlati vicini. La memorizzazione nella cache avviene su molti livelli, non solo nella CPU. Ad esempio, quando leggi dalla RAM, in genere viene recuperato un pezzo di memoria più grande di quello che è stato specificamente richiesto perché molto spesso il programma richiederà presto quei dati. Le cache HDD seguono la stessa linea di pensiero. In particolare per le cache della CPU, la nozione di linee di cache è importante.

Usare appropriato contenitori

Un semplice esempio di cache-friendly e cache-friendly è è std::vectorcontro std::list. Gli elementi di un std::vectorsono archiviati in una memoria contigua e, in quanto tale, accedervi è molto più adatto alla cache rispetto all'accesso agli elementi in a std::list, che memorizza il suo contenuto ovunque. Ciò è dovuto alla località spaziale.

Una bella illustrazione di questo è data da Bjarne Stroustrup in questo video di YouTube (grazie a @Mohammad Ali Baydoun per il link!).

Non trascurare la cache nella struttura dei dati e nella progettazione dell'algoritmo

Quando possibile, prova ad adattare le strutture dei dati e l'ordine dei calcoli in modo da consentire il massimo utilizzo della cache. Una tecnica comune a questo proposito è il blocco della cache (versione di Archive.org) , che è di estrema importanza nel calcolo ad alte prestazioni (cfr. Ad esempio ATLAS ).

Conoscere e sfruttare la struttura implicita dei dati

Un altro semplice esempio, che molte persone nel campo a volte dimenticano è la colonna maggiore (es. ,) vs. ordinamento per riga principale (es. ,) per la memorizzazione di array bidimensionali. Ad esempio, considera la seguente matrice:

1 2
3 4

Nell'ordinamento delle righe principali, questo viene archiviato in memoria come 1 2 3 4; nell'ordine delle colonne principali, questo verrà archiviato come 1 3 2 4. È facile vedere che le implementazioni che non sfruttano questo ordinamento si imbatteranno rapidamente in problemi di cache (facilmente evitabili!). Sfortunatamente, vedo cose del genere molto spesso nel mio dominio (machine learning). @MatteoItalia ha mostrato questo esempio in modo più dettagliato nella sua risposta.

Quando si recupera un determinato elemento di una matrice dalla memoria, anche gli elementi vicini verranno recuperati e archiviati in una riga della cache. Se l'ordinamento viene sfruttato, ciò comporterà un minor numero di accessi alla memoria (poiché i successivi pochi valori necessari per i calcoli successivi sono già in una riga della cache).

Per semplicità, supponiamo che la cache comprenda una singola riga della cache che può contenere 2 elementi matrice e che quando un dato elemento viene recuperato dalla memoria, anche quello successivo lo è. Supponiamo che vogliamo prendere la somma di tutti gli elementi nella matrice di esempio 2x2 sopra (chiamiamola M):

Sfruttando l'ordinamento (ad esempio cambiando prima l'indice di colonna in ):

M[0][0] (memory) + M[0][1] (cached) + M[1][0] (memory) + M[1][1] (cached)
= 1 + 2 + 3 + 4
--> 2 cache hits, 2 memory accesses

Non sfruttare l'ordine (ad esempio cambiando prima l'indice di riga in ):

M[0][0] (memory) + M[1][0] (memory) + M[0][1] (memory) + M[1][1] (memory)
= 1 + 3 + 2 + 4
--> 0 cache hits, 4 memory accesses

In questo semplice esempio, sfruttare l'ordinamento raddoppia circa la velocità di esecuzione (poiché l'accesso alla memoria richiede molti più cicli rispetto al calcolo delle somme). In pratica, la differenza di prestazioni può essere molto maggiore.

Evita rami imprevedibili

Le architetture moderne presentano pipeline e compilatori che stanno diventando molto bravi a riordinare il codice per ridurre al minimo i ritardi dovuti all'accesso alla memoria. Quando il codice critico contiene rami (imprevedibili), è difficile o impossibile precaricare i dati. Ciò porterà indirettamente a più mancati cache.

Questo è spiegato molto bene qui (grazie a @ 0x90 per il collegamento): Perché l'elaborazione di un array ordinato è più veloce dell'elaborazione di un array non ordinato?

Evita le funzioni virtuali

Nel contesto di , i virtualmetodi rappresentano un problema controverso per quanto riguarda i mancati cache (esiste un consenso generale sul fatto che dovrebbero essere evitati quando possibile in termini di prestazioni). Le funzioni virtuali possono indurre errori nella cache durante la ricerca, ma ciò accade solo se la funzione specifica non viene chiamata spesso (altrimenti verrebbe probabilmente memorizzata nella cache), quindi questo è considerato un problema da alcuni. Per riferimento a questo problema, controlla: Qual è il costo delle prestazioni di avere un metodo virtuale in una classe C ++?

Problemi comuni

Un problema comune nelle architetture moderne con cache multiprocessore si chiama falsa condivisione . Ciò si verifica quando ogni singolo processore sta tentando di utilizzare i dati in un'altra area di memoria e tenta di archiviarli nella stessa riga della cache . Questo fa sì che la riga della cache - che contiene dati che un altro processore può usare - venga sovrascritta più e più volte. In effetti, thread diversi si fanno attendere a vicenda inducendo errori di cache in questa situazione. Vedi anche (grazie a @Matt per il link): come e quando allinearlo alla dimensione della linea della cache?

Un sintomo estremo della scarsa memorizzazione nella cache della memoria RAM (che probabilmente non è ciò che intendi in questo contesto) è il cosiddetto thrashing . Ciò si verifica quando il processo genera continuamente errori di pagina (ad es. Accede alla memoria che non si trova nella pagina corrente) che richiedono l'accesso al disco.


27
forse potresti ampliare un po 'la risposta spiegando anche che, nel codice multithread, i dati possono anche essere troppo locali (es. falsa condivisione)
TemplateRex

2
Possono esserci tanti livelli di cache quanti i progettisti di chip ritengono sia utile. Generalmente stanno bilanciando la velocità contro la dimensione. Se potessi rendere la tua cache L1 grande quanto L5 e altrettanto veloce, ti servirebbe solo L1.
Rafael Baptista,

24
Mi rendo conto che i posti vuoti di accordo non sono stati approvati su StackOverflow ma questa è onestamente la risposta più chiara, migliore, che abbia mai visto finora. Ottimo lavoro, Marc.
Jack Aidley

2
@JackAidley grazie per la tua lode! Quando ho visto la quantità di attenzione ricevuta da questa domanda, ho pensato che molte persone potrebbero essere interessate a una spiegazione piuttosto ampia. Sono contento che sia utile.
Marc Claesen,

1
Quello che non hai menzionato è che le strutture di dati compatibili con la cache sono progettate per adattarsi all'interno di una linea di cache e allineate alla memoria per fare un uso ottimale delle linee di cache. Ottima risposta però! eccezionale.
Matt,

140

Oltre alla risposta di @Marc Claesen, penso che un classico esempio istruttivo di codice ostile alla cache sia il codice che analizza un array bidimensionale C (ad es. Un'immagine bitmap) per quanto riguarda le colonne invece che per le righe.

Gli elementi adiacenti in una riga sono anche adiacenti nella memoria, quindi accedervi in ​​sequenza significa accedervi in ​​ordine crescente di memoria; questo è compatibile con la cache, poiché la cache tende a precaricare blocchi contigui di memoria.

Al contrario, l'accesso a tali elementi per quanto riguarda la colonna è ostile alla cache, poiché gli elementi sulla stessa colonna sono distanti in memoria l'uno dall'altro (in particolare, la loro distanza è uguale alla dimensione della riga), quindi quando si utilizza questo modello di accesso si stanno saltando in giro nella memoria, potenzialmente sprecando lo sforzo della cache di recuperare gli elementi vicini nella memoria.

E tutto ciò che serve per rovinare la performance è passare

// Cache-friendly version - processes pixels which are adjacent in memory
for(unsigned int y=0; y<height; ++y)
{
    for(unsigned int x=0; x<width; ++x)
    {
        ... image[y][x] ...
    }
}

per

// Cache-unfriendly version - jumps around in memory for no good reason
for(unsigned int x=0; x<width; ++x)
{
    for(unsigned int y=0; y<height; ++y)
    {
        ... image[y][x] ...
    }
}

Questo effetto può essere abbastanza drammatico (diversi ordini di grandezza in velocità) in sistemi con piccole cache e / o lavorando con array di grandi dimensioni (ad esempio immagini da 10+ megapixel a 24 bpp su macchine attuali); per questo motivo, se devi eseguire molte scansioni verticali, spesso è meglio ruotare prima l'immagine di 90 gradi ed eseguire successivamente le varie analisi, limitando il codice ostile alla cache solo alla rotazione.


Err, dovrebbe essere x <larghezza?
mowwwalker,

13
I moderni editor di immagini utilizzano i riquadri come memoria interna, ad esempio blocchi di 64x64 pixel. Questo è molto più intuitivo per la cache per le operazioni locali (posizionando un tocco, eseguendo un filtro di sfocatura) perché i pixel vicini sono vicini nella memoria in entrambe le direzioni, il più delle volte.
maxy,

Ho provato a cronometrare un esempio simile sulla mia macchina e ho scoperto che i tempi erano gli stessi. Qualcun altro ha provato a cronometrarlo?
gsingh2011,

@ I3arnon: no, il primo è compatibile con la cache, poiché normalmente nelle matrici C sono archiviate nell'ordine delle righe principali (ovviamente se l'immagine per qualche motivo è memorizzata nell'ordine delle colonne maggiori è vero il contrario).
Matteo Italia,

1
@Gauthier: sì, il primo frammento è quello buono; Penso che quando ho scritto questo stavo pensando sulla falsariga di "Tutto ciò che serve [per rovinare le prestazioni di un'applicazione funzionante] è passare da ... a ..."
Matteo Italia,

88

L'ottimizzazione dell'utilizzo della cache si riduce in gran parte a due fattori.

Località di riferimento

Il primo fattore (a cui altri hanno già accennato) è la località di riferimento. La località di riferimento ha in realtà due dimensioni: spazio e tempo.

  • Spaziale

La dimensione spaziale si riduce anche a due cose: in primo luogo, vogliamo comprimere le nostre informazioni densamente, quindi più informazioni si adatteranno a quella memoria limitata. Ciò significa (ad esempio) che è necessario un notevole miglioramento della complessità computazionale per giustificare le strutture di dati basate su piccoli nodi uniti da puntatori.

In secondo luogo, vogliamo che le informazioni che verranno elaborate insieme siano anche localizzate insieme. Una cache tipica funziona in "linee", il che significa che quando si accede ad alcune informazioni, altre informazioni agli indirizzi vicini verranno caricate nella cache con la parte che abbiamo toccato. Ad esempio, quando tocco un byte, la cache potrebbe caricare 128 o 256 byte vicino a quello. Per trarne vantaggio, in genere si desidera che i dati siano organizzati in modo da massimizzare la probabilità che vengano utilizzati anche altri dati caricati contemporaneamente.

Solo per un esempio davvero banale, questo può significare che una ricerca lineare può essere molto più competitiva con una ricerca binaria di quanto ti aspetteresti. Dopo aver caricato un elemento da una riga della cache, l'utilizzo del resto dei dati in quella riga della cache è quasi gratuito. Una ricerca binaria diventa notevolmente più veloce solo quando i dati sono sufficientemente grandi da consentire alla ricerca binaria di ridurre il numero di righe della cache a cui si accede.

  • Tempo

La dimensione temporale indica che quando si eseguono alcune operazioni su alcuni dati, si desidera (per quanto possibile) eseguire tutte le operazioni su quei dati contemporaneamente.

Dal momento che hai codificato questo come C ++, Io punto ad un classico esempio di una progettazione relativamente cache-ostile: std::valarray. valarraysovraccarichi operatori più aritmetici, quindi posso (per esempio) dicono a = b + c + d;(dove a, b, ce dsono tutti valarray) per fare elemento saggio aggiunta di tali array.

Il problema è che attraversa una coppia di input, inserisce i risultati in modo temporaneo, attraversa un'altra coppia di input e così via. Con molti dati, il risultato di un calcolo può scomparire dalla cache prima di essere utilizzato nel calcolo successivo, quindi finiamo per leggere (e scrivere) i dati ripetutamente prima di ottenere il nostro risultato finale. Se ogni elemento del risultato finale sarà qualcosa di simile (a[n] + b[n]) * (c[n] + d[n]);, saremmo in genere preferiscono leggere ogni a[n], b[n], c[n]e d[n]una volta, fare il calcolo, scrivere il risultato, incremento ne ripetere 'til abbiamo finito. 2

Condivisione di linea

Il secondo fattore principale è evitare la condivisione della linea. Per capirlo, probabilmente è necessario eseguire il backup e osservare un po 'come sono organizzate le cache. La forma più semplice di cache è mappata direttamente. Ciò significa che un indirizzo nella memoria principale può essere memorizzato solo in un punto specifico nella cache. Se stiamo usando due elementi di dati che si mappano nello stesso punto della cache, funziona male: ogni volta che utilizziamo un elemento di dati, l'altro deve essere scaricato dalla cache per fare spazio all'altro. Il resto della cache potrebbe essere vuoto, ma quegli elementi non useranno altre parti della cache.

Per evitare ciò, la maggior parte delle cache sono quelle che vengono chiamate "set associative". Ad esempio, in una cache associativa set a 4 vie, qualsiasi elemento della memoria principale può essere archiviato in una delle 4 posizioni diverse nella cache. Pertanto, quando la cache carica un elemento, cerca l' elemento 3 utilizzato meno di recente tra questi quattro, lo svuota nella memoria principale e carica il nuovo elemento al suo posto.

Il problema è probabilmente abbastanza ovvio: per una cache a mappatura diretta, due operandi che si associano alla stessa posizione della cache possono portare a comportamenti errati. Una cache associativa set N-way aumenta il numero da 2 a N + 1. Organizzare una cache in più "modi" richiede circuiti extra e generalmente funziona più lentamente, quindi (per esempio) una cache associativa a 8192 vie raramente è una buona soluzione.

Alla fine, tuttavia, questo fattore è più difficile da controllare nel codice portatile. Il tuo controllo su dove sono collocati i tuoi dati è generalmente abbastanza limitato. Peggio ancora, la mappatura esatta dall'indirizzo alla cache varia tra processori altrimenti simili. In alcuni casi, tuttavia, può valere la pena fare cose come l'allocazione di un buffer di grandi dimensioni e quindi utilizzare solo parti di ciò che è stato allocato per garantire che i dati condividano le stesse linee di cache (anche se probabilmente sarà necessario rilevare l'esatto processore e agire di conseguenza per farlo).

  • False Sharing

C'è un altro elemento correlato chiamato "condivisione falsa". Ciò si verifica in un sistema multiprocessore o multicore, in cui due (o più) processori / core hanno dati separati, ma rientrano nella stessa riga della cache. Ciò costringe i due processori / core a coordinare il loro accesso ai dati, anche se ognuno ha il proprio elemento dati separato. Soprattutto se i due modificano i dati in alternanza, ciò può portare a un notevole rallentamento poiché i dati devono essere costantemente trasferiti tra i processori. Questo non può essere facilmente curato organizzando la cache in più "modi" o qualcosa del genere. Il modo principale per impedirlo è quello di garantire che due thread raramente (preferibilmente mai) modifichino dati che potrebbero trovarsi nella stessa linea di cache (con gli stessi avvertimenti sulla difficoltà di controllare gli indirizzi ai quali vengono allocati i dati).


  1. Chi conosce bene il C ++ potrebbe chiedersi se questo è aperto all'ottimizzazione tramite qualcosa come i modelli di espressione. Sono abbastanza sicuro che la risposta è che sì, potrebbe essere fatto e se lo fosse, probabilmente sarebbe una vittoria abbastanza sostanziosa. Non sono a conoscenza di nessuno che l'abbia fatto, tuttavia, e dato quanto poco valarraysi abitua, sarei almeno un po 'sorpreso di vedere nessuno farlo.

  2. Nel caso in cui qualcuno si chieda come valarray(progettato specificamente per le prestazioni) possa essere così gravemente sbagliato, si riduce a una cosa: è stato davvero progettato per macchine come il vecchio Crays, che utilizzavano una memoria principale veloce e nessuna cache. Per loro, questo era davvero un design quasi ideale.

  3. Sì, sto semplificando: la maggior parte delle cache non misura in modo preciso l'elemento utilizzato meno di recente, ma utilizza un po 'di euristica che è destinata a essere vicina a quella senza dover mantenere un timestamp completo per ogni accesso.


1
Mi piacciono le informazioni extra nella tua risposta, in particolare l' valarrayesempio.
Marc Claesen,

1
+1 Finalmente: una semplice descrizione dell'associatività impostata! MODIFICA ulteriormente: questa è una delle risposte più informative su SO. Grazie.
Ingegnere

32

Benvenuti nel mondo del design orientato ai dati. Il mantra di base è ordinare, eliminare i rami, raggruppare, eliminare le virtualchiamate - tutti i passi verso una migliore località.

Dato che hai taggato la domanda con C ++, ecco la tipica cazzata C ++ obbligatoria . Le insidie ​​della programmazione orientata agli oggetti di Tony Albrecht sono anche un'ottima introduzione all'argomento.


1
cosa intendi per lotto, uno potrebbe non capire.
0x90,

5
Batching: anziché eseguire l'unità di lavoro su un singolo oggetto, eseguirla su un batch di oggetti.
arul

Blocco dell'AKA, blocco dei registri, blocco delle cache.
0x90,

1
Il blocco / non blocco di solito si riferisce al comportamento degli oggetti in un ambiente concorrente.
arul,

2
batching == vettorializzazione
Amro

23

Basta accatastarsi: il classico esempio di codice cache-friendly o contro-cache è il "blocco della cache" della matrice moltiplicata.

La matrice ingenua si moltiplica come:

for(i=0;i<N;i++) {
   for(j=0;j<N;j++) {
      dest[i][j] = 0;
      for( k==;k<N;i++) {
         dest[i][j] += src1[i][k] * src2[k][j];
      }
   }
}

Se Nè grande, ad esempio se N * sizeof(elemType)è maggiore della dimensione della cache, ogni singolo accesso a src2[k][j]sarà un errore nella cache.

Esistono molti modi diversi per ottimizzare questo per una cache. Ecco un esempio molto semplice: invece di leggere un elemento per riga di cache nel ciclo interno, usa tutti gli elementi:

int itemsPerCacheLine = CacheLineSize / sizeof(elemType);

for(i=0;i<N;i++) {
   for(j=0;j<N;j += itemsPerCacheLine ) {
      for(jj=0;jj<itemsPerCacheLine; jj+) {
         dest[i][j+jj] = 0;
      }
      for( k=0;k<N;k++) {
         for(jj=0;jj<itemsPerCacheLine; jj+) {
            dest[i][j+jj] += src1[i][k] * src2[k][j+jj];
         }
      }
   }
}

Se la dimensione della riga della cache è di 64 byte e operiamo su float a 32 bit (4 byte), allora ci sono 16 elementi per riga di cache. E il numero di mancati cache tramite questa semplice trasformazione è ridotto di circa 16 volte.

Le trasformazioni più elaborate funzionano su riquadri 2D, ottimizzano per più cache (L1, L2, TLB) e così via.

Alcuni risultati del google "blocco della cache":

http://stumptown.cc.gt.atl.ga.us/cse6230-hpcta-fa11/slides/11a-matmul-goto.pdf

http://software.intel.com/en-us/articles/cache-blocking-techniques

Una bella animazione video di un algoritmo di blocco della cache ottimizzato.

http://www.youtube.com/watch?v=IFWgwGMMrh0

La piastrellatura ad anello è strettamente correlata:

http://en.wikipedia.org/wiki/Loop_tiling


7
Le persone che leggono questo articolo potrebbero anche essere interessate al mio articolo sulla moltiplicazione di matrici in cui ho testato l'algoritmo ikj "cache-friendly" e l'algoritmo ijk ostile moltiplicando due matrici 2000x2000.
Martin Thoma,

3
k==;Spero che questo sia un errore di battitura?
TrebledJ,

13

Oggi i processori lavorano con molti livelli di aree di memoria a cascata. Quindi la CPU avrà un sacco di memoria che si trova sul chip della CPU stessa. Ha un accesso molto veloce a questa memoria. Esistono diversi livelli di cache, ciascuno con un accesso più lento (e maggiore) rispetto al successivo, fino a quando non si arriva alla memoria di sistema che non si trova sulla CPU ed è relativamente più lenta.

Logicamente, al set di istruzioni della CPU ti riferisci solo agli indirizzi di memoria in un gigantesco spazio di indirizzi virtuali. Quando si accede a un singolo indirizzo di memoria, la CPU andrà a recuperarlo. ai vecchi tempi avrebbe recuperato solo quel singolo indirizzo. Ma oggi la CPU recupererà un sacco di memoria attorno al bit richiesto e lo copierà nella cache. Si presume che se si richiede un indirizzo particolare che è altamente probabile che si chiederà un indirizzo nelle vicinanze molto presto. Ad esempio, se si stesse copiando un buffer, leggere e scrivere da indirizzi consecutivi, uno dopo l'altro.

Quindi oggi quando recuperi un indirizzo controlla il primo livello della cache per vedere se ha già letto quell'indirizzo nella cache, se non lo trova, allora questa è una mancanza della cache e deve passare al livello successivo di cache per trovarlo, fino a quando alla fine non deve uscire nella memoria principale.

Il codice intuitivo della cache cerca di mantenere gli accessi ravvicinati in memoria in modo da ridurre al minimo i mancati errori nella cache.

Quindi un esempio potrebbe essere che tu volessi copiare una gigantesca tabella bidimensionale. È organizzato con una riga di copertura in memoria consecutiva e una riga segue la successiva subito dopo.

Se copiassi gli elementi una riga alla volta da sinistra a destra, sarebbe una cache friendly. Se decidessi di copiare la tabella una colonna alla volta, copierai esattamente la stessa quantità di memoria, ma sarebbe una cache ostile.


4

È necessario chiarire che non solo i dati devono essere compatibili con la cache, ma è altrettanto importante per il codice. Ciò si aggiunge alla previsione del ramo, al riordino delle istruzioni, evitando effettive divisioni e altre tecniche.

In genere più denso è il codice, meno righe di cache saranno necessarie per memorizzarlo. Ciò si traduce in più linee di cache disponibili per i dati.

Il codice non deve chiamare funzioni ovunque, poiché in genere richiedono una o più linee cache proprie, con conseguente riduzione delle linee cache per i dati.

Una funzione dovrebbe iniziare in corrispondenza di un indirizzo intuitivo per l'allineamento della cache. Sebbene ci siano opzioni del compilatore (gcc) per questo, tieni presente che se le funzioni sono molto brevi, potrebbe essere dispendioso per ognuno occupare un'intera linea di cache. Ad esempio, se tre delle funzioni più utilizzate si adattano all'interno di una riga della cache a 64 byte, ciò è meno dispendioso rispetto al fatto che ognuna ha una propria riga e risulta in due righe della cache meno disponibili per altri usi. Un valore di allineamento tipico potrebbe essere 32 o 16.

Quindi dedica del tempo extra per rendere il codice denso. Testa diversi costrutti, compila e rivedi le dimensioni e il profilo del codice generato.


2

Come ha affermato @Marc Claesen, uno dei modi per scrivere codice compatibile con la cache è sfruttare la struttura in cui sono archiviati i nostri dati. Inoltre, un altro modo per scrivere codice compatibile con la cache è: cambiare il modo in cui i nostri dati sono archiviati; quindi scrivere nuovo codice per accedere ai dati memorizzati in questa nuova struttura.

Ciò ha senso nel caso in cui i sistemi di database linearizzano e memorizzano le tuple di una tabella. Esistono due modi di base per memorizzare le tuple di una tabella, ad esempio un archivio di righe e un archivio di colonne. Nel file store, come suggerisce il nome, le tuple sono memorizzate in ordine di riga. Supponiamo che una tabella nominata Productcome memorizzata abbia 3 attributi cioè int32_t key, char name[56]e int32_t price, quindi la dimensione totale di una tupla è64 byte.

È possibile simulare un'esecuzione di query Productdell'archivio di righe di base nella memoria principale creando una matrice di strutture con dimensione N, dove N è il numero di righe nella tabella. Tale layout di memoria è anche chiamato matrice di strutture. Quindi la struttura del Prodotto può essere come:

struct Product
{
   int32_t key;
   char name[56];
   int32_t price'
}

/* create an array of structs */
Product* table = new Product[N];
/* now load this array of structs, from a file etc. */

Allo stesso modo possiamo simulare l'esecuzione di una query di archivio di colonne molto semplice nella memoria principale creando un 3 array di dimensioni N, un array per ogni attributo della Producttabella. Tale layout di memoria è anche chiamato struct of array. Quindi le 3 matrici per ogni attributo del Prodotto possono essere come:

/* create separate arrays for each attribute */
int32_t* key = new int32_t[N];
char* name = new char[56*N];
int32_t* price = new int32_t[N];
/* now load these arrays, from a file etc. */

Ora dopo aver caricato sia l'array di strutture (Layout di riga) sia i 3 array separati (Layout di colonna), abbiamo un archivio di righe e un archivio di colonne sulla nostra tabella Product presenti nella nostra memoria.

Passiamo ora alla parte del codice intuitivo della cache. Supponiamo che il carico di lavoro sulla nostra tabella sia tale da avere una query di aggregazione sull'attributo price. Ad esempio

SELECT SUM(price)
FROM PRODUCT

Per l'archivio righe possiamo convertire la query SQL sopra

int sum = 0;
for (int i=0; i<N; i++)
   sum = sum + table[i].price;

Per l'archivio di colonne possiamo convertire la query SQL sopra

int sum = 0;
for (int i=0; i<N; i++)
   sum = sum + price[i];

Il codice per l'archivio di colonne sarebbe più veloce del codice per il layout di riga in questa query in quanto richiede solo un sottoinsieme di attributi e nel layout di colonna stiamo facendo proprio questo, cioè accedendo solo alla colonna dei prezzi.

Supponiamo che la dimensione della linea della cache sia 64 byte.

Nel caso del layout di riga quando viene letta una riga della cache, il valore del prezzo di solo 1 (cacheline_size/product_struct_size = 64/64 = 1 ) tupla, poiché la nostra dimensione di struttura di 64 byte riempie l'intera linea della cache, quindi per ogni tupla si verifica una mancanza di cache di un layout di riga.

Nel caso del layout di colonna quando viene letta una riga della cache, il valore del prezzo di 16 (cacheline_size/price_int_size = 64/4 = 16 ) tuple, perché 16 valori di prezzo contigui memorizzati nella memoria vengono portati nella cache, quindi per ogni sedicesima tupla una cache perde ocurs in caso di layout di colonna.

Quindi il layout della colonna sarà più veloce nel caso di una determinata query ed è più veloce in tali query di aggregazione su un sottoinsieme di colonne della tabella. Puoi provare tu stesso questo esperimento usando i dati del benchmark TPC-H e confrontare i tempi di esecuzione per entrambi i layout. Anche l' articolo di Wikipedia sui sistemi di database orientati alle colonne è buono.

Pertanto, nei sistemi di database, se il carico di lavoro delle query è noto in anticipo, possiamo archiviare i nostri dati in layout adatti alle query nel carico di lavoro e accedere ai dati da questi layout. Nel caso dell'esempio precedente abbiamo creato un layout di colonna e modificato il nostro codice per calcolare la somma in modo da renderlo compatibile con la cache.


1

Tenere presente che le cache non si limitano a memorizzare nella cache memoria continua. Hanno più linee (almeno 4), quindi la memoria non contenta e sovrapposta può essere spesso archiviata in modo altrettanto efficiente.

Ciò che manca in tutti gli esempi sopra riportati sono i benchmark misurati. Ci sono molti miti sulla performance. A meno che non lo misuri, non lo sai. Non complicare il tuo codice a meno che tu non abbia un miglioramento misurato .

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.