ARCHITETTURE DEI CALCOLATORI prof. MariaGiovanna Sami AA 2003/2004 Introduzione..........................................................................................................................................3 Valutazione delle prestazioni................................................................................................................3 Le gerarchie di memoria: concetti e organizzazione............................................................................6 Introduzione......................................................................................................................................6 Le gerarchie di memoria...................................................................................................................6 Le cache............................................................................................................................................7 Il problema della scrittura.................................................................................................................8 I cache miss e le prestazioni.............................................................................................................9 Supporto hardware alla memoria virtuale..........................................................................................12 Introduzione....................................................................................................................................12 Memory Management Unit............................................................................................................12 Il TLB.............................................................................................................................................14 L’architettura della CPU.....................................................................................................................14 Le istruzioni di macchina...............................................................................................................14 Le modalità di indirizzamento........................................................................................................14 Parallelismo: verso il pipelining.....................................................................................................16 L’architettura di riferimento: la macchina MIPS............................................................................16 Le dipendenze.................................................................................................................................21 I conflitti e la loro soluzione...........................................................................................................22 Gestione delle eccezioni.................................................................................................................23 CPU ad elevate prestazioni.................................................................................................................24 Come migliorare ancora le prestazioni...........................................................................................24 CPU con una sola pipeline.............................................................................................................25 CPU con più pipeline......................................................................................................................26 CPU superscalari............................................................................................................................26 Il problema dello scheduling..............................................................................................................30 Introduzione....................................................................................................................................30 Algoritmi di scheduling..................................................................................................................31 Force Direct Scheduling.................................................................................................................32 List Based Scheduling....................................................................................................................32 Static List Scheduling.....................................................................................................................33 Verso prestazioni sempre maggiori (I)...............................................................................................33 Introduzione....................................................................................................................................33 Algoritmo di Tomasulo...................................................................................................................33 Esecuzione parallela.......................................................................................................................35 La predizione dei salti....................................................................................................................36 Speculazione hw-based..................................................................................................................38 Verso prestazioni sempre maggiori (II)..............................................................................................40 Dov’è il problema...........................................................................................................................40 L’architettura P6.............................................................................................................................41 L’architettura Pentium IV...............................................................................................................42 I processori VLIW..........................................................................................................................42 L’esecuzione predicata....................................................................................................................44 Itanium, DSP e previsioni future....................................................................................................46 I sistemi multiprocessore....................................................................................................................47 Oltre l’ILP.......................................................................................................................................47 Architetture a memoria condivisa..................................................................................................48 1
Architetture a memoria distribuita..................................................................................................49 Le reti di interconnessione..............................................................................................................50 La cache coherence nei sistemi multiprocessore............................................................................50
2
Introduzione. Lo studio delle architetture dei calcolatori è fondamentalmente e strettamente legato al problema delle prestazioni. Con prestazioni indichiamo il reciproco del tempo di risposta (detto anche tempo di esecuzione). Prestazioni migliori implicano un tempo di esecuzione minore. Per alcune applicazioni le prestazioni fanno riferimento al throughput, ovvero, alla quantità di lavoro totale compiuto nell’unità di tempo. Molto in generale le prestazioni dipendono da: • Durata del ciclo di clock: legato alla tecnologia dell’HW e alla organizzazione della CPU (pipeline, super-pipeline, CPU superscalari, …). • Numero di cicli di clock per ogni istruzione (CPI = Clock cycles Per Instruction): dipende dall’organizzazione e dalla Instruction Set Architecture. • Instruction Count (IC): è il numero di istruzioni eseguite e dipende dall’Instructions Set Architecture e della tecnologia del compilatore. Ciò che principalmente limita le prestazioni di una architettura, e ciò che principalmente studieremo noi, è: • La memoria: la memoria è più lenta della logica, ma si richiede sempre più spazio. • L’esecuzione: l’esecuzione sulla macchina di “base” di Von Neumann è totalmente seriale e quindi prima che una nuova istruzione possa essere eseguita, quella vecchia deve essere completata. Quindi si studierà come: • Modificare la struttura della memoria in modo che il programmatore veda uno spazio indirizzabile molto vasto e contemporaneamente la CPU veda una “memoria equivalente” molto veloce. Parleremo di: o Valutazione delle prestazioni della cache e gestione ottimale della cache. o Supporti HW alla memoria virtuale. • Ottenere migliore efficienza per l’esecuzione della sequenza di istruzioni, ovvero come modificare il paradigma di esecuzione del programma. Parleremo di: o Definizione dell’insieme di istruzioni o Il pipelining o Il parallelismo intrinseco (ILP) o I sistemi multi-processore.
Valutazione delle prestazioni. Iniziamo studiando il modo per valutare le prestazioni di una macchina e confrontare le prestazioni di due macchine. Questo ci permetterà di capire anche come e dove intervenire per migliorare la velocità di esecuzione di un programma. Di seguito alterneremo definizioni e “procedure operative” per il calcolo delle prestazioni delle macchine. La prima parte è dedicata alla CPU, ai suoi tempi, prestazioni, … DEF. Tempo di risposta o tempo di esecuzione (Tesec): è il tempo che intercorre tra l’inizio e il completamento di un lavoro, ovvero il numero di secondi necessari per eseguire un “programma”. DEF. Throughput (Th): è l’ammontare complessivo di lavoro svolto in una unità di tempo.
3
Relazione tra le prestazioni di due macchine. Si dice che “X è n% volte più veloce di Y”, ovvero che il tempo di esecuzione di Y diviso il tempo di esecuzione di X è pari a 1+n/100, da cui ricaviamo che: T (Y ) − Tex sec ( X ) P ( X ) − P (Y ) n% = e sec ⋅ 100 = ⋅ 100 Te sec ( X ) P (Y ) dove con P(X) abbiamo indicato la prestazione di X, ovvero l’inverso del tempo di esecuzione. DEF. Speedup migliorato (SM): è “l’accelerazione” che subisce l’esecuzione del lavoro quando viene introdotto un miglioramento. È il rapporto tra le prestazioni della macchina usando la miglioria e le prestazioni della macchina senza la miglioria o, equivalentemente, è il rapporto tra il tempo di esecuzione senza miglioria e il tempo di esecuzione con la miglioria. Speedup globale (SG) e legge di Amdahl. La legge di Amdhal afferma che il miglioramento di prestazioni che può essere ottenuto usando alcune modalità di esecuzione più veloci è limitato dalla frazione di tempo nella quale tali modalità possono essere impiegate. Chiamiamo quindi frazione migliorata (FM) la frazione del tempo di esecuzione totale nel quale possiamo usare la miglioria. Tale valore sarà sempre necessariamente minore o al più uguale a 1. Definiamo allora speedup globale la seguente quantità: 1 SG = (1 − FM ) + FM SM ovvero il rapporto tra il “vecchio” tempo di esecuzione e il “nuovo” tempo di esecuzione. È sempre 1 vero che S G ≤ . 1 − FM DEF. Tempo di CPU (TCPU): rappresenta il tempo speso dalla CPU per eseguire il programma dato. Non include il tempo di attesa per I/O o per esecuzione di altri programmi. Il tempo di CPU è quindi dato dalla somma del tempo speso per eseguire il codice del programma e del tempo impiegato dal sistema operativo per eseguire i compiti richiesti dal programma. Volendo dare una definizione “quantitativa” abbiamo che il tempo di CPU è dato dal rapporto tra il numero di cicli di clock per un programma e la frequenza di clock. DEF. Cicli di clock per istruzione (CPI): poiché istruzioni diverse richiedono un tempo di esecuzione diverso, fissata la durata del ciclo di clock varia il numero di cicli di clock per le diverse istruzioni. CPI è il numero medio di cicli di clock per le istruzioni di un dato programma. Abbiamo quindi che il CPI è il rapporto tra il numero di cicli di clock della CPU per eseguire un programma e il numero di istruzioni di quel programma . Detto CI il numero di istruzioni del programma, deriva che: CI ⋅ CPI TCPU = CI ⋅ CPI ⋅ TCLK = f CLK Ancora, detto li il numero di volte che l’istruzione i viene eseguita e CPIi il numero di cicli di clock N
per l’istruzione i abbiamo che il numero di cicli di clock della CPU è pari a
∑ CPI i =1
N
N
TCPU = ∑ (CPI i ⋅ l i ) ⋅ TCLK i =1
CPI =
4
∑ (CPI i =1
CI
i
⋅ li )
N
= ∑ (CPI i ⋅ i =1
i
⋅ l i da cui:
li ) CI
e cioè, ogni singolo CPIi viene moltiplicato per la frazione delle occorrenze dell’istruzione i nel programma. DEF. Milioni di istruzioni per secondo (MIPS): operativamente è definito come: MIPS =
CI Te sec ⋅ 10 6
. Qualche osservazione: • Il MIPS dipende dall’insieme di istruzioni quindi è difficile confrontare macchine con diversi insiemi di istruzioni. • Il MIPS varia a seconda del programma considerato. • Il MIPS può variare in modo inversamente proporzionale alle prestazioni. CI ⋅ CPI f CLK Essendo: Te sec = abbiamo che MIPS = . f CLK CPI ⋅ 10 6 Possiamo anche definire un MIPS relativo (MIPSR) in riferimento ad una specificata macchina, come il rapporto tra il tempo di esecuzione della macchina di riferimento e il tempo di esecuzione della macchina da valutare, il tutto moltiplicato per il MIPS della macchina di riferimento. 1 N ∑ Te sec,i dove Tesec,i indica il tempo N i =1 di esecuzione dell’i-esimo programma che costituisce il carico di lavoro. DEF. Media aritmetica dei tempi di esecuzione (TAM): T AM =
N
DEF. Media aritmetica pesata dei tempi di esecuzione (TWAM): TWAM = ∑ Wi ⋅ Te sec,i dove Wi i =1
rappresenta la frequenza del programma i-esimo entro il carico di lavoro. Passiamo ora a considerare le prestazioni delle gerarchie di memoria. Per la teoria delle gerarchie di memoria, il loro utilizzo e la loro massimizzazione si rimanda al capitolo successivo. Introduciamo inizialmente i termini più importanti: • Hit: successo nel tentativo di accesso al livello superiore della gerarchia di memoria. • Miss: fallimento nel tentativo di accesso al livello superiore della gerarchia. La parola deve essere cercata al livello inferiore. DEF. Hit Rate (HR): percentuale dei tentativi di accesso al livello superiore della gerarchia che hanno avuto successo. È quindi il rapporto tra il numero di hit e il numero totale di accessi (eventualmente moltiplicato per 100). DEF. Miss Rate (MR): è la percentuale di tentativi falliti rispetto al numero di accessi totale (eventualmente moltiplicato per 100). Si noti che deve essere per forza HR+MR=1. DEF. Hit Time (HT): è il tempo di accesso al livello superiore della gerarchia. Comprende anche il tempo necessario a stabilire se l’accesso ha avuto successo oppure no. DEF. Miss Penalty (MP): è la penalizzazione per un fallimento nell’accesso alla memoria. È composto da: • Tempo necessario ad accedere alla prima parola del blocco dopo che è stato rilevato il fallimento. Il tempo di accesso è legato al tempo di latenza (e quindi alla tecnologia) del livello inferiore. 5
•
Tempo di trasferimento per trasferire le altre parole del blocco. Il tempo di trasferimento è legato alla larghezza di banda del canale (bus) tra i due livelli di memoria.
DEF. Miss Time (MT): è la somma dell’hit time e del miss penalty. DEF. Tempo medio di accesso alla memoria (TM): TM = H R ⋅ H T + M R ⋅M T = ... = H T + M R ⋅ M P Ricordiamo che il tempo di CPU è dato dalla somma del tempo di CPU di sistema e del tempo utente di CPU. Quest’ultimo è a sua volta dato dalla somma tra i cicli di clock della CPU in esecuzione e i cicli di clock di stallo di memoria (il tutto moltiplicato per T CLK). Noi assumeremo che tutti gli stalli di memoria sono dovuti a fallimenti di accesso alla cache e che i cicli di clock per un accesso riuscito (hit) fanno parte dei cicli di clock della CPU in esecuzione. Da qui si ricava che il numero di cicli di clock per stallo di memoria è dato dal numero di accessi a memoria moltiplicato per il miss rate e per il miss penalty. In formule: # accessi a memoria TCPU = CI ⋅ CPI ⋅ TCLK = CI ⋅ (CPI e sec + ⋅ M R ⋅ M P ) ⋅ TCLK # istruzioni Possiamo allora esprimere il CPI con cache come: # accessi a memoria CPI CACHE = CPI e sec + ⋅MR ⋅MP # istruzioni Quindi nel caso ideale, cioè quando si trova sempre la parola nel livello superiore e quindi la percentuale di hit è il 100%, il CPI sarà pari al CPIesec poiché il miss rate è pari a zero. Nel caso in cui non ci sia una cache, avremo un miss rate pari a 1 (e cioè il 100% dei miss) e quindi # accessi a memoria ⋅MP. il CPI sarà pari a CPI e sec + # istruzioni
Le gerarchie di memoria: concetti e organizzazione. Introduzione. Le applicazioni richiedono memorie sempre più capienti e sempre più veloci. Ciò comporta costi elevati, ma d’altra parte i requisiti minimi di un sistema di calcolo (ad esempio un PC) sono quelli di supportare almeno due processi: il sistema operativo e l’applicazione. Il sistema ideale ha quindi una memoria molto grande e allo stesso tempo molto veloce. Il tutto, ovviamente, ad un prezzo ragionevolmente basso. La soluzione a questi problemi sta fondamentalmente nell’introdurre una gerarchia di memorie, e non come nella macchina di Von Neumann una sola memoria. La gerarchia sfrutta il fatto che le memoria “più alte” sono piccole e veloci, mentre man mano che si “scende” le memorie diventano più capienti e più lente. In questo modo si permette a programmatore di vedere un’ampia porzione di memoria, mentre dal lato della CPU si vede una memoria molto veloce. È chiaro fin d’ora che a tempo di esecuzione (run time) se un dato non è disponibile in un livello allora sarà necessario richiamarlo dal livello inferiore. Noi supporremo che i trasferimenti avvengano solo tra livelli adiacenti. Le gerarchie di memoria. Al vertice della gerarchia troviamo i registri, che sono on chip (cioè a bordo della CPU) ed essendo “logica cablata” sono molto veloci. L’insieme dei registri è anche detto Register File (RF) ed è gestito dal compilatore. Appena sotto avremo la cache di primo livello, anch’essa on chip (solitamente) e un poco più lenta ma più capiente dei registri. La cache e i trasferimenti di dati sono gestiti direttamente via HW. Su sistemi moderni si trovano due cache di primo livello: la prima per le istruzioni (I-cache), la seconda per i dati (D-cache). Eventualmente poi possiamo trovare cache di secondo o terzo livello, spesso come componenti esterni alla CPU. Anche questi livelli sono 6
trasparenti al programmatore e gestiti dal compilatore. Infine, troviamo la memoria RAM, che è volatile e la cui tecnologia può dipendere anche dall’applicazione per la quale si sta sviluppando l’architettura. I principi che stanno alla base dell’introduzione della gerarchia di memoria sono i principi di località: • Località temporale: quando si fa riferimento ad un elemento di memoria, con alta probabilità si farà di nuovo riferimento allo stesso elemento entro breve tempo (si pensi alle variabili di un ciclo). • Località spaziale: quando si fa riferimento ad un elemento di memoria, con alta probabilità si farà entro breve tempo riferimento a elementi vicini (si pensi a dati organizzati in vettori e matrici). In genere la località è fortemente influenzata dall’applicazione e alcune applicazioni possono godere di entrambe o solo una delle due località sopra definite. Le cache. Richiamiamo ora velocemente le “basi” delle cache. La cache è strutturata in blocchi (o linee), dove il blocco è la minima quantità di informazione trasferibile tra due livelli di memoria. La dimensione del blocco influenza direttamente la larghezza del bus tra i livelli: la scelta di questo valore è un punto critico, infatti bus larghi implicano meno trasferimenti ma anche il rischio di minore efficienza nell’uso della memoria. Se infatti trasferiamo tante parole in una sola volta, ma di queste ne usiamo solo una e poi dobbiamo “buttar via” il blocco, abbiamo trasferito tanti dati inutilmente! Inoltre un bus largo costa anche di più. La CPU indirizza lo spazio generale di memoria (si considera che gli indirizzi siano indirizzi fisici) e accede direttamente solo alla cache di livello superiore. Il controllore della cache dietro richiesta della CPU controlla che in cache sia presente il dato; se questo non c’è è necessario fare una richiesta al livello inferiore. Si rivedano ora i concetti di hit, miss, hit time, … esposti al paragrafo precedente. Le cache possono essere suddivise in due categorie “opposte”: cache direct mapped e cache totalmente associative. Nelle cache direct mapped ogni blocco nello spazio degli indirizzi trova posto nella cache in uno e un solo blocco. Molto semplicemente, i bit meno significativi dell’indirizzo fisico del blocco servono da indice per la cache. Volendo semplificare al massimo, tutti i blocchi che in RAM, ad esempio, hanno indirizzo xx…xx00..00 andranno nel blocco cache di indice 00…00. Questo permette tra le altre cose di rispettare la località spaziale, infatti tutte le parole che in RAM hanno indirizzo xx…xx00..01 entreranno in cache nel blocco 00…01, immediatamente successivo al blocco 00…00. Nasce immediatamente un problema: se un blocco RAM deve entrare in cache ha un solo modo per farlo: sostituire il blocco attualmente presente. Questo non permette di rispettare (in generale) la località temporale poiché il blocco che devo far uscire dalla cache a favore di quello nuovo potrebbe tornare utile entro breve tempo. Le principali caratteristiche di queste cache sono quindi: • Facile implementazione • Richiedono poca area di silicio • Sono veloci (percorso critico breve) • Poco efficienti per quanto riguarda la politica di sostituzione (possibili fenomeni di trashing: blocchi di codice appartenenti ad un ciclo entrano ed escono continuamente dallo stesso blocco cache). All’estremo opposto troviamo le cache totalmente associative nelle quali qualsiasi blocco RAM può entrare in uno qualsiasi dei blocchi cache. Ogni blocco in cache possiede un tag costituito dall’indirizzo completo di memoria. Quando è necessario verificare se un blocco è in cache si fa una ricerca completa (parallela) andando a confrontare tutti i tag.
7
Nella pratica raramente si usa una di queste due soluzioni estreme, ma si usa più spesso una via di mezzo rappresentata dalle cache set-associative a N vie. In pratica la cache è organizzata in set ognuno dei quali contiene N blocchi. Anche la RAM è organizzata in set, ma questi sono più grandi dei set della cache. Ogni set di RAM può essere mappato in uno e un solo set della cache, ma un blocco di un set della RAM può entrare in uno qualsiasi degli N blocchi del set della cache associato. L’indice del set della cache è estratto dagli indirizzi del set corrispondente in RAM utilizzando i bit meno significativi. Il problema della scrittura. Consideriamo ora il problema della scrittura dei dati. Innanzitutto le scritture sono meno frequenti delle letture, ma sono anche più “lente”. Se i blocchi (come normalmente accade) contengono più di una parola, la lettura del blocco inizia appena l’indirizzo è disponibile, ancor prima della verifica del tag; la scrittura, invece, deve aspettare la verifica del tag. Infatti, se leggo dalla cache una parola e poi il tag non corrisponde (queste due operazioni sono fatte in parallelo) avrò letto per niente, ma non ho certo perso tempo. Se invece scrivessi prima che la verifica sul tag è terminata e poi risultasse che il tag è diverso, la scrittura avrebbe ormai inquinato i dati senza la possibilità di “tornare indietro”. Ecco quindi che prima di scrivere devo essere sicuro di poterlo fare. La scrittura in memoria deve essere quindi veloce e consistente: l’informazione in RAM deve sempre essere consistente con l’informazione in cache. Esistono fondamentalmente due tecniche per gestire le scritture: write through e write back. • Write through: l’informazione viene scritta simultaneamente nel blocco di cache e nel blocco di RAM. In questo modo si rispetta la consistenza, ma il tempo totale per la scrittura è pari al tempo necessario per scrivere in RAM (tempo notevolmente più lungo del tempo necessario per scrivere in cache). • Write back: quando si deve effettuare una scrittura, questa viene fatta solo nella cache. Il blocco viene poi “aggiornato” anche nella RAM quando si rende necessaria una sostituzione. In questo modo la cache e la RAM sono in alcuni istanti inconsistenti. Si rende necessaria l’aggiunta di un bit al dato nella cache: questo bit indica se il dato è dirty (“sporco”, cioè modificato) o clean (“pulito”, cioè non modificato). Questo mi permette di non fare sostituzioni inutili: se devo aggiornare la RAM, ma il dato in cache è clean, vuol dire che questo non è stato modificato e quindi la RAM contiene già il dato corretto. Tra le due si tende a preferire la politica write back. Infatti, basandosi sulla località temporale, un dato modificato, verrà modificato di nuovo entro poco tempo. La politica write back permette di “aggiornare” il dato in RAM una sola volta, e non tutte le volte che viene modificato (come farebbe la write through). Inoltre la tecnica write back permette di scrivere alla velocità della cache. Lo svantaggio è però che una read miss con politica write back implica una scrittura in RAM, infatti, se un blocco in cache non viene trovato allora è necessario un trasferimento dalla RAM e quindi, se qualche blocco è dirty, è necessaria una scrittura in RAM (per mantenere la consistenza) alla velocità della RAM. La politica write through invece non richiede una scrittura in RAM in caso di read miss, che quindi costano meno. La soluzione write through è più semplice da implementare e se la si vuole rendere anche più performante è necessario introdurre un registro (write buffer) “parallelo” alla cache. In questo modo una scrittura comporta una scrittura in cache e nel write buffer (che è un registro ed è quindi molto veloce). Il write buffer può contenere più dati che saranno poi trasferiti alla RAM dal write buffer dal controllore di memoria. Se il buffer si riempie si verificano degli stalli poiché prima di procedere nella scrittura si deve attendere lo svuotamento. Può inoltre accadere che si verifichi una read di una parola presente nel write buffer. Allora, in caso di read miss (cioè la parola non è presente in cache) si attende che il write buffer sia vuoto e si procede poi “normalmente” (soluzione poco performante), oppure si può andare a cercare nel write buffer la parola che si vuole leggere; se non c’è si serve per prima la read miss e poi, eventualmente, si procede nelle scritture del contenuto del write buffer verso la RAM. 8
Una scrittura in cache può anche introdurre write miss (cioè si vuole scrivere in un blocco che però non è presente in cache). Allora: • Write allocate: il blocco viene caricato in cache e si compie poi la write (in uno qualsiasi dei due modi precedentemente illustrati). • No write allocate: il blocco viene modificato direttamente nella RAM senza portarlo prima nella cache. In genere le cache write back tendono ad utilizzare la soluzione write allocate poiché portare un blocco in cache può essere utile dato il principio di località (se ci devo scrivere ora potrei doverci riscrivere tra poco). Le cache write through tendono invece ad usare la tecnica no write allocate, tanto in ogni caso una scrittura deve andare a finire nella RAM. Ora che abbiamo illustrato tutte (o quasi) le possibili alternative per le cache ci si chiede: come scegliere la soluzione più corretta? Innanzitutto c’è da dire che al giorno d’oggi per la cache di primo livello si adotta sempre la separazione tra istruzioni (I-cache) e dati (D-cache). Questo perché cache separate possono essere ottimizzate individualmente. Per quanto riguarda la scelta di adottare più livelli di cache… Vedremo tra poco come valutarla. I cache miss e le prestazioni. Parliamo ora delle cause dei cache miss: • Compulsory miss: sono anche detti “miss per partenza a freddo”, ovvero, all’avvio di un programma è logico che il blocco in cache non sia presente, perché questo non è mai stato caricato. Questi miss non dipendono quindi dalla tecnologia o dall’architettura. • Capacity miss: durante l’esecuzione del programma è normale che i blocchi debbano essere sostituiti ogni tanto, e questo principalmente perché le dimensioni di un programma, di solito, sono maggiori di quelle della cache. È ovvio che per diminuire tali conflitti si può aumentare la dimensione della cache. • Conflict miss: sono i miss da collisione e cioè i miss che si verificano quando un blocco che devo caricare deve sostituire un blocco già presente nella cache (come detto nelle direct mapped ogni blocco RAM può entrare in un solo blocco cache e anche nelle set-associative a N vie accade questo). Per diminuire questi miss si può aumentare la dimensione della cache (più blocchi ci sono a disposizione, minori sono i conflitti) e/o la sua associatività (maggiore è l’associatività, maggiore è il numero di blocchi che un set può ospitare). Un fenomeno molto comune è quello detto di cache trashing: un blocco di codice continua ad entrare e uscire dalla cache (si pensi ad un ciclo, ad una cache direct mapped e a due blocchi di codice del ciclo che possono entrare in cache solo nello stesso blocco: ad ogni iterazione l’uno rimpiazzerà l’altro generando miss in continuazione). Esistono degli accorgimenti per ridurre questo fenomeno. Il primo è detto di cache locking, ovvero tramite sw si può imporre che un blocco di codice non esca mai dalla cache (si può fare solo con cache set-associative, per ovvi motivi). Altro modo è quello di introdurre una mini-cache nella quale salvare il codice che non si vuole perdere. Come influenzano le prestazioni della CPU i cache miss? Mentre si tenta di recuperare un dato dai livelli inferiori, la CPU è in stallo, cioè non fa niente e resta in attesa del dato. Ovviamente questo comporta una perdita di prestazioni. Il tempo di esecuzione del programma è dato quindi dalla somma del tempo per eseguire l’istruzione e del tempo di stallo della CPU. Analizzando la situazione risulta che il numero di cicli in cui la CPU è in stallo è dato da: IC ⋅ (# Accessi / Istruzione) ⋅ M R ⋅ M P Il numero di istruzioni (IC) dipende dal programma, il numero di accessi a memoria per ogni istruzione (#Accessi/Istruzione) dipende dall’instruction set. Ciò che si può fare è quindi ridurre il miss rate e la miss penalty. Iniziamo con la riduzione della miss penalty. Quello che si fa è introdurre il concetto di cache multi livello, ovvero di cache di primo livello, secondo, (terzo) … Quello che si deve fare è predisporre una cache di secondo livello sufficientemente grande da catturare tutti i miss della cache di primo 9
livello ed evitare così che i blocchi debbano essere recuperati da memorie lente come la RAM. Il miss penalty della cache di livello 1 dipende quindi da quanto la cache di livello 2 ci mette a stabilire se c’è miss o hit sulla richiesta sommato al tempo necessario per recuperare il dato dal livello inferiore in caso di miss anche sul secondo livello. In formule: CPI gerarchia = H T ,L1 + M R ,L1 ⋅ ( H T ,L 2 + M R ,L 2 ⋅ M P ,L 2 ) Parlando di cache multi livello è necessario introdurre le seguenti nuove definizioni: • Local misses: numero di miss in una cache diviso per il numero totale di accessi alla stessa cache. • Global misses: numero di miss in una cache diviso per il numero totale di accessi a memoria generati dalla CPU. Ne risulta che il global miss rate per la cache di secondo livello è dato dal prodotto del miss rate del primo livello moltiplicato per il miss rate del secondo livello, infatti si ha miss su L2 (cache di secondo livello) se il dato non è né in L1 (cache di primo livello) né in L2. Dalle simulazioni e dalle analisi risulta che: • La cache di secondo livello ha senso se è molto più grande della cache L1. • La velocità della L1 influenza la frequenza di clock della CPU, quella della L2 influenza solamente la miss penalty della L1. • La cache L2 può essere basata su meccanismi semplici (come la soluzione direct mapped). Con la presenza di due cache è bene chiedersi se i dati presenti nell’una debbano essere presenti anche nell’altra. Due soluzioni: • Multilevel inclusion: i dati nella L1 sono sempre presenti nella L2. Il problema è che le statistiche consiglierebbero di avere i blocchi in L1 più piccoli di quelli in L2, quindi ci sono problemi di trasferimento sul bus: ci vogliono più cicli per un blocco. • Multilevel exclusion: i dati in L1 non sono mai presenti in L2. Ciò è ragionevole soprattutto se la L2 è piccola, e di fatto, permette di vedere una cache complessiva data dalla somma delle due capacità. Altre due politiche permettono di decidere come trasferire la parola cercata dalla cache alla CPU: • Critical word first: la parola ricercata viene inviata per prima. Per fare un esempio semplice, se il blocco ha quattro parole e quella cercata è la terza, allora questa sarà la prima ad essere trasferita, e poi si penserà alle altre. • Early restart: le parole vengono lette nel loro “normale” ordine. Ovvero, continuando l’esempio precedente, se la parola cercata è la terza del blocco questa viene inviata alla CPU solo dopo aver letto le prime due. Per ridurre la miss penalty possiamo anche “modificare” la priorità dei read miss sulle scritture. Abbiamo già parlato del caso della cache write through: se una parola cercata è nel write buffer, allora o si attende lo svuotamento del write buffer (soluzione lenta) oppure si carica la parola dal write buffer, “posticipando” la scrittura in RAM. Nel caso delle cache write back, invece, se si verifica un read miss significa anche che è giunto il momento di scrivere i blocchi dirty nella RAM, quindi si procede così: si copia il dato dirty in un buffer, poi si compie la lettura da memoria (per risolvere la read miss) e infine si scrive il blocco dirty in memoria. Quindi si introduce un write buffer anche nelle cache write back. Tre casi possibili: • Se il write buffer è vuoto allora la CPU scrive semplicemente i dati e l’indirizzo completo e la scrittura è terminata. • Se il write buffer contiene dei blocchi modificati è necessario prima verificare che il dato da memorizzare non sia già presente nel buffer. Se c’è allora il suo contenuto viene aggiornato con il dato più recente, altrimenti viene scritto in una posizione libera. • Se il write buffer è pieno (e il dato non è già presente) bisogna attendere una effettiva scrittura in memoria che liberi una posizione.
10
Infine, per la riduzione della miss penalty possiamo introdurre una victim cache che riceve i blocchi scartati dalla cache. In questo modo, se una read miss ha luogo, prima di caricare i dati dalla memoria di livello inferiore si controlla che i blocchi non siano eventualmente presenti nella victim cache. Se ci sono si è risparmiata una lettura e si è preservata la località. Ricordiamo ora l’espressione che esprime il numero di cicli di stallo della CPU: IC ⋅ (# Accessi / Istruzione) ⋅ M R ⋅ M P Abbiamo parlato di come ridurre la miss penalty, parliamo ora di come ridurre il miss rate. • Si possono fare blocchi più grandi riducendo così i compulsory miss, ma anche il numero totale di blocchi della cache (a parità di dimensione) e questo comporta un aumento dei conflict miss (meno blocchi in cui possono entrare lo stesso numero di blocchi RAM) e anche i capacity miss. Tutto questo porta ad un aumento della miss penalty, e quindi del tempo medio di accesso. Le statistiche mostrano che la dimensione ottima del blocco è fra 32 bytes (per cache piccole) e 64 bytes (per cache grandi). • Si può aumentare l’associatività. Le statistiche mostrano che una cache set-associativa a 8 vie porta a riduzione dei miss quasi come una cache totalmente associativa. L’associatività più elevata d’altro canto aumenta lo hit time poiché ci vuole più tempo per controllare tutti i tag della cache, e a sua volta lo hit time influenza la frequenza di clock della CPU. Come ridurre invece i cache miss? Ecco alcune soluzioni. • Per cache set-associative a N vie si può usare la tecnica della way prediction. In pratica si aggiungono altri bit in cache per predire la via (o il blocco entro il set) del prossimo accesso alla cache. Un multiplexer nello schema di selezione dei dati viene preposizionato sul blocco predetto, e si fa quindi un solo confronto (invece di confrontare tutti i tag del set). In caso di miss, in cicli di clock successivi si controllano gli altri blocchi. Questa tecnica “speculativa” deve essere ben valutata con opportune simulazioni ma spesso su sistemi embedded funziona e permette di ridurre la potenza utilizzata perché si fa un unico confronto. • Anche a livello di compilatore si possono prevedere delle modifiche atte a ridurre i cache miss. Il compilatore può ad esempio spostare dei blocchi di codice per migliorare la località (ad esempio, se una matrice è memorizzata per righe, il compilatore modifica il codice per accedere a tale matrice per righe e non per colonne); le matrici possono essere trattate a blocchi anziché per righe o per colonne, in modo da migliorare la località; infine alcuni compilatori adottano la tecnica in-lining: invece di effettuare un salto alla funzione, portano il codice della funzione direttamente nel main, per evitare il salto e migliorare la località (però il codice oggetto si allunga). Un modo alternativo di ridurre i cache miss e il penalty ad essi connesso è il concetto di parallelismo. L’idea è quella di sovrapporre l’esecuzione del programma all’attività di memoria, con lo scopo di non bloccare la CPU anche se il dato non è disponibile. • Non blocking cache: durante un D-cache miss, la CPU continua a leggere istruzioni dalla Icache (approccio semplice); in generale la D-cache continua a fornire degli hit anche durante dei miss (“hit under miss”). In pratica se un dato non è disponibile la CPU continua a caricare istruzioni che può eseguire se queste non dipendono dal dato non ancora pronto. • Prefetching hw di istruzioni e dati: in pratica si leggono elementi di memoria prima che la CPU ne faccia richiesta. Tipicamente quando si legge un blocco di istruzioni, viene letto anche il successivo. Il blocco prefetched viene portato nell’Instruction Stream Buffer (ISB). Se la prossima richiesta della CPU riguarda una istruzione effettivamente presente nell’ISB, la richiesta viene cancellata e il blocco portato alla CPU. Una soluzione simile si può adottare per la D-cache. • Prefetching da compilatore: il compilatore inserisce delle istruzioni di prefetch per richiedere i dati prima che occorrano. Due tipi di prefetch: register (carica i dati in un registro) e cache (carica i dati in cache). Entrambe queste soluzioni possono essere faulting o non faulting. Nel primo caso se il dato richiesto dal prefetch non è presente si attiva tutto il meccanismo per 11
richiamare il dato dalla memoria virtuale. Nel secondo caso, invece, in caso di miss “si fa finta di niente”. Un accenno infine a come ridurre lo hit time. Sicuramente montando una cache sul chip della CPU il tempo di hit diminuisce e con esso la frequenza di clock. Inoltre, se l’architettura è semplice si può pensare ad una cache direct mapped. Oppure, come vedremo, si può pensare di introdurre una pipeline…
Supporto hardware alla memoria virtuale. Introduzione. La memoria virtuale permette di separare i concetti di spazio di indirizzamento di un programma eseguibile e dimensione effettiva della memoria fisica. Lo spazio di indirizzamento è il numero di parole indirizzabili e dipende pertanto dal numero di bit dell’indirizzo (23 bit 223 parole indirizzabili). La dimensione della memoria fisica indica il numero di byte (o di parole) che la costituiscono. Il programma in esecuzione sulla macchina non usa indirizzi fisici, bensì indirizzi virtuali, generati dal linker, e quindi rilocabili. Lo spazio di indirizzamento virtuale è pertanto definito dalla lunghezza degli indirizzi virtuali, mentre la dimensione della memoria virtuale di un programma è inizialmente calcolata dal linker dopo la generazione del codice oggetto. A run time questa dimensione può variare per la modifica dello stack (chiamate a funzioni) e dell’heap (gestione delle strutture di dati dinamiche). La CPU durante l’esecuzione genera indirizzi virtuali che debbono essere tradotti in indirizzi fisici (cioè in indirizzi di RAM). La traduzione viene fatta in automatico e coinvolge sia il sistema operativo, sia elementi hw, è fatta ad ogni riferimento a memoria ed è trasparente sia al programmatore che al linker. Richiamiamo ora il meccanismo della paginazione. La paginazione serve per la gestione della memoria virtuale. Le pagine sono blocchi di memoria di dimensione generalmente fissa che vengono allocati per l’esecuzione di un programma. È il sistema operativo che gestisce il paging, il quale ha come vantaggio la riduzione della frammentazione della memoria e la facilità di gestione della crescita di occupazione di memoria di un programma a run time. Lo spazio di indirizzamento virtuale è lineare e suddiviso in un numero intero di pagine di dimensione fissa e potenza di 2. L’indirizzo virtuale è pertanto formato da due campi: NPV e il campo spiazzamento. Il primo indica il numero di pagina virtuale, il secondo la posizione della parola all’interno della pagina. Anche lo spazio di indirizzamento fisico è suddiviso in pagine della stessa dimensione delle pagine virtuale, ma queste sono per numero inferiori. L’indirizzo fisico pertanto è suddiviso tra campo NPF (Numero di Pagina Fisica) e il campo spiazzamento. La corrispondenza tra pagine fisiche e virtuali viene mantenuta in una tabella delle pagine gestita dal sistema operativo. Ogni processo possiede una tabella delle pagine. Questa tabella in linea di principio contiene una riga per ogni pagina virtuale del processo, e il campo NPV viene usato come indice. Memory Management Unit. Veniamo ora al supporto hw della memoria virtuale. La traduzione da indirizzo virtuale a fisico è fatta via hw da un dispositivo chiamato MMU, Memory Management Unit. La MMU ha al suo interno una piccola memoria, molto veloce che contiene le tabelle delle pagine dei processi. Tale memoria non contiene tutte le pagine ma solo quelle maggiormente usate; per distinguere la pagine di ogni processo si inserisce un nuovo campo detto Process IDentification (PID). La coppia PID, NPV fornisce quindi un indice univoco per la ricerca nella tabella della MMU del corrispondente indirizzo fisico. La MMU gestisce anche la protezione delle pagine, ovvero consente di definire i diritti di accesso alle pagine (lettura, scrittura, esecuzione). Questo si fa associando nuovi bit ad ogni riga della tabella. Questi diritti sono significativi soprattutto se più processi condividono le stesse pagine. La tabella della MMU contiene anche un bit di validità che indica se la pagina è già presente nella memoria fisica o se è necessario recuperarla dal disco fisso. Se la pagina è presente nella memoria fisica, allora la MMU genera il numero di pagina fisica partendo dal numero di 12
pagina virtuale e gli accosta lo spiazzamento (uguale sia per indirizzi fisici che virtuali). Se la riga corrispondente alla pagina cercata non è presente nella tabella della MMU, e se questa contiene tutte e solo le pagine residenti in memoria, significa che la pagina cercata non è presente in memoria e deve quindi essere caricata dal disco.
13
Il TLB. Per accedere ad un dato nella memoria principale quindi è necessario prima rivolgersi alla MMU per ottenere un indirizzo fisico e poi andare in RAM con l’indirizzo fisico e recuperare il dato. Quindi per ogni lettura due accessi. Si può pensare allora di sfruttare la località temporale e spaziale delle pagine e quindi “ricordare” le traduzioni di indirizzi virtuali fatte più di recente. Si introduce allora una cache detta Translation Lookaside Buffer (TLB) nella quale memorizziamo le traduzioni fatte di recente. Il TLB contiene solo le corrispondenze delle tabelle delle pagine: il campo tag contiene una parte del numero di pagina virtuale, mentre il campo dati contiene un numero di pagina fisica. Inoltre sono memorizzati bit quali la validità e la modifica. È chiaro che non è più necessario accedere alla tabella delle pagine per ogni accesso a memoria se nel TLB è già contenuta la traduzione che ci interessa. La procedura diventa quindi la seguente: dato un indirizzo virtuale si cerca nel TLB la corrispondenza, se presente si preleva l’indirizzo fisico, si mette a 1 il bit di riferimento (che indica che la pagina è stata utilizzata di recente) e se l’operazione è di scrittura si mette a 1 anche il bit di modifica. Se invece la pagina virtuale non è registrata nel TLB, ma risulta presente in memoria (e questo lo scopro cercando nella MMU) allora carico nel TLB la corrispondenza e ripeto l’accesso. Se la pagina non è nemmeno in memoria (cioè non è nemmeno in MMU) allora si ha un page fault e si deve far intervenire il sistema operativo per caricare la pagina dal disco fisso. Essendo la memoria TLB molto piccola, i miss sul TLB sono molto più frequenti dei veri page fault.
L’architettura della CPU. Le istruzioni di macchina. La scelta dell’insieme delle istruzioni di macchina è un punto critico per le prestazioni finali. L’Instruction Set include l’effettiva scelta delle operazioni da compiere e la modalità dell’indirizzamento. L’insieme delle istruzioni determina in pratica il programma oggetto che viene prodotto dal compilatore. Ci sono due tendenze opposte: • CISC (Complex Instruction Set Computer): si traducono con singole istruzioni macchina operazioni anche molto complesse. Questo porta a codici oggetto molto brevi che occupano quindi poca memoria, inoltre, eseguire una singola operazione complessa richiede meno tempo rispetto all’esecuzione di una sequenza di operazioni semplici. Tutto questo comporta però che l’unità di controllo sia necessariamente microprogrammata, e quindi sia molto complessa. Ciò a sua volta porta ad una riduzione della frequenza di clock. • RISC (Reduced Instruction Set Computer): in questo caso si adottano istruzioni che fanno cose semplici, quindi un’operazione complessa è una sequenza di operazioni semplici. Questa filosofia nasce dall’osservazione che la CPU passa il 90% del tempo ad eseguire il 10% del codice che comprende operazioni semplici come load e store, operazioni in virgola fissa e salti. Non è quindi vantaggioso migliorare le operazioni complesse eseguite raramente. Si deve cercare di migliorare le operazioni eseguite più frequentemente. Lo scopo centrale della scelta delle istruzioni di macchina diventa quindi quello di semplificare il più possibile l’unità di controllo, riducendo così il ciclo di clock e rendendo quindi la macchina più veloce. Le modalità di indirizzamento. Altro aspetto molto importante è la scelta del metodo di indirizzamento per le istruzioni di macchina. Anche in questo caso possiamo adottare diverse soluzioni. Partiamo dal fondamento che la ALU (unità aritmetico-logica) ha due operandi e un risultato. Quindi: • Macchine a tre indirizzi: l’istruzione è formata da un codice operativo (che la identifica), due indirizzi per gli operandi e un indirizzo per il risultato. Queste istruzioni rendono semplice la 14
programmazione, ma essendo fissa la lunghezza di una istruzione, il campo degli indirizzi contiene pochi bit e quindi possono essere indirizzati meno registri. • Macchine a due indirizzi: l’istruzione è formata dal codice operativo e da due indirizzi; il primo inizialmente contiene un operando, e al termine dell’istruzione conterrà il risultato. Il secondo indirizzo contiene il secondo operando. In questo modo si aumentano i bit a disposizione dell’indirizzamento, ma è necessario salvare in un altro registro il primo operando se dopo l’operazione è ancora necessario. • Macchine ad un indirizzo: si fornisce esplicitamente solo l’indirizzo di un operando. L’altro operando è contenuto in un registro dedicato (l’accumulatore solitamente) che al termine dell’operazione conterrà anche il risultato. In questo modo si hanno ancora più bit per l’indirizzamento, ma è necessario prima predisporre un operando nell’accumulatore e salvare lo stesso operando in altro registro se lo si vuole riutilizzare. • Macchine a zero indirizzi: fanno riferimento allo stack. Inizialmente gli operandi si trovano in ordine opportuno in cima allo stack, dove alla fine verrà depositato il risultato. Questa soluzione non è adottata nelle CPU odierne. Una volta scelto il formato dell’istruzione, con il numero di indirizzi che si desidera (o che sono necessari), che significato dare all’indirizzo contenuto nell’istruzione? In generale, possiamo avere macchine con riferimento a memoria e macchine registro-registro. Per quanto riguarda le prime, abbiamo che tutte le istruzioni possono fare riferimento alla memoria e l’indirizzo specificato nell’istruzione può indicare diverse cose: • Modalità diretta: si indica direttamente un indirizzo di memoria; • Modalità indiretta da registro: si indica il puntatore ad un indirizzo di memoria; • Modalità relativa a registro: si costruisce l’indirizzo fornendo uno spiazzamento rispetto ad una base contenuta in un registro (cioè si somma lo spiazzamento al contenuto del registro) ; • Modalità indiretta: si indica una parola di memoria che contiene l’indirizzo della parola che contiene l’operando; • Indirizzamento relativo al Program Counter: l’indirizzo si ottiene sommando uno spiazzamento al contenuto del PC; • Indirizzamento immediato: si fornisce in modo esplicito l’operando, invece del suo indirizzo. Un’architettura che permette l’utilizzo a tutte le istruzioni di tutti i modi di indirizzamento è detta ortogonale. È chiaro che la scelta va fatta in base anche al formato dell’istruzione. Per esempio, la modalità diretta non è utilizzabile per macchine con istruzioni a tre indirizzi poiché sono disponibili troppi pochi bit per indicare un indirizzo di memoria completo. Oppure, si può pensare di adottare istruzioni di lunghezza variabile, come ad esempio istruzioni aritmetiche con modalità di indirizzamento diretto o indiretto per i quali una istruzione a lunghezza fissa comporterebbe una riduzione del numero di bit disponibili per l’indirizzo. Nelle macchine con riferimento a memoria diamo quindi la possibilità a tutte le istruzioni di accedere alla memoria, e quindi potenzialmente rallentiamo tutte le istruzioni perché l’accesso è lento. Una tale soluzione può essere valida se la macchina ha un numero molto limitato di registri interni (al limite solo l’accumulatore). Inoltre questa soluzione non rende facile l’applicazione del pipelining. L’altra soluzione già accennata è quella delle macchine registro-registro. In queste macchine solo le istruzioni di load e di store possono accedere alla memoria. La prima carica dalla memoria in un registro una parola; la seconda pone il contenuto di un registro in memoria. Tutte le istruzioni della ALU hanno operandi e risultati esclusivamente nei registri della CPU (quindi molto veloci). Tutto ciò è adatto ovviamente a macchine che hanno un buon numero di registri interni. Tutte le istruzioni avranno lunghezza fissa, e avremo pochi formati di istruzione: ciò semplifica l’unità di controllo e aumenta quindi la frequenza di clock, ma d’altro canto allunga il codice oggetto (e quindi è richiesta più memoria). La regolarità di queste istruzioni però è molto alta e questo ci permette di introdurre il pipeline. 15
Parallelismo: verso il pipelining. Il parallelismo ha come scopo quello di aumentare le prestazioni di una architettura rendendo possibile l’esecuzione parallela di più istruzioni. Esistono vari livelli di parallelismo; noi ci concentreremo sul parallelismo a livello di istruzione (ILP = Instruction Level Parallelism) detto anche parallelismo intrinseco. L’idea di base è eseguire contemporaneamente più istruzioni macchina dello stesso codice oggetto, e per fare ciò abbiamo due strategie (che non si escludono a vicenda, ma che possono convivere): • Replicazione delle unità funzionali: più unità dello stesso tipo eseguono più operazioni dello stesso tipo in parallelo. • Pipelining: più unità funzionali (differenti) vengono usate in sequenza per compiere una singola computazione (ovvero una singola istruzione macchina) formando una “linea di montaggio” o pipeline. Il pipelining, che è quello che affronteremo ora, non è ristretto a macchine RISC, ma è utilizzato anche su macchine CISC. Questa tecnica però è fondamentale per rendere competitive le macchine RISC: su queste macchine il codice oggetto è più lungo, ma grazie al pipelining il tempo di esecuzione può essere inferiore rispetto a quello di una machina CISC. Si rivedano ora i concetti di CPI, speedup, legge di Amdahl, … visti al paragrafo “Valutazione delle prestazioni”. L’architettura di riferimento: la macchina MIPS. Descriveremo ora l’architettura della macchina MIPS nata negli anni ’80, che ci servirà per illustrare meglio i concetti e il funzionamento del pipelining. Le caratteristiche di questa macchina sono riassunte di seguito: • Architettura a 32 bit di tipo registro-registro (cioè, tutte le istruzioni sono lunghe 32 bit e solo le load e le store accedono alla memoria dati). • Le istruzioni ALU hanno due registri sorgente e un registro destinazione. • Tutti i registri nel Register File (RF) sono indifferenziati. Contiene 32 registri, dei quali 31 possono essere usati sia come sorgente che come destinazione (unica eccezione è il registro 0, che può solo essere letto e contiene la costante zero). • Le istruzioni di salto (jump e branch) possono avere indirizzamento relativo, o relativo al PC o rispetto ad un registro base. Le istruzioni sono divise in tre categorie: • Istruzioni di tipo I: load, store, istruzioni che operano su immediati, salti (con indirizzamento indiretto da registro). Il formato è: [ OpCode (31..26) | RS (25..21) | RD (20..16) | Immediato (15..0) ] • Istruzioni di tipo R: istruzioni ALU registro-registro. Il formato è: [ OpCode (31..26) | RS1 (25..21) | RS2 (20..16) | RD (15..11) | Function (10..0) ]. Il campo Function permette di specificare funzioni aggiuntive rispetto a quanto è specificato nel campo OpCode (che è troppo breve). • Istruzioni di tipo J: salti incondizionati, chiamate a subroutine (indirizzamento relativo al PC). Il formato è: [ OpCode (31..26) | Spiazzamento (25..0) ]. Analizziamo ora le cinque fasi che coinvolgono l’esecuzione di una istruzione. Queste fasi sono: 1. Fetch. 2. Decodifica dell’istruzione e lettura dei registri. 3. Esecuzione e calcolo dell’indirizzo di memoria. (Nota: questa fase coinvolge l’ALU per qualsiasi tipo di istruzione, ma nessuna istruzione MIPS richiede l’uso dell’ALU contemporaneamente all’uso della memoria, quindi, le due fasi possono essere sovrapposte). 4. Accesso alla memoria e completamento dei salti condizionati. 5. Write back (scrittura) nel Register File. 16
Riassumiamo ora nella seguente tabella ciò che accade per le istruzioni di load, store, branch, jump e istruzioni ALU.
17
fase 1
fase 2
load store jump branch
istr. ALU
l’istruzione viene letta dalla memoria e caricata nell’Instruction Register (IR)
in questa fase si svolgono diverse operazioni parallele. Mentre l’istruzione viene decodificata (cioè si interpreta l’OpCode), i 10 bit che seguono l’OpCode vengono interpretati come indirizzi di due registri, e il loro contenuto viene trasferito nei latch A e B. Nel frattempo, gli ultimi 26 bit vengono interpretati come un valore immediato ed estesi a 32 bit. Tutto questo prima ancora che si sappia qual è l’istruzione da eseguire! Si noti però che non si introduce una perdita di tempo, né sono necessarie risorse aggiuntive. Anzi, si può introdurre uno speedup: se al termine della decodifica si scopre, ad esempio, che l’istruzione è di tipo J i due registri nel latch A e B non servono, ma potrebbero servire in futuro, inoltre, il valore immediato è già stato esteso a 32 bit e quindi è pronto per l’uso.
fase 3 il valore immediato esteso (operazione svolta alla fase 2) viene sommato al registro base (già predisposto nel latch A durante la fase 2); il risultato viene scritto nel registro indirizzamento della D-cache. il valore nel registro destinazione RD (già predisposto nel latch B nella fase 2) viene trasferito al registro di lettura/scrittura della D-cache in vista della scrittura in memoria. lo spiazzamento esteso (già calcolato alla fase 2) viene sommato al contenuto del PC. Il risultato viene immediatamente riscritto nel PC.
lo spiazzamento esteso (già calcolato alla fase 2) viene sommato al contenuto del PC
i contenuti dei registri sorgente (già predisposti nei latch A e B nella fase 2) vengono inviati all’unità aritmetico-logica insieme ai comandi del campo Function; se un operando era immediato, questo è già pronto (è stato esteso durante la fase 2).
18
fase 4
fase 5
il segnale di Read/Write viene inviato alla Dcache. I dati vengono trasferiti dalla cache al registro di lettura/scrittura.
i dati presenti nel registro di lettura/scrittura vengono scritti nel Register File.
il segnale di Read/Write viene inviato alla Dcache. I dati presenti nel registro di lettura scrittura vengono scritti in memoria.
non attiva
non attiva
non attiva
si verifica la condizione; se questa è soddisfatta e quindi si deve fare il salto, l’indirizzo calcolato alla fase 3 viene posto nel PC, altrimenti si mantiene l’indirizzo attuale.
non attiva
non attiva
il risultato dell’operazione viene scritto nel registro destinazione.
Come risulta dalla tabella precedente, le istruzioni hanno latenza variabile: la load è l’istruzione più “lunga”, mentre il jump è quella più “corta”. Vediamo un esempio: load $1, 100($0) latenza 5 cicli load $2, 200($0) latenza 5 cicli load $3, 300($0) latenza 5 cicli Abbiamo un totale di 15 cicli. Vedremo tra poco come con la pipeline questo numero diminuirà, a parità di codice oggetto. Introduciamo quindi la pipeline nella macchina MIPS: ogni stadio della pipeline corrisponde ad una delle cinque fasi illustrate prima: 1. Fetch IF 2. Decodifica dell’istruzione e lettura dei registri ID 3. Esecuzione e calcolo dell’indirizzo di memoria EXE 4. Accesso alla memoria e completamento dei salti condizionati MEM 5. Write back (scrittura) nel Register File WB Tutte le istruzioni attraversano tutti gli stadi della pipeline, anche se non sono attive in quello stadio. Ovviamente, tutti gli stadi sono sincroni, ovvero, la latenza di ogni stadio è uguale alle altre e corrisponde alla latenza dello stadio non pipelined più lungo. Non appena l’i-esima istruzione lascia uno stadio, l’istruzione al passo successivo può occupare quello stadio: la latenza delle singole istruzioni non diminuisce, ma la frequenza di completamento delle istruzioni (cioè il throughput) aumenta. Questo significa che idealmente ad ogni ciclo si può completare una istruzione ottenendo un CPI pari a 1 (nelle macchine non pipelined è normalmente maggiore di 1). Il pipelining sfrutta il parallelismo intrinseco in un flusso sequenziale di istruzioni, cioè il concetto di esecuzione sequenziale della macchina di Von Neumann resta valido, solo che si cerca di eseguire le istruzioni in modo “intelligente” per così dire. Il parallelismo intrinseco non è visibile al programmatore: sono necessari compilatori ottimizzanti per poterne sfruttare a pieno i vantaggi. Il percorso che seguono le istruzioni nella macchina MIPS (il data path del MIPS) può essere così schematizzato: IF
EXE (ALU)
ID
MEM
WB
RF dallo stadio ID a IF abbiamo una doppia freccia poiché l’istruzione deve essere contemporaneamente decodificata e si devono cominciare a leggere i registri e i valori immediati; il bypass della fase MEM indica invece che non tutte le istruzioni passano per questo stadio. Il flusso dei dati è quindi normalmente da sinistra a destra, tranne nei seguenti casi: • Lo stadio di write back (WB) deve accedere al register file per memorizzare i dati di una load o il risultato di una operazione (freccia tratteggiata in figura). • Scelta del prossimo indirizzo (per salti e salti condizionati viene calcolato “alla destra”, nello stadio EXE, rispetto alla sua posizione). Riconsideriamo ora le tre istruzioni: i1: load $1, 100($0) i2: load $2, 200($0) i3: load $3, 300($0) e analizziamo la pipeline: c.c 1 2 3 4 5 6 7 i1 IF ID EXE MEM WB i2 IF ID EXE MEM WB i3 IF ID EXE MEM WB 19
Come si vede sono necessari solo 7 cicli di clock contro i 15 precedenti. Per garantire la corretta esecuzione è necessario però inserire dei buffer interstadio tra i vari stadi della pipeline. In questo modo l’informazione non viene mai persa e resta stabile. I buffer prendono il nome dai due stadi tra cui sono posti. Avremo quindi i seguenti buffer: IF/ID, ID/EXE, EXE/MEM, MEM/WB. Non esiste un buffer tra WB e IF poiché quando l’istruzione giunge in WB la si considera conclusa e le informazioni relative ad essa possono essere perse. Come vedremo l’informazione salvata nei buffer interstadio sarà necessaria anche per la gestione delle eccezioni. Visto il miglioramento ottenuto con la pipeline a 5 stadi, si potrebbe pensare di allungare la catena, suddividendo gli stadi più lenti in altri sotto-stadi più veloci. Questo è possibile, ma non si può andare oltre un certo limite, infatti, se cresce il numero degli stadi, cresce anche il numero dei buffer interstadio. Quando la latenza di uno stadio di pipeline è paragonabile alla latenza di un buffer allora non si ha più un guadagno, ma anzi un peggioramento. Per fissare le idee, portiamo ora come esempio l’attività di una load e di una store all’interno della pipeline della macchina MIPS. Questo ci aiuterà a capire meglio da dove nascono i conflitti e le alee. Load Word (lw) IF ID EXE MEM WB il valore immediato (o lo spiazzamento) si calcola viene esteso a 32 l’istruzione viene l’indirizzo bit; gli indirizzi letta dalla I-cache effettivo di i dati vengono dei registri e trasferita al memoria (con letti dalla Di dati nel buffer vengono trasferiti buffer IF/ID; il l’ALU) il cache e trasferiti MEM/WB alle porte di PC viene risultato viene (insieme al PC e vengono scritti lettura/scrittura incrementato di scritto nel buffer al registro di nel registro del RF; i valori 4; il nuovo valore EXE/MEM destinazione) al destinazione letti vengono viene trasferito al insieme al PC e al buffer MEM/WB scritti nel buffer buffer IF/ID registro di ID/EXE insieme destinazione. al contenuto del PC e al registro di destinazione. Store Word (sw) IF ID EXE MEM WB l’istruzione viene il valore si calcola i dati letti dal RF non attiva letta dalla I-cache immediato (o lo l’indirizzo nello stadio ID e trasferita al spiazzamento) effettivo di vengono trasferiti buffer IF/ID; il viene esteso a 32 memoria (con al registro di PC viene bit; gli indirizzi l’ALU) il lettura /scrittura incrementato di dei registri risultato viene della D-cache; si 4; il nuovo valore vengono trasferiti scritto nel buffer manda il viene trasferito al alle porte di EXE/MEM comando di buffer IF/ID lettura/scrittura insieme al PC e al scrittura alla Ddel RF; i valori registro sorgente. cache letti vengono scritti nel buffer ID/EXE insieme al contenuto del PC e al registro 20
sorgente. Si considerino le seguenti istruzioni: i1: lw $2,100($3) i2: sw $4, 200($5) Costruendo la pipeline si vede che al quarto ciclo, la prima istruzione accede al registro della cache per leggere il dato, ma contemporaneamente la seconda istruzione (che è alla fase EXE) accede allo stesso registro per preparare il dato che al ciclo successivo dovrà essere scritto. Questo è solo un primo esempio di conflitto, ed è detto conflitto sulle risorse. In questo semplice caso si può pensare di evitare il conflitto semplicemente replicando le risorse, e cioè dotando la cache di un registro di lettura e scrittura separati. Questi ed altri tipi di conflitti più complessi possono trasformarsi in alee e portare ad una esecuzione del programma errata, ovvero ad un risultato che differisce da quello che si sarebbe ottenuto con una “normale” esecuzione sequenziale sulla macchina di Von Neumann. Le dipendenze. Il funzionamento ideale della pipeline abbiamo visto porta il CPI ad un valore unitario. Purtroppo la presenza di conflitti e di alee implica una modifica dell’hw e del compilatore il che implica necessariamente una perdita di prestazioni e un CPI reale maggiore di uno. Le alee sono dovute alle dipendenze tra istruzioni, e sono di tre tipi: dipendenza sulle risorse (l’esempio al paragrafo precedente), dipendenza sui dati e dipendenza di controllo. Come già accennato, per la dipendenza sulle risorse si può intervenire sia a livello hw replicando le risorse, ma anche a livello sw imponendo a livello di compilazione che due istruzioni che possono generare conflitto siano ad una “distanza di sicurezza”. Tornando all’esempio della load seguita dalla store, basterebbe inserire tra le due una istruzione nop per risolvere il problema. Vediamo ora la dipendenza sui dati. Si dice che due istruzioni mostrano dipendenze sui dati se hanno in comune un operando in un registro o in memoria, a meno che l’operando non sia per entrambe un operando sorgente (se due istruzioni leggono dallo stesso registro non c’è conflitto perché la lettura non modifica i dati). In una esecuzione sequenziale è ovvio che un tale tipo di conflitto non può esistere, ma già con una singola pipeline possono crearsi tre diversi tipi di dipendenze: RAW (Read After Write), WAR (Write After Read), WAW (Write After Write). • Dipendenza RAW. Spieghiamo il tutto con un esempio: ik: sub $1,$2,$3 ($1=$2-$3); ik+1: add $4,$1,$3 ( $4=$1+$3). Il problema è che ik scrive il valore di $1 allo stadio WB, mentre i k+1 lo legge allo stadio ID, cioè mentre ik sta calcolando $2-$3. In pratica il valore di $1 letto da i k+1 è quello “vecchio”, e non come si vorrebbe (e come accadrebbe in una esecuzione sequenziale) quello “nuovo”. Se inizialmente $2=5, $3=2, $1=4, l’esecuzione sequenziale porta ad avere $1=2 e $4=4, mentre l’esecuzione con la pipeline porta ad avere $1=2 e $4=6! La dipendenza RAW è detta true dependency poiché effettivamente provoca un alea e quindi un risultato errato. • Dipendenza WAR. Si consideri l’esempio: ik: mul $4,$1,$2 ($4=$1*$2); ik+1: add $1,$5,$3 ( $1=$5+$3). La dipendenza è detta false poiché non provoca un’alea. Infatti, prima leggo il valore di $1 (cioè leggo il valore corretto) e poi in un secondo tempo lo modifico scrivendoci la somma tra $5 e $3. Lo stesso risultato lo ottengo sia con la pipeline, sia con la macchina di Von Neumann. Questa dipendenza (che diventerà importante nelle CPU con scheduling dinamico e possibilità di esecuzione delle istruzioni fuori ordine) è dovuta solo al fatto che il numero dei registri della CPU sono finiti. • Dipendenza WAW. Anche in questo caso siamo di fronte ad una false dependency, poiché in due istanti successivi scrivo lo stesso registro, ma questo non comporta alee (se nel frattempo qualche altra istruzione non lo deve leggere, ma a questo punto la dipendenza diventa RAW). Possono essere ora chiare le seguenti definizioni: • Latenza define-use di una istruzione operativa: ritardo dopo la decodifica di un’istruzione finché i risultati diventano disponibili per un’istruzione successiva con dipendenza RAW. In 21
pratica è il tempo che si deve attendere tra la definizione (define) e l’uso (use) di un dato affinché non si verifichi una dipendenza di tipo RAW. • Tempo define-use di una istruzione operativa: è il numero di cicli per cui si deve bloccare (stall) l’istruzione successiva con dipendenza RAW. Nel nostro esempio della dipendenza RAW basterebbe attendere 3 cicli per poter leggere il giusto valore di $1 nella fase ID di ik+1. • Tempo e latenza load-use: definiti come i precedenti. Le dipendenze richiedono delle modifiche al programma e/o all’hw, ma solo nel caso in cui siano true. Le dipendenze che con una sola pipeline sono false, daranno problemi in CPU nelle quali è consentita l’esecuzione fuori ordine. Abbiamo fin qui considerato degli esempi di codice lineare, cioè porzioni di codice in cui non sono presenti dei salti. Delle dipendenze possono crearsi anche all’interno dei cicli e vengono dette ricorrenze o dipendenze inter-iterazione. Il tipo più comune è quello lineare del primo ordine: x[i]=a[i]*x[i-1]+b[i], ovvero, una istruzione al passo attuale dipende da una istruzione al passo precedente, e ciò può creare alee come le RAW (leggo x[i-1] prima che questo sia stato effettivamente scritto). Consideriamo ora le dipendenze di controllo, dovute a quelle istruzioni che modificano il flusso di controllo del programma. In generale tutte le istruzioni di controllo (incluse le chiamate a procedura e i ritorni da procedura) creano dipendenze di controllo. Gli errori nascono se si avviano nella pipeline istruzioni immediatamente successive a quelle di controllo. Non tutti i casi però generano errori: i salti incondizionati si riconoscono nella fase ID quindi l’istruzione letta successivamente (che è alla fase IF) deve essere annullata perdendo così un ciclo, ma non introducendo errori. Nel caso dei salti condizionati invece, la condizione viene valutata alla fase 4 (MEM) e quindi le istruzioni avviate in sequenza potrebbero nel frattempo aver modificato lo stato della macchina generando errori. I conflitti e la loro soluzione. I conflitti sui dati nella macchina MIPS sono dovuti solamente alle dipendenze RAW (le uniche “vere”, e possono essere di due tipi: define-use e load-use. Cominciamo con i conflitti define-use. Si consideri la sequenza: sub $2,$1,$3 and $12,$2,$5 or $13,$6,$5 add $14,$2,$2 sw $15,100($2) Prima che il “nuovo” risultato di $2 sia effettivamente memorizzato, la seconda, terza e quarta istruzione hanno già letto il “vecchio” valore di $2, generando così un errore. La situazione può essere risolta dal compilatore o dall’hw: • Se possibile il compilatore inserisce tante istruzioni non dipendenti quante sono necessarie tra la prima “define” e la prima “use”, non peggiorando quindi le prestazioni. Se ciò non è possibile, vengono inserite delle nop (in questo caso l’efficienza diminuisce). • Dal punto di vista hw possiamo operare in vario modo. Una soluzione equivalente a quella di inserire le nop prevede lo stallo della CPU, cioè l’inserimento di “bolle” nella pipeline. Durante un bolla non si fa nulla e quindi le prestazioni peggiorano. Una soluzione più elegante e efficiente è quella del data forwarding che consiste nella modifica del data path. Si noti che gli operandi sorgente sono necessari all’ingresso dell’ALU all’inizio della fase EXE e che l’operando destinazione è in realtà disponibile all’uscita dell’ALU al termine della fase EXE. In pratica le dipendenze vengono rilevate non rispetto al RF ma rispetto ai buffer interstadio. Consideriamo ora i conflitti load-use, e la seguente sequenza: lw $17,200($19) add $12,$17,$18 22
il valore nel registro $17 viene effettivamente caricato solo nella fase WB, quindi, affinché il risultato sia corretto bisogna in qualche modo posticipare l’inizio della seconda istruzione di tre cicli. Per fare questo via sw si può procedere come per il caso define-use. Dal punto di vista hw, anche ricorrendo al data forwarding è comunque necessario l’inserimento di una bolla poiché i dati sono disponibili solo al termine dello stadio MEM. Veniamo ora ai conflitti di controllo. Data una sequenza di istruzioni in cui è presente un salto condizionato e una serie di istruzioni successive, la verifica della condizione del salto è fatta solo al quarto ciclo, per cui tre istruzioni successive al salto vengono comunque avviate nella pipeline, sia che il salto debba essere realmente eseguito, sia che non lo debba essere. L’importante è che le istruzioni avviate dopo il branch non vadano a modificare lo stato della macchina, perché se il salto poi deve essere fatto, quelle istruzioni non si sarebbero dovute eseguire! Se il salto invece non dovrà essere fatto, aver avviato le istruzioni che seguono il branch assicura un CPI pari a uno (idealmente). Il problema è quindi il ritardo nella determinazione della prossima istruzione da eseguire e prende il nome di control hazard o branch hazard. Ecco qualche semplice schema di soluzione: • Stallo automatico: ogni volta che si riconosce un salto condizionato si attende di risolvere la condizione prima di continuare ad inserire istruzioni nella pipeline. Questa soluzione, quando il salto non deve essere eseguito, porta ad una inutile penalizzazione. • Branch hoisting (“innalzamento” del branch): il compilatore sposta tre istruzioni prima l’istruzione di branch in modo tale che al momento in cui si risolve la condizione si sa se la prossima istruzione da eseguire è quella che originalmente seguiva il branch (quindi il salto non deve essere fatto) o quella destinazione del salto (quindi il salto deve essere fatto). In questo caso non si richiedono stalli, ma possono essere necessarie delle nop. • Branch prediction: è la soluzione più utilizzata, che nella sua versione più semplice considera che il salto debba sempre essere compiuto, ma le istruzioni che seguono il salto sono comunque avviate nella pipeline. Se il salto deve effettivamente essere fatto allora si svuota la pipeline e si annullano le istruzioni in essa contenute. La branch prediction implica quindi una esecuzione speculativa. Se la probabilità di esecuzione sequenziale (cioè senza salti) è alta, le prestazioni rispetto all’inserimento di stalli aumentano. Se la probabilità di esecuzione sequenziale è circa del 50% la speculazione non porta a miglioramenti significativi. Gestione delle eccezioni. Nell’architettura di Von Neumann la gestione delle eccezioni è piuttosto semplice: c’è sempre una sola istruzione in esecuzione per cui ad essa si associa l’eccezione. Anche il rientro dalla routine che serve l’eccezione è semplice: il valore del PC è opportunamente salvato prima della chiamata. In una CPU pipelined al contrario, ci sono più istruzioni in esecuzioni e più PC di ritorno! Come riconoscere l’eccezione, cioè come associarla alla giusta istruzione nella pipeline? Come salvare lo stato della macchina corretto? Innanzitutto vediamo le possibili cause di eccezioni: • Condizioni che sorgono dall’esecuzione dell’istruzione • Interrupt esterni • Chiamate a sistema operativo • Cache miss • Uso di codici operativi non definiti • Malfunzionamenti hw Non tutte queste possono essere prese in considerazione in una architettura. Analizziamo più in profondità le seguenti: • Esecuzione di una istruzione: l’eccezione viene sollevata nel corso dello stadio EXE e il PC salvato è quello dell’istruzione successiva, e cioè il PC salvato nel buffer ID/EXE. 23
•
Interrupt esterno: non si può determinare a priori la specifica fase di un’istruzione associata ad un interrupt, ma poiché nelle CPU non pipelined il segnale di interrupt è rilevato al termine della fase WB, è in questa fase della pipeline che si associa l’interruzione e pertanto il PC salvato è quello contenuto nel buffer MEM/WB. Queste considerazioni valgono anche per le eccezioni dovute a guasto hw. • Cache miss: solo i casi di D-cache miss vengono considerati eccezioni perché nel caso di Icache miss la CPU viene messa in stallo automatico dall’hw. Il problema viene rilevato quando si passa l’indirizzo (calcolato nello stadio EXE) alla memoria: il PC da salvare è quello dell’istruzione stessa. Infatti, dopo aver caricato l’istruzione (supponiamo una store) al termine della fase EXE si conosce l’indirizzo da cui caricare il dato. Se questo non è presente in memoria allora si genera un D-cache miss e bisogna attendere che la situazione venga risolta. Al termine dell’eccezione si deve riprendere il programma dalla store stessa, e quindi è il PC associato a tale istruzione che deve essere salvato. • Codice operativo non esistente: questa eccezione può essere dovuta ad un errore del compilatore, ad un guasto di memoria, … L’eccezione si solleva nello stadio ID e l’indirizzo di rientro dalla routine di eccezione è quello del PC contenuto nel buffer IF/ID. Le eccezioni in macchine pipeline possono essere “imprecise” o “precise”. Nel primo caso l’eccezione può essere associata a un’istruzione diversa da quella che l’ha generata. È ovvio che questa soluzione non è accettabile (ma questo caso era presente in alcune vecchie macchine RISC). Le CPU moderne supportano tutte le eccezioni “precise”: lo stato salvato è lo stesso che si sarebbe salvato se l’eccezione si fosse verificata su una architettura sequenziale e seriale. A tale scopo: 1. tutte le istruzioni che precedono quella che ha generato l’eccezione devono essere portate a compimento permettendo che modifichino lo stato della macchina. 2. tutte le istruzioni che seguono l’istruzione che ha sollevato l’eccezione vengono “annullate” e non si consente che modifichino lo stato della macchina. 3. se l’eccezione è stata sollevata da una istruzione del programma il PC salvato corrisponde a tale istruzione che viene completamente eseguita o annullata a seconda dell’architettura e della causa dell’eccezione. Il problema del salvataggio dello stato della macchina non è banale se l’istruzione che ha sollevato l’eccezione era eseguita in modo speculativo. In questo caso si rinvia la gestione dell’eccezione finché non si è risolta la speculazione. Nel caso invece di più eccezioni simultanee è necessario stabilire delle priorità quando si progetta l’architettura.
CPU ad elevate prestazioni. Come migliorare ancora le prestazioni. Nelle macchine pipelined il CPI reale può essere visto come somma del CPI ideale, degli stalli dovuti a conflitti sulle risorse, degli stalli dovuti ai conflitti sui dati e dagli stalli dovuti ai conflitti di controllo. Una qualsiasi tecnica che riduce uno di questi termini, porta alla riduzione del CPI reale della macchina pipelined. Ad esempio, lo scheduling dinamico permette la riduzione degli stalli da alee sui dati, la branch prediction dinamica riduce gli stalli dovuti ad alee di controllo, la issue multipla (CPU superscalari) riduce il CPI ideale e la speculazione riduce gli stalli dovuti ai conflitti sui dati e di controllo. Vedremo queste tecniche, ma il concetto fondamentale è che, data una architettura, si deve estrarre maggior parallelismo dal programma: occorre identificare e risolvere le dipendenze e ordinare le istruzioni (scheduling) in modo da raggiungere il massimo parallelismo di esecuzione compatibile con le risorse ottenibili. Ancora una volta si evidenzia quindi come le dipendenze siano fondamentali. Ecco quindi due approcci per individuarle: • Scheduling statico, o rilevamento e risoluzione statica delle dipendenze. È una operazione eseguita dal compilatore che essenzialmente mira a riordinare il codice e fornire all’unità di 24
elaborazione il codice libero da dipendenze. Se alcune dipendenze rimangono, in molti casi, la macchina non è in grado di rilevarle (né tanto meno di risolverle). • Scheduling dinamico. È svolto dall’hw il quale a run time riordina le istruzioni per liberarle dalle dipendenze. Ciò che si deve garantire è il mantenimento del flusso di dati e il comportamento a fronte delle eccezioni. E questo porta ad un accrescimento della complessità dell’unità di controllo. In linea di principio lo scheduling dinamico legge e lancia (issue) le istruzioni in ordine di programma; l’esecuzione dell’istruzione lanciata inizia non appena i dati necessari si rendono disponibili e questo può portare ad una esecuzione fuori ordine (ovvero in un ordine diverso di quello che si sarebbe ottenuto eseguendo il programma su una macchina sequenziale). L’esecuzione fuori ordine introduce la possibilità di alee di dati di tipo WAR e WAW. Si pensi infatti a due istruzioni (la 1 e la 2 per semplicità) e si supponga che l’esecuzione fuori ordine porti ad eseguire prima la 2, la quale legge il registro A; a questo punto la 1 viene eseguita e va a scrivere il registro A: è una dipendenza WAR perché la 1 scrive dopo che la 2 ha letto e ovviamente il risultato che si ottiene è errato. Altro problema sono le dipendenze WAW. Si supponga di avere ancora le istruzioni 1 e 2. L’esecuzione fuori ordine porta la 2 a scrivere in A. Successivamente la 1 scrive anch’essa in A. Ciò che l’istruzione 2 aveva scritto è perso per sempre! Tutto ciò è dovuto quindi al fatto che l’esecuzione è fuori ordine. Per rilevare le dipendenze ad ogni ciclo le istruzioni nella finestra di issue (che sono istruzioni lette e decodificate, ma non ancora lanciate in esecuzioni) vengono controllate per cercare dipendenze rispetto a tutte le istruzioni nella finestra di esecuzione e rispetto a tutte le istruzioni nella finestra di issue stessa. Dopo questo controllo, nessuna, una o più istruzioni possono essere lanciate alle unità di esecuzione (si lancerà una sola istruzione se la CPU è scalare, più istruzioni se la CPU è superscalare). Anche per lo scheduling dinamico il compilatore fornisce una ottimizzazione parallela del codice. Nel caso di esecuzione parallela (e quindi con la possibilità che le istruzioni vengano eseguite fuori ordine) è necessario garantire la consistenza logica del programma, ovvero è necessario garantire che, anche se le istruzioni sono eseguite fuori ordine, l’esecuzione parallela porta agli stessi risultati che si otterrebbero su una macchina sequenziale. Questo deve valere anche per le eccezioni (si devono sollevare solo quelle eccezioni che si sarebbero sollevate se il programma fosse stato eseguito su una macchina di Von Neumann) e per gli accessi alla memoria. Chiameremo quindi consistenza del processore la consistenza sequenziale per il completamento delle istruzioni; consistenza della memoria la consistenza sequenziale per gli accessi a memoria. Potremo avere consistenza forte se si ha esattamente la stessa sequenza di istruzioni che si avrebbe su una macchina sequenziale; consistenza debole se la sequenza è diversa ma l’integrità logica è la stessa. Quando abbiamo introdotto il parallelismo al capitolo precedente abbiamo detto che ci saremmo concentrati sul parallelismo ILP, ovvero il parallelismo a livello di istruzione (o parallelismo intrinseco). Lo scheduling dinamico a livello ILP comprende, come detto, la rilevazione delle dipendenze, ma anche l’ottimizzazione del codice parallelo. Cosa possiamo attenderci quindi? Quale guadagno, o speedup? Se si disponesse di infinite risorse il parallelismo sarebbe limitato solo dalle caratteristiche del programma applicativo. Purtroppo i programmi generali hanno bassissimo grado di parallelismo (un po’ meglio va per i programmi scientifici) e questo è dovuto allo scheduling svolto solo all’interno dei blocchi di codice lineare, i quali sono molto corti e incidono perciò poco sull’accelerazione complessiva (si ricordi la legge di Amdahl). Si potrebbe pensare quindi di “accelerare” anche i cicli (loop unrolling, software pipelining, …) o di orientarsi verso una esecuzione speculativa (che incrementerebbe centinaia di volte lo speedup se si disponesse di un predittore ideale). CPU con una sola pipeline. Consideriamo di avere una sola pipeline, come migliorare le prestazioni? Una volta ottimizzato il codice l’unica cosa che ci rimane da fare è ridurre il tempo di ciclo, ovvero aumentare il numero di stadi della pipeline per far si che gli stadi più lenti vengano accelerati. Questa soluzione prende il 25
nome di superpipelining. In teoria il tempo di ciclo è inversamente proporzionale al numero di stadi della pipeline (cioè maggiore è il numero di stadi e minore è il tempo di ciclo), ma questo nella realtà non è sempre vero. Le limitazioni ad una pipeline “lunghissima” sono: • Al crescere degli stadi della pipeline cresce anche il numero di buffer interstadio. Quando la latenza di un buffer è paragonabile a quella di uno stadio della pipeline la latenza totale aumenta rispetto a quella di una “normale” pipeline. • Ci sono problemi di progettazione elettronica che diventano rilevanti. • Il ritardo define-use e quello load-use crescono, il che implica la presenza di più bolle e quindi aumenta la perdita di prestazioni dovute alle alee di dati. • Le alee di controllo implicano salti più”lenti”: se la pipeline è più lunga e la predizione sul salto è sbagliata, bisogna “svuotare” la pipeline e annullare più istruzioni. Ad oggi si hanno 10-12 stadi di pipeline nelle CPU. Per migliorare le prestazioni in CPU con una sola pipeline si può adottare lo scheduling dinamico, che come visto riordina le istruzioni a run time (lo scheduling statico se non riesce a risolvere la dipendenza manda in stallo la CPU). CPU con più pipeline. Si potrebbe pensare di inserire in una CPU più pipeline specializzate (una per l’esecuzione di load/store, istruzioni in virgola fissa, … e una per le istruzioni in virgola mobile, ad esempio). Oppure usare pipeline multiple, cioè avere più pipeline dello stesso tipo che lavorano in parallelo. Iniziamo dalla prima soluzione. Si può pensare di lanciare comunque una sola istruzione alla volta, ma indirizzare questa istruzione ad una pipeline specifica a seconda di ciò che si deve fare. Quindi, dopo lo stadio ID si decide su quale linea mandare l’istruzione e si può passare poi all’analisi dell’istruzione successiva. Anche in questo caso può nascere il problema dell’esecuzione fuori ordine perché le diverse pipeline avendo “compiti” diversi ci possono impiegare tempi diversi a svolgere il lavoro. È necessario quindi conservare la consistenza sequenziale. La soluzione più semplice è impedire alla pipeline specializzata di scrivere nel Register File ma lasciare che questa operazione venga comandata via software; oppure si può pensare di instaurare un rapporto di tipo master-slave tra la pipeline specializzata e quella di esecuzione (che qui supponiamo essere quella a latenza minore). Le due vengono sincronizzate, ovvero la più “corta” viene rallentata per mantenere l’esecuzione in ordine; infine, ma questo lo vedremo più avanti, si può utilizzare la tecnica del riordinamento. CPU superscalari. Ci resta ora da considerare l’ipotesi di avere più pipeline dello stesso tipo, ed in particolare, più unità di esecuzione che lavorano in parallelo. Potremmo così lanciare in esecuzione più istruzioni alla volta estraendo quindi maggior parallelismo a livello di istruzione. Queste che stiamo per introdurre sono dette CPU superscalari. Ciò che si vuole fare è leggere e decodificare simultaneamente più istruzioni, lanciarle nelle pipeline di esecuzione e garantire la consistenza sequenziale. Se si riesce a far questo allora il CPI ideale diventa inferiore all’unità perché in un ciclo si possono eseguire N istruzioni! È chiaro che tutto ciò comporta delle enormi complicazioni. Infatti, analizziamo le singole attività: • Decodifica parallela: si devono decodificare più istruzioni contemporaneamente e per evitare di allungare il ciclo di decodifica si introduce una pre-decodifca a livello di I-cache. • Lancio parallelo (o instruction issue): le istruzioni devono essere analizzate per identificare le possibili dipendenze e poi lanciate alle pipeline. La frequenza di lancio di una istruzione diventa più alta e questo complica i problemi legati alle dipendenze sui dati e alle dipendenze di controllo (si pensi ai salti…). È necessario definire delle politiche di lancio che permettano di lanciare alle pipeline istruzioni tra loro indipendenti.
26
•
Esecuzione parallela: l’esecuzione dell’istruzione ha inizio non appena sono disponibili i dati necessari e non rispettando l’ordine di programma (questa è come si ricorderà la caratteristica base dello scheduling dinamico). Tutto ciò porta quindi ad esecuzione fuori ordine e al problema della consistenza sequenziale: lo stato della macchina deve essere aggiornato come verrebbe aggiornato su una macchina sequenziale (lo stesso deve valere per le eccezioni). Ora che il concetto generale è stato illustrato vediamo più nel dettaglio come avvengono e cosa implicano le singole fasi. Ecco un rapido schema riassuntivo per capire meglio di cosa parleremo e di come le varie problematiche sono tra loro legate: Decodifica parallela
Lancio superscalare
Predecodifica
False dipendenze
Issue rate
Dipendenze non risolte
Issue policy
Ridenominazione
Frequenza di issue
Shelving
Blocking issue
Dispatching Lettura degli operandi Issue bound
Disponibilità degli operandi
Dispatch bound
Scoreboarding
Iniziamo dalla decodifica parallela. In un solo ciclo la CPU deve decodificare più istruzioni e verificare che queste siano libere da dipendenze con le altre istruzioni presenti nella finestra di esecuzione e di issue. Il numero di confronti da fare in un unico ciclo può davvero essere elevato, quindi si introduce una pre-decodifica che sposta parte del lavoro alla fase di caricamento dell’istruzione nella I-cache (cioè quando si sposta l’istruzione dalla RAM alla I-cache, o dalla cache di secondo livello alla I-cache). L’unità di pre-decodifica aggiunge dei bit che indicano la classe dell’istruzione, il tipo di risorse richieste e in alcune CPU anche il pre-calcolo dell’indirizzo obiettivo del salto. Passiamo ora al lancio parallelo (o superscalare) delle istruzioni. Due gli aspetti principali da analizzare: la issue policy e l’issue rate. Il primo specifica la gestione delle dipendenze durante il processo di lancio; L’issue rate (anche detto grado di superscalarità) indica il massimo numero di istruzioni che si possono lanciare e va da un minimo di 2 a, in genere, 4 e viene scelto sulla base del bilancio tra potenziale speedup e complessità dell’unità di controllo, nonché, ovviamente, sul parallelismo potenziale valutato su programmi benchmark. Le politiche di issue superscalare includono i seguenti aspetti fondamentali: • False dipendenze dai dati relative a dipendenze WAR e WAW che si verificano tra le istruzioni nella finestra di esecuzione e le istruzioni in attesa di lancio oppure entro la finestra di issue. Una possibile politica è quella di rinominare i registri scrivendo il risultato in un opportuno spazio extra allocato dinamicamente. • Dipendenze di controllo non risolte. Includono ad esempio i salti condizionati quando la condizione non è ancora stata risolta. Allora si può attendere la risoluzione della condizione o adottare una esecuzione speculativa.
27
•
Gestione della frequenza di issue. Si possono usare tecniche dette di shelving per evitare che il lancio venga bloccato, oppure consentire che le istruzioni che presentano dipendenze irrisolte vengano bloccate. Lo shelving comunque non garantisce il totale non blocco del lancio: anche le risorse limitate possono determinare il mancato issue. Vediamo come funziona lo shelving. Esso permette di disaccoppiare l’issue dal controllo delle dipendenze; richiede speciali buffer detti Reservation Stations (RS) in testa alle unità funzionali. Le istruzioni vengono inviate alle RS senza effettuare controlli su dipendenze di dati, di controllo o di risorse. Tale controllo verrà eseguito successivamente all’interno degli shelving buffer in una fase detta di dispatching: le istruzioni libere da dipendenze (e che hanno disponibili gli operandi) sono “eleggibili” per l’esecuzione e la politica di dispatching definirà quale tra queste possono essere inviate all’unità funzionale. Esistono più tecniche di shelving: • Reservation stations individuali: l’istruzione inviata all’unità X per essere eseguita, viene temporaneamente depositata nella RS X. Ogni stazione può contenere 2-4 istruzioni. • Reservation stations di gruppo: una stazione contiene istruzioni per un intero gruppo di unità funzionali ognuna delle quali esegue istruzioni dello stesso tipo. Le stazioni hanno capacità di 816 istruzioni e sono in grado di eseguire il dispatch di più istruzioni in un solo ciclo. • Reservation station centrale: una sola stazione per tutte le unità funzionali, molto capiente e in grado di accettare e/o fornire più istruzioni in un unico ciclo. • Un’altra possibilità è quella di avere un unico buffer per lo shelving, la ridenominazione e il riordino delle istruzioni. Questo buffer prende il nome di ROB (ReOrder Buffer) e lo incontreremo di nuovo più avanti. Approfondiamo l’importante fase del dispatching. Questa attività ha principalmente due compiti: procedere allo scheduling per l’esecuzione delle istruzioni in una particolare RS e distribuire le istruzioni schedulate alle unità funzionali. Queste due attività coinvolgono numerose altre attività che definiscono la politica di dispatch: • Regola di selezione: una istruzione è eseguibile quando i suoi operandi sono disponibili, siano essi nel RF o nello spazio dedicato alla ridenominazione. Questo principio è detto di funzionamento dataflow. • Regola di arbitraggio: può capitare che più istruzioni siano eleggibili ed è necessario quindi definire quale tra queste scegliere (solitamente la più vecchia). • Ordine di dispatching: determina se una istruzione non eseguibile impedisce il dispatching di tutte le altre istruzioni. Possiamo avere tre casi: o Dispatch in-ordine: una istruzione non eseguibile implica il blocco di tutte le altre. Le prestazioni sono scarse, ma è semplice poiché solo l’istruzione più vecchia del blocco viene controllata. o Dispatch parzialmente fuori ordine: solo alcuni tipi particolari di istruzioni bloccano tutte le altre; altri tipi invece non bloccano altre istruzioni se non possono essere eseguite. Rispetto al caso precedente da prestazioni leggermente migliori (ma è un po’ più complesso). o Dispatch fuori ordine: nessuna istruzione non eseguibile blocca l’esecuzione delle altre. Questo da luogo al fenomeno del completamento delle istruzioni fuori ordine. Questa tecnica richiede che tutti gli elementi della RS siano esaminati per verificare la loro eleggibilità. Quasi tutte le moderne CPU adottano questo schema. Un problema connesso al dispatching è quindi quello della lettura degli operandi. Anche in questo caso possiamo distinguere due casi: • Politica issue bound: gli operandi vengono letti durante l’esecuzione dell’istruzione pertanto gli shelving buffer forniscono uno spazio per registrare gli operandi sorgente delle operazioni. Infatti, nella fase di issue gli identificatori dei registri sorgente vengono inviati al RF per la lettura degli operandi. A questo punto il codice operativo dell’istruzione, l’identificatore del registro di destinazione e il valore degli operandi sorgente sono depositati negli shelving buffer. 28
Il RF deve mettere a disposizione più porte di lettura per permettere a tutte le istruzioni di ottenere gli operandi simultaneamente. • Politica dispatch bound: gli operandi vengono letti durante la fase di dispatch quindi gli shelving buffer contengono solo gli identificatori dei registri sorgente. Infatti, le RS contengono l’istruzione, l’identificatore del registro destinazione e gli identificatori di registri sorgente. Nella fase di dispatch il codice operativo e l’identificatore del registro destinazione vengono inviati all’unità funzionale e contemporaneamente gli identificatori dei registri sorgenti al RF, il quale penserà poi a fornire i valori corrispondenti direttamente all’ingresso dell’unità funzionale. Ovviamente ci resta da discutere come verificare la disponibilità degli operandi. Anche questa è un’operazione molto importante perché, come visto, è alla base delle politiche di dispatching. La verifica della disponibilità degli operandi implica due problemi fondamentali: durante la lettura degli operandi dal RF è necessario verificare se gli operandi sono disponibili nel RF; inoltre, durante il dispatching occorre verificare se tutti gli operandi delle istruzioni presenti nelle RS sono disponibili. La soluzione a questi problemi prende il nome di scoreboard. Molto semplicemente, lo scoreboard è un registro di stato che consiste di elementi di un bit ognuno dei quali costituisce un’estensione di un bit del corrispondente registro. In pratica il bit marca il registro come disponibile o non disponibile, e le unità che hanno necessità di sapere la disponibilità di un registro non devono fare altro che interrogare lo scoreboard. Se un registro è disponibile il suo bit viene posto a uno, fino a quando l’istruzione non viene lanciata: in questo istante il bit viene posto a zero per indicare ad altri pretendenti che il registro è temporaneamente occupato. Al termine dell’esecuzione dell’istruzione il bit viene riportato a uno rendendo di fatto disponibile il registro per altre istruzioni. Esistono vari modi per verificare i bit di stato dello scoreboard: • Verifica diretta: la disponibilità degli operandi sorgente non è esplicitamente indicata nelle RS, quindi i bit dello scoreboard vengono controllati per verificare tale disponibilità. Questo metodo è usato di solito con la tecnica dispatch bound di lettura degli operandi (cioè la RS contiene il codice operativo dell’istruzione, l’identificatore del registro destinazione e quelli dei registri sorgente). La procedura è la seguente: ad ogni ciclo per l’istruzione più vecchia di ogni RS vengono direttamente verificati i bit di stato dei registri sorgente. Se questi sono a uno l’istruzione è eleggibile e il codice operativo e l’identificatore del registro destinazione vengono passati all’unità funzionale, mentre gli identificatori dei registri sorgenti vengono inviati al RF, a questo punto i bit di stato nello scoreboard vengono posti a zero e il contenuto dei corrispondenti registri inviato all’unità funzionale per l’esecuzione. Quando l’istruzione è completata i bit di stato vengono riportati a 1. • Verifica dei bit espliciti di stato: la disponibilità degli operandi sorgente è indicata direttamente nelle RS. Questa modalità è usata solitamente quando per prelevare gli operandi si usa la politica di issue bound. In questo caso la RS contiene il codice operativo dell’istruzione, l’identificatore del registro destinazione e il valore letto dei registri sorgente con i rispettivi bit di disponibilità. Se al momento dell’issue (cioè quando il RF dovrebbe fornire il valore contenuto nei registri sorgente) i registri non sono disponibili, si fornisce il loro identificatore. Se invece i registri sono disponibili, all’istante dell’issue il bit di disponibilità viene posto a zero. Quando l’unità funzionale produce il risultato si aggiorna il registro destinazione e si pone a 1 il bit di disponibilità dei registri sorgente appena utilizzati nello scoreboard. Le RS che contengono il valore o i riferimenti degli stessi registri non ne sono informate: per questo ad ogni ciclo, mediante ricerca associativa, aggiornano lo stato dei propri registri sorgente. Abbiamo accennato in precedenza alla ridenominazione per la soluzione delle dipendenze false (WAW e WAR). Se non si adotta questa politica, allora gli operandi vengono tutti letti dal register file, ma se si attua la ridenominazione è necessario avere a disposizione altro spazio per memorizzare i valori ridenominati. In ogni caso, le politiche di lettura degli operandi restano quelle appena illustrate. Vediamo quindi un po’ meglio la tecnica di ridenominazione. 29
Questa tecnica, innanzitutto, presume che le istruzioni siano del tipo registro-registro a 3 operandi: non è infatti possibile rinominare una cella di memoria! La ridenominazione può essere statica o dinamica. La prima è effettuata dal compilatore, mentre la seconda è fatta a run time e coinvolge delle unità hw extra a quelle presenti per le altre operazioni. La ridenominazione dinamica può essere parziale (limitata cioè a particolari tipi di istruzioni) o completa: tutti i tipi di istruzioni eleggibili possono subire la ridenominazione dei loro registri (è quello che accade nelle CPU superscalari moderne). La ridenominazione è compiuta mediante un buffer di ridenominazione; il tipo di questo buffer determina dove i risultati intermedi devono essere scritti o letti. Si noti che i risultati intermedi non possono modificare lo stato della macchina fino a che non è stata verifica la consistenza sequenziale. Abbiamo tre soluzioni sulla scelta del tipo di buffer: • Fusione del RF architetturale e di quello di ridenominazione. Se il RF è di grandi dimensioni, i registri architetturali (cioè quelli che non verranno rinominati) e di ridenominazione vengono allocati dinamicamente nel RF fisico. Quando una istruzione specifica un registro di destinazione, viene allocato un registro nel RF, se disponibile. L’allocazione da registri architetturali a registri fisici viene gestita mediante una mapping table. • RF architetturali e di ridenominazione separati. L’istruzione specifica un registro di destinazione, e questo viene allocato con un registro di ridenominazione nuovo che resta valido fino a che un’istruzione successiva fa riferimento allo stesso registro destinazione (in questo caso si alloca un nuovo registro di ridenominazione) o fino a che l’istruzione giunge a completamento e l’allocazione perde la sua validità. • Uso del ReOrder Buffer (ROB) per implementare i registri di ridenominazione. A ogni istruzione, al momento dell’issue, viene allocato un diverso elemento del ROB; i risultati intermedi vengono poi registrati nello stesso elemento.
Il problema dello scheduling. Introduzione. Abbiamo discusso in precedenza dell’importanza dello scheduling quale strumento in grado di estrarre la “maggior quantità” di parallelismo possibile da un programma. Si vuole ora approfondire un po’ questo aspetto introducendo una descrizione di vari metodi per eseguire lo scheduling. Per capire al meglio come funzionano questi algoritmi, sono necessarie alcune premesse. L’obiettivo dello scheduling è assegnare le operazioni del programma ai passi di controllo (o cicli di esecuzione) in modo da minimizzare una data funzione “obiettivo” (che può essere la minimizzazione delle risorse o del tempo di esecuzione, come vedremo), e al tempo stesso soddisfare una serie di vincoli imposti (numero limitato di risorse, numero limitato di cicli, …). Nei sistemi sincroni ogni modulo può essere usato solo una volta all’interno di un passo di controllo e, in un passo di controllo, per eseguire una operazione si deve utilizzare una corrispondente e separata unità funzionale. Il numero totale di unità funzionali richieste è pari al massimo numero di unità dello stesso tipo utilizzate in un passo di controllo (cioè, se in un passo si fanno al più tre moltiplicazioni, serviranno 3 moltiplicatori). È abbastanza intuitivo pensare che se si vuole una realizzazione con poche unità funzionali, il tempo per eseguire il codice crescerà, cioè saranno necessari più passi di controllo (o cicli). Viceversa, se si vuole ridurre il numero di passi di controllo, saranno necessarie, a parità di codice, più unità funzionali. Lo scheduling quindi implica un bilancio tra costi ( unità funzionali) e prestazioni ( numero di cicli di controllo). Esiste una tecnica detta chaining delle operazioni che permette di ridurre il numero di passi di controllo, inserendo due operazioni non parallele nello stesso passo. Il bilancio da fare non è solo tra il numero di risorse hw e il numero di passi di controllo, ma coinvolge anche la lunghezza del passo di controllo (una moltiplicazione richiede più tempo per l’esecuzione rispetto ad una somma). Se si ricorre al chaining la lunghezza del passo di controllo aumenta e quindi diminuiscono le prestazioni (poiché aumenta il tempo di ciclo). Nel seguito noi supporremo che: 30
• • • • •
Le varie fasi di esecuzione non si sovrappongano, cioè si esclude il pipelining. Ottimizzeremo solo codice lineare, che non contiene cioè né cicli né salti. Ogni operazione può essere eseguita esattamente in un passo di controllo. Ogni tipo di operazione può essere eseguita da un solo tipo di unità funzionale. Sia presente un clock ad una sola fase e registri master-slave.
Algoritmi di scheduling. Assumiamo assegnata una libreria di unità funzionali e registri con caratteristiche note ed inoltre assumiamo definita la lunghezza del passo di controllo. Abbiamo le seguenti possibilità: • Approcci time-constrained: minimizzano il numero di risorse rispettando un vincolo relativo al massimo numero di passi di controllo. • Approcci resource-constrained: minimizzano il numero di passi di controllo rispettando un vincolo relativo al numero di risorse massime disponibili (in termini di unità funzionali e registri). Gli algoritmi di scheduling operano su grafi di flusso dei dati chiamati Data Flow Graph (DFG). In questi grafi si hanno dei vertici (vi) che rappresentano un’operazione (oi); esistono delle frecce (eij) che collegano i vari vertici (vi vj) indicando che il prodotto di una certa operazione (oi) è consumato da un’altra operazione (oj). In questo caso si dice che vi è predecessore immediato di vj. Ogni operazione può essere eseguita in Di passi di controllo (noi assumeremo sempre Di = 1). Il DFG viene estratto da una descrizione comportamentale (che descrive, appunto, il comportamento del programma, cioè “cosa fa il programma”). Gli operatori ammessi nel DFG sono solo quelli elementari (a uno o due operandi), per cui operazioni complesse devono essere “scomposte” in operazioni elementari. In tutti i casi in cui non esistono dipendenze di dati le operazioni possono essere eseguite in parallelo da nodi indipendenti. Il DFG permette quindi una prima estrazione del parallelismo dal codice mettendo in rilievo che le operazioni possono essere eseguite in cicli diversi. Esistono degli algoritmi di scheduling che valutano i limiti estremi entro i quali le operazioni possono essere svolte: • Algoritmo ASAP (As Soon As Possible): dopo aver ordinato le operazioni in base alle dipendenze sui dati ogni operazione viene assegnata al primo passo di controllo disponibile. L’algoritmo per prima cosa scorre tutte le operazioni previste e a quelle che non hanno predecessori assegna una etichetta di valore 1 che sta ad indicare che quelle operazioni saranno schedulate al primo passo. Le operazioni che hanno dei predecessori non possono essere schedulate al primo passo pertanto, momentaneamente, verrà loro assegnata una etichetta pari a 0. Terminata la schedulazione delle operazioni al primo passo l’algoritmo passa in rassegna le operazioni rimanenti verificando se per ognuna sia già stata schedulata l’operazione precedente (cioè quella da cui dipende). Se l’operazione precedente è stata schedulata, allora a quella in analisi viene assegnata una etichetta che riporta il numero del primo ciclo disponibile per l’esecuzione; se la precedente non è ancora stata schedulata, l’operazione viene momentaneamente “saltata” e si procede con le altre. Tutto ciò è ripetuto fino a quando tutte le operazioni hanno una etichetta valida (cioè tutte le operazioni sono state schedulate). • Algoritmo ALAP (As Late As Possible): è l’opposto dell’algoritmo precedente. In pratica cerca di schedulare le operazioni nell’ultimo ciclo utile. La procedura è speculare alla precedente. L’algoritmo analizza prima tutte le operazioni che non hanno un successore e le assegna all’ultimo ciclo di controllo. A questo punto risale il grafo analizzando le operazioni che precedono quelle già schedulate e assegnando loro una etichetta che indica l’ultimo ciclo utile in cui possono essere eseguite. Come si sarà certamente notato, questi due algoritmi non prendono mai in considerazione le risorse. Sono infatti algoritmi che minimizzano la latenza, e sono pertanto facili da realizzare. Sulla base di questi due algoritmi si definisce un parametro che verrà utilizzato in altri metodi: la mobilità degli operatori. Sia Sk e Lk l’etichetta assegnata alla k-esima operazione dall’algoritmo 31
ASAP e ALAP rispettivamente. Chiaramente Sk sarà minore o uguale a Lk. Definiamo mobility range dell’operazione l’insieme dei passi di controllo cj tali che Sj≤j≤Lj, ovvero l’insieme di quei passi di controllo compresi tra il passo di controllo “al più presto” e “al più tardi”. Si definisce mobilità dell’operatore la differenza tra Lk e Sk. Abbiamo già accennato alla classificazione degli algoritmi di scheduling in time-constrained e resource-constrained: • Time-constrained: fanno parte di questa famiglia algoritmi quali Integer Linear Programming (ILP), Force Direct Scheduling, Iterative Refinement. Questi algoritmi sono importanti nei sistemi dedicati e real time e hanno come obiettivo la minimizzazione del numero di risorse, fissato il numero di passi di controllo. • Resource-constrained: fanno parte di questa famiglia algoritmi quali List Based Scheduling e Static List Scheduling. Questi algoritmi sono importanti per sistemi sui quali ci sono vincoli sull’area di silicio o sul consumo di potenza poiché ottimizzano il numero di passi di controllo, dato un vincolo sulle risorse. In generale questi algoritmi costruiscono lo scheduling assegnando una operazione alla volta (per non oltrepassare il vincolo sulle risorse e per non violare le dipendenze sui dati). I vincoli sulle risorse sono rispettati garantendo che in un passo di controllo non si assegnino più operazioni di quanti non siano le unità funzionali per eseguirle. La dipendenza dei dati è rispettata assicurandosi che prima di assegnare una operazione, siano già state assegnate le operazioni precedenti. Force Direct Scheduling. Questo algoritmo è della famiglia time-constrained e ha pertanto l’obiettivo di ridurre il numero di unità funzionali dato un vincolo sul numero di passi di controllo. Ciò viene ottenuto distribuendo in modo uniforme le operazioni dello stesso tipo in tutti gli stati disponibili. Tale distribuzione garantisce che le unità funzionali allocate in un passo di controllo vengano usate in modo efficiente in tutti gli altri passi di controllo: si ha un miglior rapporto di utilizzazione. Si parte calcolando la mobilità di ogni operatore e si assume che ogni operazione abbia probabilità uniforme di essere assegnata a qualunque passo del campo ammissibile (mobility range) e probabilità zero di essere assegnata ad un passo esterno a questo intervallo. Per uno stato c j tale che 1 Sj≤j≤Lj la probabilità che l’operazione oi sia assegnata a cj è data da: p j (oi ) = . Si Lj − S j +1 costruisce a questo punto un diagramma che rappresenta il costo previsto per l’operatore nel rispettivo stato: Expected Operator Cost (EOC). Tale costo è ottenuto come somma del costo dell’unità funzionale moltiplicata per la probabilità che l’operazione corrispondente sia assegnata al passo j. In formule si può scrivere: EOC j ,k = Ck ⋅ ∑i ,s ∈m _ range ( o ) p j (oi ) dove Ck è il costo della kj
i
esima unità funzionale, m_range(oi) è il mobility range dell’operazione i-esima e sj è il j-esimo passo di controllo. L’EOC viene costruito per tutti i tipi di operazione per tutti i passi di controllo (che si ricorda sono in numero fissato) e si cerca di bilanciarli spostando le operazioni nei vari cicli per fare in modo che gli EOC di ogni ciclo (e di ogni operazione) siano il più possibile omogenei. Una volta assegnata una operazione ad un passo di controllo, questa non viene più “spostata” (no back tracking). Il risultato che si ottiene può non essere ottimo. Si può ottenere un ulteriore miglioramento utilizzando tecniche di raffinamento iterativo in cui si ammette di ri-assegnare un’operazione già assegnata utilizzando opportune strategie. List Based Scheduling. È un algoritmo resource-constrained, per cui si assegnano delle risorse e si cerca di minimizzare il numero di passi di controllo. L’algoritmo mantiene una lista di nodi ready, cioè dei nodi i cui predecessori sono già stati assegnati. Questa lista è ordinata per una funzione di priorità e ne esiste una per ogni operazione. La mobilità è una buona funzione di priorità: operatori con mobilità bassa hanno maggiore priorità. Ad ogni iterazione si assegnano le operazioni all’inizio della lista fino a 32
quando le risorse disponibili non sono state tutte assegnate. L’assegnamento di una operazione rende ready altre operazioni che perciò entrano nella lista, con il loro valore di priorità. Static List Scheduling. Anche questo è della famiglia resource-constrained. Date le risorse, si definisce una lista di priorità ordinando i nodi in questo modo: • 1° chiave di ordinamento: etichette ALAP decrescenti. • 2° chiave di ordinamento: etichette ASAP decrescenti. A parità delle precedenti chiavi, l’ordinamento è casuale. Una volta definita questa lista, partendo dall’operazione a priorità più alta, usando lo scheduling ASAP soggetto ai vincoli sulle risorse e nel rispetto delle dipendenze, si assegnano le operazioni ai vari cicli di controllo.
Verso prestazioni sempre maggiori (I). Introduzione. Nel capitolo “CPU ad elevate prestazioni” abbiamo terminato il discorso accennando al renaming, ovvero a quella tecnica che permette di rinominare i registri ed evitare quindi dipendenze di tipo WAR e WAW. Consideriamo un esempio: DIV.D F0,F2,F4 ADD.D F6,F0,F8 S.D F6,0(R1) SUB.D F8,F10,F14 MUL.D F6,F10,F8 Tra le istruzioni ADD.D e SUB.D c’è una dipendenza WAR sul registro F8, infatti F8 viene scritto in SUB.D dopo essere stato letto in ADD.D. Come già detto, in una CPU scalare con una sola pipeline ciò non crea nessun problema, perché l’esecuzione delle istruzioni avviene in ordine, per cui siamo certi che ADD.D verrà eseguita prima di SUB.D. Nelle CPU superscalari, invece, non è garantita l’esecuzione in ordine, per cui può anche capitare che i registri F10 e F14 siano disponibili prima di F0 e F8 e di conseguenza l’istruzione SUB.D inizi e si completi prima dell’istruzione ADD.D portando ad una scrittura di F8 “errata”. Un ragionamento analogo può essere fatto tra ADD.D e MUL.D per quanto riguarda l’alea WAW che interessa F6. Un modo semplice per risolvere questo problema è la ridenominazione statica, cioè compiuta dal compilatore. Tornando all’esempio, si potrebbe sostituire il registro F6 con il registro S e il registro F8 con il registro T: DIV.D F0,F2,F4 ADD.D S,F0,F8 S.D S,0(R1) SUB.D T,F10,F14 MUL.D F6,F10,T In questo capitolo approfondiremo il problema del renaming introducendo delle nuove tecniche che, appunto, portano a prestazioni della CPU sempre maggiori. Algoritmo di Tomasulo. Potremmo pensare di attuare la ridenominazione dinamica (cioè eseguita a run time dall’hw) usando le reservation stations e la logica di issue. In pratica, una RS legge e registra un operando non appena questo è disponibile, e le istruzioni in attesa di un operando registrano la RS che fornirà loro l’operando (quindi una RS contiene l’istruzione lanciata e in attesa di esecuzione più i valori degli operandi o in alternativa il nome della RS che fornirà gli operandi). Quindi, al lancio dell’istruzione gli indicatori dei registri per gli operandi vengono ridenominati con i nomi delle RS. In questo modo l’identificazione delle alee e il controllo dell’esecuzione sono attività distribuite: l’informazione nella RS di ogni unità funzionale determina quando può iniziare l’esecuzione su quella unità funzionale. È necessario introdurre una nuova struttura hw detta Common Data Bus sul 33
quale si possono scambiare le informazioni senza dover passare per il Register File. Il CDB non è l’unica unità hw aggiuntiva che deve essere prevista. Vengono introdotti anche i Load Buffer e gli Store Buffer che contengono dati (per le store) o indirizzi (per le load) da scambiare con la memoria; vengono introdotti dei registri FP collegati mediante due bus alle unità funzionali e da un bus allo Store Buffer. Il CDB trasporta tutti i dati da memoria e dalle unità funzionali e raggiunge tutti gli elementi tranne il Load Buffer. In particolare, una RS contiene i seguenti campi: • Tag: identifica la RS. • OpCode: l’istruzione lanciata e in attesa di esecuzione. • I valori degli operandi sorgente (Vj e Vk). • I puntatori alle RS che producono Vj e Vk (Qj e Qk). • Il campo Busy che indica che la RS è occupata. Instructions queue
FP Registers
Address Unit
Load Buffer RS
RS
Store Buffer
Memory Unit
FP Adder
FP Multiplier
Common Data Bus
Possiamo ora analizzare i tre stadi dell’algoritmo di Tomasulo. 1. Issue. Si prende l’istruzione I dalla testa della coda delle istruzioni (gestita in ordine FIFO per garantire la correttezza del flusso di dati) e si cerca una RS vuota adatta all’istruzione alla quale viene inviata l’istruzione e il valore degli operandi (se disponibili nei registri). Se gli operandi non sono disponibili si tiene conto dell’unità funzionale che li produce (registrando cioè il tag della RS che produrrà il risultato) e di fatto si attua la ridenominazione, eliminando alee WAR e WAW. Se non è disponibile nessuna RS allora, l’alea strutturale blocca il lancio delle istruzioni. 2. Esecuzione. Fino a che gli operandi non sono disponibili si monitora il CDB in attesa dei risultati delle istruzioni in esecuzione: non appena gli operandi sono disponibili si esegue l’istruzione. In questo modo si ritarda l’esecuzione dell’istruzione all’istante in cui gli operandi sono disponibili e di fatto si evitano alee di tipo RAW. È possibile che più istruzioni possano diventare eseguibili nello stesso ciclo e si deve pertanto operare una scelta. Questa scelta è particolarmente critica nel caso delle load e delle store: non si può scegliere a caso, ma bisogna tenere conto degli indirizzi di memoria ai quali fanno riferimento. Per le load e le store il processo è quindi diviso in due passi. Al primo passo si calcola l’indirizzo (con l’Address Unit) reale e lo si scrive nel buffer di load e store. A questo punto, le load vengono eseguite appena si rende disponibile l’unità di memoria; le store, invece, devono attendere che si renda disponibile il risultato da memorizzare. Le load e le store vengono mantenute in ordine di programma mediante il calcolo dell’indirizzo reale in modo da evitare le alee sulla memoria. In realtà una load e una store possono essere eseguite anche fuori ordine a patto che non facciano riferimento allo stesso indirizzo in memoria, altrimenti si possono creare alee WAR se si scambia la 34
sequenza load-store, alee RAW se si scambia la sequenza store-load o alee WAW se si invertono due store. Si noti che le load possono essere riordinate liberamente poiché non esistono alee “RAR”. È evidente comunque che per evitare queste alee (e quindi evitare lo “scambio” di due istruzioni) è necessario che la CPU abbia già calcolato gli indirizzi in memoria dei dati associati con qualunque precedente operazione sulla memoria: ecco perché il calcolo degli indirizzi deve essere fatto in ordine di programma. Ultimo aspetto da considerare è il comportamento da tenere per mantenere la gestione delle eccezioni “precisa”: nessuna istruzione può iniziare l’esecuzione fino a che i salti condizionati che la precedono non sono stati risolti. Se si usa la branch prediction la CPU deve conoscere la correttezza della predizione prima che inizi l’esecuzione delle seguenti istruzioni. Vedremo che si ottengono risultati molto più brillanti con la speculazione. 3. Scrittura dei risultati. Le scritture si compiono nello stadio detto di Write Result. Quando il risultato è disponibile lo si scrive sul CDB il quale lo porta nei registri e in qualsiasi RS ne abbia bisogno (inclusi gli store buffer). Una store scrive i dati in memoria (non sul CDB) quando sono disponibili l’indirizzo e il dato (quando accade ciò la store è terminata). Esecuzione parallela. Approfondiamo ora alcuni concetti propri dell’esecuzione parallela che ci serviranno più avanti per il ROB e l’esecuzione speculativa. Le istruzioni eseguite in parallelo sono in generale terminate fuori ordine (cioè istruzioni più “giovani” possono essere completate prima delle istruzioni più “vecchie”). Diremo quindi che: • Una istruzione è finita quando l’operazione richiesta dal codice operativo è completata a meno della fase di Write Back al registro (architetturale) destinazione o alla memoria. • Una istruzione è completata quando il risultato è stato scritto nel registro destinazione (o nella memoria). • Una istruzione è committed (o retired) quando è completata e l’architettura comprende il ROB. Con l’esecuzione fuori ordine è necessario parlare anche di consistenza sequenziale. La consistenza sequenziale dell’esecuzione del programma si riferisce all’ordine in cui le istruzioni vengono completate (consistenza del processore) e all’ordine in cui si accede alla memoria (consistenza di memoria). Entrambe possono essere deboli o forti. • Consistenza del processore debole: le istruzioni possono essere completate fuori ordine purché non si sacrifichi alcuna dipendenza di dati. • Consistenza del processore forte: le istruzioni vengono forzate al completamento in stretto ordine di programma usando il ROB. In questo caso, le istruzioni vengono finite fuori ordine, ma modificano lo stato della macchina (cioè giungono a commit) in ordine. • Consistenza della memoria debole: gli accessi alla memoria possono essere fuori ordine purché non si violi alcuna dipendenza di dati. Questo ci permette di utilizzare il riordinamento di load e store e ottenere così migliori prestazioni (tecnica usata sulla maggior parte delle CPU moderne). • Consistenza della memoria forte: gli accessi alla memoria si verificano strettamente in ordine di programma. Sulla base delle precedenti possiamo quindi caratterizzare la consistenza sequenziale di una architettura (processore e memoria) integrando entrambi gli aspetti. Le alternative possibili sono WW, WS, SW e SS (dove W sta per weak = debole, e S sta per strong = forte). Le CPU moderne tendono ad utilizzare il modello di consistenza SW: forte per la CPU e debole per la memoria. Iniziamo a discutere della consistenza debole della memoria che ha il suo aspetto principale nella possibilità di riordinare le load e le store. In generale, sia la load che la store prima di accedere alla memoria hanno la necessità di conoscere l’indirizzo in cui accedere. Una volta noto l’indirizzo, la load accede alla D-cache per leggere il dato che viene poi reso disponibile per il registro destinazione (a questo punto la load è finita); la load è completata quando il valore viene effettivamente scritto nel registro architetturale. Una store, 35
oltre a dover attendere l’indirizzo, deve anche attendere che il dato da memorizzare sia disponibile. La consistenza debole permette il riordino delle load e delle store e quindi permette di usare la tecnica del load/store bypassing, rende fattibili le load/store speculative e consente di mascherare i cache miss. Il load/store bypassing consente ad una load di “scavalcare” una store (e viceversa) purchè non si violi alcuna dipendenza di dati in memoria. Il bypass di una load ai danni di una store precedente può avvenire solo se nessuna store ha lo stesso indirizzo di memoria: è quindi necessario verificare che l’indirizzo in possesso alla load non coincida con nessuna store non ancora completata. La soluzione più semplice prevede di attendere ad effettuare il bypassing fino a che le precedenti store che hanno lo stesso indirizzo non sono completate (esecuzione non speculativa). Oppure si può procedere con l’esecuzione speculativa delle load: si inizia l’accesso a memoria nonostante i confronti con gli altri indirizzi non siano ancora stati terminati. Con alta probabilità gli indirizzi non coincideranno, ma è comunque necessario terminare la verifica. Quando gli indirizzi relativi alle store sono stati calcolati, si verifica quello relativo alla load speculativa: se coincidono è necessario annullare la load speculativa e tutte le istruzioni successive, e quindi ri-eseguire la load. Si noti che non si possono eseguire store speculative poiché una scrittura in memoria non può essere annullata. Passiamo ora alla consistenza forte del processore e al ROB (ReOrder Buffer). Il ROB è stato introdotto inizialmente per il problema degli interrupt precisi e successivamente è stato adottato per garantire la consistenza sequenziale. In pratica il ROB è un buffer circolare dotato di due puntatori: quello di testa indica il primo elemento libero del buffer; quello di coda indica la prossima istruzione che lascerà il ROB essendo giunta a commit. Le istruzioni vengono scritte nel ROB strettamente in ordine di programma: quando si lancia una istruzione le si alloca un elemento nel ROB in sequenza e questo elemento contiene molte informazioni relative all’istruzione, tra cui il suo stato (lanciata, in esecuzione o finita). Un’istruzione può giungere a commit solo se è finita e se tutte le precedenti istruzioni sono già giunte a commit. Solo le istruzioni che giungono a commit possono essere completate, ovvero possono modificare lo stato della macchina (aggiornando i registri e la memoria). Il ROB può gestire sia l’esecuzione speculativa sia, come detto, la gestione delle eccezioni. Per gestire l’esecuzione speculativa ogni elemento del ROB viene esteso aggiungendo un nuovo campo che indica se l’istruzione è eseguita in modo speculativo. Le istruzioni finite non possono giungere a commit se sono eseguite in modo speculativo: per completarle è necessario risolvere la speculazione (verificare cioè che la previsione sia corretta). Per quanto riguarda le eccezioni, invece, abbiamo che quelle generate in relazione all’esecuzione di una istruzione vengono rese precise accettando la richiesta di eccezione solo quando l’istruzione diventa “prossima a commit” (le eccezioni vengono gestite in ordine). Le interruzioni esterne possono essere invece associate convenzionalmente alla istruzione in coda al ROB. Quando si gestisce una eccezione il ROB viene svuotato del suo contenuto successivo all’istruzione associata all’eccezione. La predizione dei salti. Le alee dovute ai dati e alle risorse sono state in qualche modo l’argomento centrale dei precedenti capitoli. Se si vogliono ottenere prestazioni ancora superiori è necessario studiare i modi per gestire le alee di controllo e ovviamente, evitarle. Abbiamo già introdotto in precedenza i seguenti modi per ridurre la penalizzazione riguardo ad un salto: • Svuotare la pipeline quando si decodifica un salto condizionato. • Scegliere la tecnica di prevedere che il salto non venga mai eseguito (si avviano nella pipeline le istruzioni successive al salto). • Scegliere la tecnica di prevedere che il salto venga sempre eseguito (non appena si è decodificato il salto condizionato e si è calcolato l’indirizzo di destinazione si avviano le istruzioni che iniziano all’indirizzo destinazione del salto). • Ritardare il salto. 36
Possiamo calcolare lo speedup reale della pipeline in presenza delle penalizzazioni di salto (supponendo che il CPI ideale sia uno): profonditàPipeline profonditàPipeline SpeedupPipeline = = 1+ # stalli 1 + frequenzasalti ⋅ penalitàsalto questo sarà dato dal rapporto tra la profondità della pipeline e il termine 1 + “numero stalli nella pipeline dovuti ai salti”. Il numero di stalli nella pipeline dovuti ai salti è poi legato alla frequenza con cui si eseguono i salti in un programma moltiplicato per la penalità di ogni salto. Queste due componenti possono riguardare sia i salti incondizionati che quelli condizionati, ma i secondi sono senza dubbio i più frequenti. Ciò che possiamo fare è ridurre la penalità dovuta alle dipendenze di controllo (non certo la loro frequenza!). La predizione dei salti condizionati (Branch Prediction) può portare a notevoli miglioramenti in termini di prestazioni agendo soprattutto sulla penalità dovuta ai salti: ciò che si deve fare è predire il prima possibile il risultato di un salto condizionato. Le prestazioni nella predizione dei salti dipendono dall’accuratezza della previsione, cioè il rapporto tra previsioni errate (misprediction) e il totale delle previsioni, e dal costo di una errata predizione, cioè il tempo sprecato eseguendo istruzioni non utili. La branch prediction può essere svolta dinamicamente con dell’hw specializzato e ha lo scopo di decidere rapidamente l’esito del salto (“da fare” o “da non fare”) e l’indirizzo di destinazione. La soluzione più semplice prevede l’uso del Branch Prediction Buffer (BPB) che è una piccola memoria indirizzata mediante la parte meno significativa dell’indirizzo di una istruzione di salto. Ad ogni indirizzo è associato un bit che indica se quel salto è stato recentemente fatto oppure no. Il buffer potrebbe contenere anche dei tag, ma questi diventano utili solo per ridurre il ritardo del salto quando questo ritardo è più lungo del tempo per calcolare il possibile indirizzo di destinazione. Si noti che c’è anche la possibilità che la predizione sia errata a causa del fatto che il bit di predizione sia stato impostato da un altro salto che ha gli ultimi bit dell’indirizzo coincidenti con quelli del salto che si sta analizzando. La predizione fatta sul salto è da intendersi come un “suggerimento che si presume corretto”, ovvero la lettura della prossima istruzione inizia nella direzione predetta, e se poi si scopre che il suggerimento è errato, si inverte il bit relativo a quel salto (ovvero se, ad esempio, il bit prediceva che il salto andasse fatto, si scrive nel BPB che lo stesso salto non va fatto). Il BPB è quindi una cache in cui ogni accesso è un hit e le sue prestazioni sono essenzialmente legate a quanto spesso la predizione fa veramente riferimento al salto attuale e a quanto la predizione è corretta. Consideriamo questo esempio: un ciclo ripetuto per 10 volte. Al termine del ciclo si ha inevitabilmente una predizione errata poiché le nove volte precedenti il ciclo è stato eseguito. Inoltre c’è la possibilità che all’inizio del ciclo un salto con gli stessi bit finali avesse il bit corrispondente impostato a “salto non fatto” introducendo quindi un’altra predizione errata (il ciclo andrà ripetuto dieci volte). L’accuratezza, in questo esempio, è quindi dell’80%: ci sono due predizioni errate su dieci. Per superare il problema sollevato nell’esempio precedente si può introdurre la predizione a due bit: prima di invertire la previsione, si deve sbagliare due volte. Quello che ne risulta è una macchina a stati finiti: in due stati il salto è predetto come “da fare” (stati 112 e 102) e in due come “non da fare” (012 e 002). Se, ad esempio siamo nello stato 112 prima di invertire i bit di previsione è necessario sbagliare due volte la previsione (il primo errore ci porta nello stato 10 2 e quindi il salto è ancora predetto come “da fare”; il secondo errore ci porta nello stato 00 2 e il salto è predetto come “da non fare”). Si può estendere lo stesso principio a N bit usando un contatore saturante (da 0 a 2 N-1) e la seguente regola: quando il valore del contatore è maggiore o uguale al valore massimo rappresentabile con N bit, il salto è “da fare”. Il contatore va incrementato se il salto si fa, decrementato se non si fa (per esempio con N=3 e stato di partenza 010 2=2 il salto viene predetto come “da non fare”; un errore ci porta nello stato 0012=1 e la predizione resta la stessa; un altro errore ci porta nello stato 0002=0 e continuiamo a predire il salto come “da non fare”; il terzo errore ci porta nello stato 1112=7: il salto è “da fare”). Uno schema a N bit è raramente usato poiché le 37
statistiche indicano che gli schemi a 2 bit funzionano quasi altrettanto bene. Con la predizione a due bit e un BPB di 4K da prestazioni prossime a quelle di un buffer infinito. Nella pratica, usando la classica pipeline a 5 stadi, si accede al BPB nel ciclo ID, al termine del quale otteniamo l’indirizzo destinazione del salto (calcolato in ID, è l’indirizzo della prossima istruzione se il salto deve essere fatto), l’indirizzo fall-through (calcolato in IF, è l’indirizzo della prossima istruzione se il salto non deve essere fatto, ed è il PC del salto incrementato di 4) e la predizione del salto. Queste informazioni sono sufficienti per leggere l’istruzione predetta. Anche con l’uso del BPB abbiamo a che fare comunque con una penalità di un ciclo. Infatti veniamo a conoscenza del prossimo PC solo nella fase ID cioè quando stiamo già eseguendo la fase IF della prossima istruzione. Se questa non è l’istruzione destinazione del salto, deve essere annullata perdendo un ciclo di clock. Si noti che per ottenere prestazioni migliori, il fattore da migliorare non è l’hit rate del buffer, ma l’accuratezza della predizione, e questo comporta non solo la predizione dei salti ma anche la fornitura di un flusso di istruzioni su larga banda (4-8 istruzioni per ciclo). Questo implica la predizione dell’indirizzo destinazione del salto (e non solo se il salto andrà fatto o meno). Le CPU recenti integrano nella Fetch Unit le seguenti funzioni: • Predizione dei salti • Prefetch delle istruzioni • Accessi e buffering dalla I-cache (la lettura di più istruzioni può coinvolgere blocchi di cache diversi; la Intruction Fetch Unit usa il prefetching per mascherare la latenza e fornisce la registrazione in registri tampone). Un’altra tecnica possibile è quella che fa uso del Branch Target Buffer (BTB). Ciò che si vorrebbe avere al termine della fase IF (riferendosi alla pipeline del MIPS) è il Program Counter della prossima istruzione, ovvero si vorrebbe conoscere qual è la prossima istruzione da avviare nella pipeline prima ancora di aver decodificato l’istruzione attuale. Questo, infatti, porterebbe ad una penalità pari a zero, sempre che l’istruzione attuale sia un salto (condizionato o incondizionato). Ecco quindi che si introduce una cache di predizione dei salti che memorizza gli indirizzi predetti per l’istruzione successiva a quella di salto. A questa memoria si accede nella fase IF e si utilizza l’indirizzo dell’istruzione letta come indice nel buffer. Il BTB contiene indirizzi di soli salti, per cui se l’istruzione letta è un salto, il suo indirizzo sarà contenuto nella memoria, altrimenti l’istruzione attuale non è un salto. Se l’istruzione è un salto, si legge l’indirizzo destinazione associato (PC predetto) e la previsione del salto. Alla fine della fase IF quindi si conosce già se l’istruzione attuale è un salto e si utilizza il PC predetto come indirizzo della prossima istruzione da prelevare e avviare nella pipeline: ciò che si ottiene (se la previsione è corretta) è una penalità pari a zero. In pratica nel BTB vengono memorizzati solo gli indirizzi predetti dei salti che devono essere eseguiti: se un salto non deve essere eseguito, il calcolo del prossimo PC è identico a quello delle altre istruzioni (non c’è nemmeno bisogno di memorizzare un bit che indichi se il salto deve essere fatto: questa informazione è implicita nel PC predetto). Speculazione hw-based. Quello che si fa è scommettere (si specula) sul risultato dei salti condizionati, e si esegue il programma come se le predizioni fossero corrette (lo scheduling dinamico semplice si limiterebbe a leggere e decodificare le istruzioni, ma non ad eseguirle): occorrono meccanismi per gestire le speculazioni errate. La speculazione hw estende quindi lo scheduling dinamico, ed in particolare combina i seguenti aspetti: • Predizione dinamica dei salti: per scegliere quale istruzione eseguire. • Speculazione: per poter eseguire delle istruzioni prima di aver risolto le dipendenze (con la possibilità di annullare gli effetti di predizioni errate). • Scheduling dinamico: per gestire diverse combinazioni di blocchi basici (cioè di blocchi di codice lineare). 38
La speculazione segue il flusso predetto dei valori dei dati per scegliere quando eseguire una istruzione. L’esecuzione è essenzialmente di tipo data flow: le istruzioni vengono eseguite non appena gli operandi sono disponibili. Ciò che si fa è estendere il supporto hw per l’algoritmo di Tomasulo in modo tale da poter supportare la speculazione (la fase di commit viene separata dalla fase di esecuzione: si introduce il ROB). L’algoritmo base di Tomasulo porterebbe (nella fase di write result) ad una scrittura dei risultati nel Register File. Con la speculazione i risultati vengono scritti (cioè modificano lo stato della macchina) solo quando l’istruzione giunge a commit (cioè quando si sa anche se l’istruzione andava eseguita, nel caso fosse speculativa). L’idea è quindi quella di consentire una esecuzione fuori ordine, ma assicurare un completamento in ordine. È necessario apportare una modifica anche al ROB, nel quale ogni elemento conterrà i seguenti campi: • Campo tipo istruzione: indica se l’istruzione è un salto condizionato (non c’è destinazione per il risultato), una store (la destinazione è un indirizzo di memoria), una load o una operazione ALU (per le quali la destinazione è un registro). • Campo destinazione: fornisce per le load e le istruzioni ALU il numero del registro in cui scrivere il risultato, e per le store l’indirizzo di memoria nel quale scrivere il valore. • Campo valore: usato per contenere il valore del risultato fino a che l’istruzione giunge a commit. • Campo ready: indica che l’esecuzione è completata e che il valore è pronto. Lo schema hw dell’algoritmo di Tomasulo viene quindi modificato poiché il ROB sostituisce gli store buffer e in pratica le store vengono eseguite in due passi: il secondo (la scrittura) si esegue solo al momento del commit. Il ROB si incarica anche della funzione di renaming in precedenza attuato dalle Reservation Stations, le quali registrano gli operandi e le operazioni tra l’istante di lancio e quello in cui inizia l’esecuzione. I risultati verranno poi etichettati con il numero dell’elemento del ROB invece che con il numero della RS; questo implica che l’elemento del ROB assegnato alla istruzione deve essere rintracciato nelle RS (infatti nell’algoritmo di Tomasulo le RS possono contenere riferimenti ad altre RS che producono i risultati attesi; con questa modifica non si dovrà più cercare il tag della RS ma il numero dell’elemento del ROB associato al risultato atteso). Tutte le istruzioni non speculative e quelle speculate in modo corretto giungono a compimento (commit) quando raggiungono la coda del ROB. Quando un salto speculato in modo errato giunge alla coda del ROB si indica che la speculazione è sbagliata, il ROB viene svuotato e l’esecuzione riprende dal successore corretto del salto; in questo modo le speculazioni vengono facilmente annullate e non rischiano di modificare lo stato della macchina in modo irreversibile. Possiamo allora riassumere le diverse fasi dell’esecuzione dopo aver introdotto queste ultime modifiche: • Issue: l’istruzione viene estratta dalla coda delle istruzioni e se c’è una RS libera e un elemento del ROB vuoto l’istruzione viene lanciata (in caso contrario la CPU va in stallo). Se gli operandi sono disponibili nel RF o nel ROB vengono inviati alla RS che ha accolto l’istruzione; si invia anche la posizione dell’istruzione nel ROB poiché questo valore servirà ad etichettare i risultati quando verranno posti sul Common Data Bus. • Execute: se gli operandi (entrambi o anche solo uno dei due) non sono disponibili il CDB viene monitorato nell’attesa dei risultati richiesti (in questo modo si evitano alee RAW). Quando tutti gli operandi sono entrambi disponibili si esegue l’operazione (per una store è necessario possedere solo il registro di base: a questo punto si calcola solo l’indirizzo reale, cioè di memoria). • Write Result: non appena il risultato è disponibile questo viene scritto sul CDB e da qui alle RS e agli elementi del ROB che lo attendono (a questo punto la RS è disponibile ad accettare nuove istruzioni). Se l’istruzione era una store il risultato viene scritto nel campo valore dell’elemento del ROB ad essa associato. • Commit: in questa fase abbiamo diverse possibilità. Se l’istruzione che raggiunge la coda del ROB non è speculativa (o è stata speculata in modo corretto) e se il risultato è presente nel 39
buffer, allora si scrive il risultato nel registro destinazione e si cancella l’istruzione dal ROB (se l’istruzione è una store il risultato è scritto in memoria). Se invece l’istruzione è un salto con predizione errata il ROB viene svuotato e l’esecuzione riprende dal successore corretto del salto. Il ROB era stato introdotto inizialmente per mantenere un modello di gestione delle interruzioni preciso ed, ovviamente, questa caratteristica viene mantenuta: se l’istruzione Ij genera una eccezione, la CPU attende fino a che Ij giunge alla coda del ROB e solo allora gestisce l’eccezione. Le eccezioni possono essere sollevate anche da salti speculati: se la speculazione è errata l’eccezione viene ignorata. Si noti che la speculazione porta all’eliminazione di alee WAW e WAR sulla memoria poiché l’aggiornamento della memoria è fatto in ordine (in un certo senso è come se rinominassimo anche gli indirizzi di memoria). Per quanto riguarda le alee RAW sulla memoria invece, vengono introdotte due limitazioni: una load non può leggere dalla memoria se nel ROB c’è una store attiva che ha lo stesso indirizzo di memoria come destinazione, ed inoltre, si mantiene l’ordine di programma per il calcolo degli indirizzi della load rispetto a tutte le store precedenti. L’uso del ROB per la ridenominazione non è “obbligato”: alcune CPU utilizzano un maggior numero di registri e la tecnica di ridenominazione. Nell’algoritmo di Tomasulo questo significa che i valori dei registri architetturalmente visibili sono contenuti, in qualsiasi punto dell’esecuzione, in una qualche combinazione di insieme dei registri e RS. Se si attua la speculazione i valori possono trovarsi temporaneamente nel ROB: se per un certo tempo non si lanciano nuove istruzioni tutte le istruzioni nel ROB giungono a commit e i valori dei registri vengono scritti nel RF il quale corrisponde direttamente ai registri architetturalmente visibili. In questo modo le alee WAR e WAW sono evitate grazie al renaming dei registri; il registro fisico che riceve il risultato di una istruzione speculata diventa un registro architetturale solo al momento del commit. Si deve mantenere una mappa di ridenominazione che è una semplice struttura dati che fornisce il numero del registro fisico che al momento corrisponde al registro architetturale specificato. In conclusione, il commit e la gestione delle speculazioni errate risultano più semplici con l’uso del ROB e il mapping dinamico dei registri complica il progetto e il debugging.
Verso prestazioni sempre maggiori (II). Dov’è il problema. Fino a qui abbiamo cercato di ottenere il massimo dalle prestazioni delle architetture proposte, le quali, come detto inizialmente, vanno tutte alla ricerca del parallelismo intrinseco ad un programma eseguibile. Ecco qual è il limite: la “quantità” di ILP (parallelismo intrinseco) che si trova all’interno di un programma. Questa quantità è finita e molto limitata, quindi ci chiediamo: cosa serve per sfruttare maggior ILP? Per poter migliorare ancora dobbiamo concentrarci sia sul compilatore sia sull’architettura. Il fatto che l’ILP di un programma sia limitato è supportato dalle seguenti considerazioni. Consideriamo un modello hw ideale per la valutazione delle prestazioni. Gli unici limiti che si pongono all’ILP sono quelli dovuti all’effettivo flusso dei dati all’interno dei registri e della memoria. Assumiamo quindi queste ipotesi: • Ridenominazione dei registri: perfetta! Assumiamo di avere un numero infinito di registri. In pratica, eliminiamo le dipendenze di dati “false”. • Predizione dei salti (condizionati e incondizionati): perfetta! Eliminiamo ogni dipendenza di controllo. • Analisi degli indirizzi di memoria: perfetta! Non ci sono problemi di false dipendenze nemmeno sulla memoria. Qualsiasi istruzione può quindi essere schedulata nel ciclo seguente a quello dell’istruzione da cui dipende, perché la speculazione risulta essere perfetta. Inoltre assumiamo che la CPU possa lanciare simultaneamente un numero illimitato di istruzioni esplorando il programma su una finestra illimitata; supponiamo che non ci siano restrizioni sui tipi di istruzioni che si possono eseguire in un 40
ciclo; tutte le unità funzionali hanno latenza pari a uno e le cache sono perfette, ovvero tutte le load e le store si eseguono in un solo ciclo. Testando questa CPU (nella pratica irrealizzabile) con dei benchmark tipo SPEC92 il parallelismo che si ottiene va da 16 a 150 (con codice opportunamente ottimizzato). Questo significa che nel caso ideale non abbiamo parallelismo illimitato, ma limitato e piuttosto basso. Un processore reale non potrà mai raggiungere valori simili: ecco perché l’estrazione di ILP rappresenta un limite al raggiungimento di prestazioni elevate. Il limite principale dell’estrazione di maggior parallelismo dal codice sta nella finestra delle istruzioni. Ricordiamo che nelle CPU superscalari, prima di lanciare un’istruzione, bisogna verificare che essa sia libera da dipendenze con le istruzioni già in esecuzione e con quelle presenti nella finestra di lancio. Supponiamo quindi che la dimensione della finestra sia pari a 2000. Per poter effettuare tutti i controlli sulle dipendenze si devono eseguire circa 4 milioni di confronti, che è impensabile per una CPU reale. Il numero di istruzioni da considerare per il lancio deve essere molto limitato (nelle CPU moderne la finestra è grande da 32 a 126 istruzioni). A tutto ciò, in una CPU reale, vanno aggiunti i limiti legati al numero finito (e scarso) di registri, alla necessità di ricercare le dipendenze tra coppie di istruzioni, all’issue in ordine, al numero limitato di unità funzionali, al numero limitato di bus, al numero di porte del Register File. Tutto ciò porta a limiti sul numero di istruzioni iniziate in un solo ciclo e quindi al numero massimo di istruzioni che possono essere lanciate, eseguite e portate a commit. L’architettura P6. L’architettura P6 è la base del Pentium Pro (1995), del Pentium II (1998) e del Pentium III (1999). La differenza in ognuna di queste architetture commerciali sta essenzialmente nell’organizzazione delle cache, nella frequenza di clock e nell’interfacciamento alla memoria. L’architettura P6 è stata progettata tenendo presente la necessità di mantenere la portabilità di codice compilato per macchine precedenti (come le architetture 386, 486, …). Per questo motivo prevede una unità in grado di “convertire” istruzioni complesse (CISC) in istruzioni semplici (RISC). In questo modo il “vecchio” codice sorgente può essere eseguito dal processore superscalare senza avere la necessità di ricompilare. La P6 ha una CPU con scheduling dinamico e traduce ogni istruzione IA-32 (CISC) in una serie di micro operazioni (μ-ops, tipiche istruzioni RISC) eseguite dalla pipeline. In ogni ciclo si leggono, decodificano e traducono in μ-ops fino a tre istruzioni IA-32. La P6 è quindi un superscalare a 3 vie ed è fondamentalmente “spinto” da 3 motori: unità di lettura e decodifica, unità di dispatch ed esecuzione e una unità di retire (o commit). L’unità di lettura e di decodifica inserisce le istruzioni decodificate nel pool (e cioè converte le istruzioni IA-32 in μ-ops). L’unità di dispatch e esecuzione si occupa del lancio fuori ordine delle μ-ops presenti nel pool passandole alle reservation stations. L’unità di retire, infine, riordina le istruzioni e porta a commit i risultati (speculativi e non). Le istruzioni IA-32 che richiedono più di 4 μ-ops, vengono implementate da sequenze di micro codice che generano le necessarie μ-ops in più cicli di clock. Il numero massimo di μ-ops generate in un ciclo di clock è pari a 6: quattro vengono allocate alla prima istruzione IA-32, le altre due in ognuna delle restanti due istruzioni IA-32 (come detto la P6 è un superscalare a 3 vie: in un ciclo si leggono e decodificano fino a tre istruzioni IA-32). La pipeline della P6 è di tipo speculativo e utilizza la ridenominazione dei registri e il ROB. In un ciclo si possono ridenominare e inviare alle reservation stations fino a 4 μ-ops, mentre si possono portare a commit fino a 3 μ-ops per ciclo (le eventuali altre μ-ops che sono finite, prima di essere portate a commit vengono accodate; questo può sembrare un “collo di bottiglia”, ma in realtà è una eventualità molto remota). La pipeline è di 14 stadi: • 8 stadi sono per la lettura in ordine, la decodifica e il dispatch. La prossima istruzione viene selezionata durante lo stadio IF usando un predittore di salti di 512 elementi, a 2 livelli. Decodifica e issue includono ridenominazione dei registri (40 registri virtuali) e dispatch a una delle 20 reservation stations e a una delle 40 posizioni del ROB. La dispatch unit invia le μ-ops 41
fuori ordine prelevandole dal ROB e inviandole alle unità funzionali attraverso le RS, quando gli operandi sono tutti pronti e la risorsa richiesta è disponibile. Il throughput massimo che si può ottenere è 5 μ-ops/ciclo (nonostante si possano inviare solo 4 μ-ops a ciclo, possono essere portate alle unità funzionali – che sono, appunto, 5 – cinque μ-ops; non è difficile pensare che oltre alle istruzioni lanciate nel presente ciclo di clock, nel ROB possano essere presenti istruzioni lanciate in precedenza ma non ancora avviate all’esecuzione). Se la μ-op è un salto condizionale, allora si confronta l’esecuzione con l’indirizzo predetto nella fase di fetch. Se la predizione era errata, l’unità funzionale di salto cambia lo stato di tutte le μ-ops seguenti e le rimuove dal ROB. • 3 stadi usati per l’esecuzione fuori ordine in una delle unità funzionali appartenenti a 5 tipi: virgola fissa, virgola mobile, salti, indirizzamento della memoria e accesso alla memoria. La latenza di esecuzione varia da un ciclo per operazioni ALU semplici, a 32 per la divisione tra floating point. • 3 stadi vengono usati per il commit. Veniamo alle prestazioni di questa architettura. Mediamente il 20% dei salti condizionati non vanno a buon fine e usano la regola statica di predizione dei salti (salti in avanti: “non fatti”; salti indietro: “fatti”). A causa della speculazione, in media si lanciano 1.2 μ-ops in più di quante ne giungano a commit. Complessivamente il CPI che si può ottenere è pari a 1.15. L’architettura Pentium IV. La microarchitettura del Pentium IV prende il nome di NetBurst. Rispetto alla P6 raggiunge frequenze di clock molto più elevate e mantiene il throughput di esecuzione sempre prossimo al livello massimo. Le caratteristiche principali di questa architettura sono: • hyper pipeline: 20 stadi alcuni dei quali hanno come unica funzione quella di trasferire i dati all’interno del chip. • Frequenza di lavoro tra 1.4 e 2.0 GHz. • 3 meccanismi di prefetching: prefetch hw delle istruzioni (basato sul Branch Target Buffer), prefetching controllato dal sw sulla D-cache e meccanismo hw di prefetching di istruzioni e dati da cache di livello 3 e 2. • Uso di ridenominazione dei registri: fino a 128 possibili risultati in attesa di finalizzazione (nella P6 erano 40). • Sette unità di esecuzione su interi (contro i 5 della P6). • Disegni aggressivi di ALU e D-cache. • BTB otto volte più grande rispetto alla P6 e migliore algoritmo di predizione. L’innovazione più importante che caratterizza questa architettura è la Trace Cache. Il concetto base è il seguente. Nelle “normali” I-cache i blocchi contengono le sequenze di istruzioni così come sono state tradotte dal compilatore (sono quindi statiche, nel senso che nella memoria queste istruzioni resteranno sempre nello stesso ordine). Con la trace cache si vuole invece memorizzare un blocco di istruzioni nell’ordine in cui è stato eseguito. In pratica è come se si registrasse una parte del flusso di esecuzione a run time creando una traccia che viene memorizzata nella trace cache. Questa traccia include i salti che a run time sono stati eseguiti. Quindi, possiamo vedere una traccia come un blocco di codice lineare, che però è dinamico. La predizione dei salti fa parte della trace cache e di fatto viene utilizzata per costruire le tracce dinamiche da memorizzare. La gestione della trace cache è davvero molto complicata e non entreremo perciò nei particolari del suo controllore. La traccia dinamica già decodificata può essere quindi riutilizzata successivamente evitando così di dover ripetere le operazioni di lettura e decodifica. Quando si preleva una traccia dalla cache bisogna prestare molta attenzione agli indirizzi delle istruzioni poiché, ad esempio, più tracce diverse possono condividere più istruzioni comuni ma fare riferimento a salti condizionati diversi. I processori VLIW. 42
La filosofia VLIW (Very Long Instruction Word) è per così dire “opposta” alla filosofia delle CPU superscalari. In quest’ultime, infatti, l’estrazione del parallelismo viene fatta dalla CPU stessa, che come visto risulta molto complessa. L’idea allora è quella di costruire CPU molto più semplici e lasciare al compilatore il compito di estrarre il parallelismo dal programma. Avere una CPU più semplice comporta molti vantaggi, tra i quali una minore occupazione di area di silicio, che può quindi essere riutilizzata per ampliare le cache. Un ruolo fondamentale è svolto dal compilatore il quale legge il codice e aggrega delle istruzioni indipendenti, che possono cioè essere eseguite in parallelo, e le consegna alla CPU, la quale dopo la decodifica le esegue. Il compilatore ha un buon vantaggio: la finestra di programma vista è molto più ampia rispetto a quella che si può trovare su una CPU superscalare, e questo permette di analizzare meglio le istruzioni e le relative dipendenze. D’altro canto però il compilatore esegue queste operazioni staticamente, ignorando cioè tutte le informazioni che possono venire a run time. Talvolta esso possiede informazioni statistiche fornitegli durante la compilazione e questo può portare ad un leggero miglioramento dell’ottimizzazione del codice. L’istruzione “lunga” di una VLIW può arrivare fino a 128 bit e viene chiamata bundle. In ogni bundle vengono impaccate più operazioni, dette sillabe, ognuna delle quali sarà eseguita in parallelo dalla CPU. I bundle sono eseguiti rigorosamente in ordine sequenziale. Ad ogni sillaba del bundle viene associato un “percorso” all’interno della CPU che viene chiamato lane. Ogni lane avrà il compito di eseguire la sillaba che gli è stata assegnata. Tutte le sillabe possono quindi accedere al register file contemporaneamente. Se, ad esempio, il bundle contiene 4 sillabe, dato che ogni sillaba ha due registri sorgente e un registro destinazione, il register file dovrà prevedere 8 porte di lettura e 4 porte di scrittura. Il processore può avere anche più unità funzionali che lane, e questo permette di avere più flessibilità nella costruzione del bundle. Il compilatore ha dunque il compito di identificare quattro istruzioni indipendenti che possano essere eseguite in parallelo ma si preoccupa anche di risolvere i conflitti relativi alle risorse, cioè verifica che le sillabe selezionate oltre ad essere indipendenti siano anche eseguibili contemporaneamente sulla base delle risorse a disposizione. Nello schema di VLIW più semplice, ogni bundle può contenere una sola istruzione di controllo (un solo salto condizionato/incondizionato, chiamate a procedura, …), ma esistono applicazioni più avanzate in cui la CPU è organizzata in cluster ognuno corrispondente ad un parallelismo limitato che fa riferimento ad un proprio banco di registri. Vediamo un semplice esempio di architettura VLIW, con parallelismo 4, un solo cluster e 5 unità funzionali: FXU1 (operazioni aritmetiche su interi), FXU2BR (operazioni aritmetiche su interi e salti condizionati), FLU1 e FLU2 (unità aritmetica in virgola mobile), LS (unità di load e store per l’accesso alla memoria dati). Supponiamo ora che le operazioni ALU su interi abbiano latenza pari a 1, la moltiplicazione su interi abbia latenza pari a 3, la load abbia latenza pari a 2, la store e i salti abbiano latenza pari a 1 e le operazioni in virgola mobile abbiano latenza pari a 3. Con queste ipotesi analizziamo il seguente segmento di codice: i1: add r1,r2,r3 i2: sub r4,r2,r5 i3: mul r6,r2,r3 i4: and r1,r4,r1 i5: or r2,r4,r3 i6: add r7,r1,r6 Innanzitutto, analizzando le dipendenze tra le sei istruzioni, possiamo definire i seguenti bundle: bundle A = i1 | i2 | i3 | nop bundle B = i4 | i5 | nop | nop bundle C = i6 | nop | nop | nop In linea puramente teorica, le singole istruzioni su una lane possono essere portate a termine (con la fase di write back) non appena l’operazione che deve essere svolta fornisce il risultato. Questo però, viste le diverse latenze delle unità funzionali, può portare a problemi di utilizzo delle risorse. 43
Supponiamo ad esempio che, come nel nostro esempio, le risorse siano superiori alle lane. Può accadere che, date le diverse latenze, ci siano in esecuzione più di 4 istruzioni (appartenenti a bundle diversi) e che accidentalmente tutte vogliano scrivere nello stesso istante: evidentemente è un bel problema visto che il register file mette a disposizione solo quattro porte di scrittura. Nella pratica si impone quindi che le istruzioni di uno stesso bundle scrivano tutte nello stesso ciclo di clock, rallentando eventualmente le istruzioni con latenza minore. In questo modo il numero massimo di scritture che possono avvenire contemporaneamente è proprio 4. Tornando al nostro esempio, se potessimo usare un meccanismo di esecuzione che non impone il write back sincrono, potremmo eseguire i bundle nel modo seguente: bundle A, bundle B, nop | nop | nop | nop, bundle C. Inserire una nop equivale ad allungare di un ciclo la latenza delle istruzioni ed in pratica serve a ritardare di un ciclo l’inizio dell’istruzione i6 la quale ha bisogno del risultato fornito da i3 (r6) per poter eseguire l’operazione. Nella pratica però dovremo adottare il write back sincrono per evitare i problemi visti, per cui l’esecuzione sarà la seguente: bundle A, nop | nop | nop | nop, nop | nop | nop | nop, bundle B, bundle C. I due bundle di nop servono a ritardare di due cicli il write back di i1 e i2 per renderlo sincrono al write back di i3. Dobbiamo inserire esattamente due nop perché due è la differenza di latenza che c’è tra l’esecuzione di una moltiplicazione e l’esecuzione di una operazione ALU su interi. Già da questo semplice esempio emerge un ulteriore problema: se il parallelismo non è elevato, in memoria vengono memorizzate moltissime nop. Per evitare un enorme spreco, i bundle vengono impaccati, cioè si scrivono solo le sillabe significative (quelle che effettivamente contengono una istruzione) delimitate da opportuni separatori. Ad esempio, il nostro segmento di codice sarebbe memorizzato in questo modo: i1 | i2 | i3 || i4 | i5 || i6 || dove il simbolo | indica che la prossima sillaba fa parte dello stesso bundle, mentre il simbolo || indica che la prossima sillaba fa parte di un altro bundle. In questo modo, se prima di aver letto 4 sillabe si incontra il separatore || vengono automaticamente inserite tante nop quante ne sono necessarie a raggiungere 4 sillabe. In definitiva, l’architettura VLIW è più semplice di una CPU superscalare a pari parallelismo, e questo determina un ciclo di clock più breve. D’altro canto però il compilatore deve essere molto più sofisticato e oltre a conoscere l’architettura sul quale il codice andrà eseguito deve conoscere specifici parametri come la latenza delle unità funzionali, il ritardo load-use, … Tutto questo riduce la portabilità del codice del programma (anche fra successive generazioni della stessa famiglia). Esistono poi dei problemi più “sottili”, come quello dei data cache miss. Un miss nella D-cache, se non pone in stallo la CPU, “scombussola” l’esecuzione parallela dei bundle. Il compilatore deve prevedere allora l’eventualità dei cache miss inserendo istruzioni “di protezione”. Ma poiché i cache miss non possono essere predetti, ci si dovrebbe porre nel caso peggiore, e cioè prevedere che ad ogni load ci possa essere un cache miss e prendere le adeguate misure. È evidente che ciò non è vantaggioso, per questo ci sono soluzioni più complesse. Ad esempio, per la cache istruzioni ci sono complesse strutture di gestione e si fa uso di un prefetch buffer. Per la cache dati, invece, si può far uso delle load speculative, ma questo accresce di molto la complessità della CPU andando un po’ in contrasto con la filosofia VLIW. Infine, c’è da considerare il problema dei salti. Per rendere ancora più performante l’esecuzione si può prevedere l’esecuzione speculativa dei salti, fornendo però la possibilità di annullare l’esecuzione speculata in caso di errore. La speculazione è anche in questo caso statica e perciò demandata al compilatore. L’esecuzione predicata. Al termine del paragrafo precedente abbiamo accennato alla gestione dei salti in una macchina VLIW. Esiste un’altra soluzione molto interessante che prende il nome di esecuzione predicata. L’idea è quella di trasformare le dipendenze di controllo in dipendenze di dati, dando al compilatore una alternativa ai salti condizionati. L’esecuzione predicata (detta anche esecuzione con guardia o condizionale) implica una forma di esecuzione condizionale basata su una guardia booleana.
44
Abbiamo, anche in questo caso, due soluzioni: l’esecuzione completamente predicata e l’esecuzione parzialmente predicata. Vediamo la prima. A tutte le istruzioni si aggiunge un operando booleano: si introducono perciò delle istruzioni che definiscono un predicato e un register file separato per i predicati. La notazione “($p) operazione” indica (in equivalente linguaggio C): “if(p = = true) operazione”. Consideriamo quindi questo segmento di codice: if (x > 0) c = a + b; Su una VLIW completamente predicata, ponendo x in R5, a in R1, b in R2 e c in R3, il codice diventa: cmpgt Sp1 = R5, 0 ;; ($p1) add R4 = R1, R2 Le operazioni vengono quindi eseguite condizionatamente, ovvero, solo se il predicato è vero. Le istruzioni che generano un predicato, generano sia quello vero che quello falso, e in questo modo si possono gestire operazioni di confronto molto complesse (come gli if annidati). Vediamo un esempio: if (a[i].ptr != 0) then b[i]=a[i].k; else b[i]=a[i].r; i=i+1; L’istruzione di confronto genera due predicati e di fatto il codice viene diviso in quattro blocchi lineari: load r7 = a[i].ptr p1,p2 = r7 != 0 ($p1) load r8 = a[i].k ($p1) store b[i] = r8 ($p2) load r9 = a[i].r ($p2) store b[i] = r9 i = i+1 se il confronto da un risultato vero, allora p1 viene posto a 1, mentre se il risultato è falso è p2 ad essere posto ad 1. Quindi, p1 “predica” il percorso “then”, mentre p2 “predica” il percorso “else”. In questo modo abbiamo eliminato le penalizzazioni dovute a errate predizioni dei salti. Il prezzo da pagare è l’inserimento di istruzioni “utili” e “inutili” nello scheduling. Il bilancio risulta positivo se le risorse hw non sono troppo limitate, e il tempo di esecuzione deve essere breve. Vediamo ora l’esecuzione parzialmente predicata. Il principio è simile, ma in questo caso si eseguono incondizionatamente entrambi i percorsi generati da una “diramazione”, alla fine si selezionano poi tra i risultati generati quelli corretti e coerenti con la vera destinazione del salto. Riconsideriamo il semplice esempio: if (x > 0) c = a + b; Ponendo x in R5, a in R1, b in R2 e c in R3, abbiamo: cmpgt $b1 = R5, 0 add R4 = R1, R2 ;; slct R3 = $b1, R4, R3 La add viene sempre eseguita (ed è quindi speculativa). Se $b1 risulta essere vero, per R3 selezioniamo (select = slct) il valore già calcolato e messo in R4, altrimenti lasciamo il valore precedente. 45
L’esecuzione predicata, concludendo, migliora il codice nel caso di salti difficili da predire e crea blocchi basici (o lineari) più grandi estendendo così la possibilità di ottimizzazione. Inoltre la predicazione può essere combinata con la speculazione, anzi, la sola predicazione da risultati peggiori di una buona speculazione. Bisogna però tenere conto anche del fatto che il codice aumenta in modo considerevole e quindi i sistemi embedded o i DSP possono fare un uso limitato di questa soluzione; si ricordi che per la predicazione è anche necessario aggiungere un nuovo register file. Itanium, DSP e previsioni future. Itanium. La prima CPU Intel a 64 bit è Itanium ed è basata sul concetto EPIC (Explicit Parallel Instruction Computing). Si può pensare che l’Itanium sia un VLIW, ma molto più complesso. Il compilatore, infatti, ottimizza il codice a livello globale, ma la CPU prevede anche un supporto hw per le ottimizzazioni dinamiche a run time in modo da mantenere sempre alto il throughput. L’architettura ha pipeline con larghezza 6 (ovvero, il grado di superscalarità è 6) e profondità pari a 10 e dispone delle seguenti unità funzionali: 4 unità su interi, 4 unità su interi multimediali, 2 unità di load/store, 3 unità di salto, 2 unità floating point in precisione estesa e 2 unità floating point in precisione singola. Il bundle elementare è costituito da tre operazioni più una sillaba di template (che fornisce le informazioni per la decompressione delle istruzioni); il parallelismo è pari a 6, quindi due bundle costituiscono una istruzione lunga completa. Nell’Itanium non ci sono strutture hw come le reservation stations o il ROB: l’hw per la speculazione è semplice; le strutture di register renaming sono sostituite da soluzioni register remapping; le dipendenze sono identificate dal compilatore. DSP. I sistemi embedded sono sistemi di elaborazione progettati e realizzati per specifiche applicazioni. L’esempio più importante di processori per sistemi embedded sono i DSP (Digital Signal Processing). Rispetto ad una CPU general purpose, i DSP hanno fondamentali modifiche sull’insieme di istruzioni e sui modi di indirizzamento. I DSP sono usati per elaborare segnali digitali, quindi raramente si usano operazioni in virgola mobile; di contro però le operazioni coinvolgono accumulatori molto grandi. Spesso è necessario utilizzare una aritmetica saturante: se il risultato è troppo grande per essere rappresentato, lo si pone al massimo numero rappresentabile con il numero di bit a disposizione. I dati trattati dai DSP sono spesso flussi di dati continui, e questo costringe ad introdurre, per i dati, dei buffer circolari gestiti con i puntatori (il principio di funzionamento è quello del ROB: si ha un puntatore di testa e uno di coda). Come detto, anche l’insieme di istruzioni può essere diverso; una istruzione tra le più usate è la MAC (Multiply and ACumulate) utilizzata per moltiplicare due numeri e sommare il risultato ai risultati precedenti (in pratica implementa l’operazione ∑ ai ⋅ bi ). Esistono anche cicli a zero overhead: una particolare i
istruzione permette di ripetere una sequenza di istruzioni per un certo numero di volte, leggendola e decodificandola però una sola volta. Un nodo chiave dei sistemi embedded, soprattutto quelli portatili, è il consumo di potenza. Lo scopo principale è quello di raggiungere alte prestazioni a livello tecnologico, architetturale, di programma e di sistema, con consumo di potenza limita. Tendenze. Si prevede che prima del 2008 si costruiranno chip con un miliardo di transistori. Cosa si metterà su questi chip? Le filosofie attuali tendono ad incrementare la memoria cache on chip, a rendere le architetture sempre più complesse inserendo anche più processori sullo stesso chip (network on chip). Alla prima generazione di CPU completamente seriale, sono seguite le CPU di seconda generazione che hanno introdotto la pipeline; in seguito, le CPU di terza generazione, sono diventate superscalari ed oggi, abbiamo CPU con pipeline “profondissime” e superscalari “larghi”. 46
Ad ogni passaggio la percentuale dei transistor essenziali per eseguire una istruzione diventa una frazione sempre più piccola del totale: occorre molto hw per garantire che le unità parallele siano mantenute occupate. Inoltre, ad ogni transizione il miglioramento delle prestazioni non è uniforme per tutte le applicazioni: alcune sfruttano l’hw meglio di altre. Ad oggi migliorare ancora le architetture, e quindi le prestazioni, comporta investimenti sempre maggiori con risultati appena di poco migliori dei precedenti. Per poter estrarre maggior parallelismo da un programma per aumentare prestazioni e potenza di calcolo si può pensare, oggi, di passare a molti processori semplici su un solo chip, oppure, ai sistemi multiprocessore.
I sistemi multiprocessore. Oltre l’ILP. Come visto, le architetture superscalari o VLIW supportano quello che abbiamo chiamato parallelismo a grana fine, cioè il parallelismo intrinseco (ILP) nel codice del programma da eseguire. Al giorno d’oggi, estrarre maggior parallelismo a livello ILP costa molto in investimenti architetturali e i benefici che porta spesso non ripagano dei costi. Si può dire quindi che si è “spremuto” quasi tutto quello che c’era. È per questo motivo che ci si dirige ora verso un parallelismo “a grana grossa” con i sistemi multiprocessore. Il parallelismo che si vuole sfruttare ora non è più l’ILP ma il parallelismo a livello di processi o thread (in realtà il parallelismo ILP è ancora sfruttato poiché i sistemi multiprocessore vengono spesso realizzati con CPU commerciali, che possono essere superscalari o VLIW). Quello che si vuole ottenere è quindi un sistema in cui ogni processore esegue un processo (o più processi) in parallelo ed indipendentemente dagli altri processori. La principale difficoltà sta nell’avere il software adatto: si deve fare in modo che le operazioni svolte dal programma possano essere suddivise in processi “separabili” ed eseguibili indipendentemente gli uni dagli altri. È ovvio che ciò non è completamente possibile, per cui si prevede la possibilità che i vari processi comunichino tra di loro attraverso una rete di comunicazione alla quale tutti i processori del sistema sono collegati. La tassonomia (cioè la classificazione) tradizionale dei sistemi di elaborazione prevede quattro categorie, suddivise sulla base della natura del flusso di esecuzione e del flusso di dati: • SISD (Single Instruction stream Single Data stream): è il classico processore tradizionale. Abbiamo un unico flusso di istruzioni e un unico flusso di dati (si pensi alla macchina di Von Neumann). • SIMD (Single Instruction stream Multiple Data streams): la stessa istruzione viene eseguita da più processori che usano diversi flussi di dati. Ogni processore ha una propria memoria dati e il sistema ha un’unica memoria istruzioni con un unico processore di controllo. In modo limitato, l’elaborazione multimediale presenta una struttura SIMD (si pensi ad unico filtro da applicare ad una immagine; l’immagine può essere suddivisa in più “spezzoni” e ad ognuno di questi si deve applicare la stessa elaborazione). Questi sistemi non sono progettati per applicazioni general purpose, ma per applicazioni specifiche. • MISD (Multiple Instruction streams Single Data stream): più unità funzionali operano, con istruzioni diverse, su un unico flusso di dati. Di fatto non è mai stata costruita una architettura di questo tipo che può essere usata solo per scopi particolarissimi. • MIMD (Multiple Instruction streams Multiple Data streams): ogni processore legge le proprie istruzioni e opera sui propri dati. Anche se negli anni ’80 si è prestata molta attenzione alle architetture SIMD (la Connection Machine era una macchina con 4096 processori seriali), al giorno d’oggi l’architettura MIMD è la prescelta per l’implementazione di sistemi multiprocessore, per i seguenti motivi: • Le macchine MIMD sono flessibili: possono funzionare come macchine ad utente singolo con alte prestazioni su una certa applicazione, come multiprocessore multiprogrammato, che esegue
47
cioè più compiti simultaneamente, o una combinazione di queste due soluzioni. Il tutto è riconfigurabile dinamicamente a seconda delle esigenze. • Le architetture MIMD si possono costruire usando processori standard. Poiché il numero di processori può essere molto elevato, le probabilità di guasti aumentano. Usare processori standard assicura che questa probabilità sia comunque contenuta vista l’affidabilità dei processori commerciali odierni. Ogni processore esegue il proprio flusso di istruzioni sui propri dati, ma questi processi possono condividere parte delle istruzioni e parte dei dati e, per questo, spesso vengono chiamati thread. Se una architettura ha n processori servono almeno n processi/thread da eseguire. Ciò che è necessario ricordare è che questi thread o processi vengono identificati dal software (o dal programmatore) e non dall’hw al momento dell’esecuzione come facevano le CPU superscalari con l’ILP. Vedremo nel seguito le due principali classi di sistemi multiprocessore: le architetture a memoria condivisa e le architetture a memoria distribuita. Architetture a memoria condivisa. Le architetture centralizzate a memoria condivisa sono dotate al più di una dozzina di processori. Questi processori condividono un’unica memoria centralizzata e sono collegati con la memoria e tra di loro da un bus. Questo è il principale motivo per cui il numero di processori è piuttosto basso: il bus e la memoria possono soddisfare poche richieste contemporaneamente (al limite una) e quindi se n processori devono accedere a memoria, n-1 di questi devono attendere. Sostituendo il bus con più bus o con una rete di comunicazione il numero di processori può salire fino a qualche dozzina (oltre questo limite la memoria centralizzata non è più una buona soluzione). In questi sistemi, la memoria primaria ha una relazione di simmetria con ogni processore: tutti impiegano lo stesso tempo ad accedere ad una cella di memoria. Per questo motivo, queste architetture prendono anche il nome di multiprocessori simmetrici (a memoria condivisa) SMP (o anche architetture con accesso a memoria uniforme UMA). Un miglioramento a questa architettura può essere apportato introducendo delle cache, una per ogni processore. In questo modo, se un dato si trova in cache, non c’è bisogno di accedere al bus (diminuiscono i conflitti di accesso) e alla memoria. L’introduzione di cache, come vedremo, porta al problema della cache coherence, ma ci permette ora di introdurre il concetto di dato privato e dato condiviso. Un dato è privato quando è usato da un solo processore, mentre è condiviso se lo stesso dato è usato da più processori. Un dato privato viene portato in cache e li rimane, utilizzato solo dal processore al quale è associata la cache. Nessun altro richiederà mai quel dato; di fatto è come se l’architettura fosse a singolo processore. I dati condivisi sono anch’essi residenti in cache, ma possono essere replicati in più cache. Questo permette di ridurre il numero di accessi al bus e alla memoria introducendo però l’importante problema della cache coherence (se un processo modifica un dato condiviso, tutti gli altri lo devono sapere). La memoria condivisa non è necessariamente centralizzata ma può essere anche fisicamente distribuita, anche se continua ad essere organizzata in un unico spazio di indirizzamento. Queste soluzioni prendono il nome di architetture a memoria condivisa distribuita (DSM). Memoria condivisa, quindi, non implica necessariamente un’unica memoria (fisicamente) centralizzata. Una categoria di macchine a memoria condivisa distribuita sono le NUMA, cioè macchine ad accesso non uniforme. La memoria centrale è fisicamente distribuita sui nodi, e questo determina la perdita di simmetria (o uniformità). Infatti, se un processore deve accedere allo spazio di memoria presente nel suo nodo, i tempi di accesso saranno brevi, mentre se la memoria a cui accedere è remota i tempi si allungheranno anche in relazione allo stato della rete di interconnessione. Sono possibili moltissime soluzioni NUMA; ad esempio, è possibile creare dei cluster di processori in cui esiste una memoria condivisa locale e un bus locale; tutti questi cluster sono poi collegati tramite una rete globale di interconnessione che vede anche una memoria globale condivisa. Altra soluzione è la cosiddetta CC-NUMA (Cache Coherent NUMA). Ogni nodo “possiede” una parte di memoria condivisa e una grande cache. In questo modo si cerca di ovviare al problema dell’accesso non uniforme. In questi sistemi la distribuzione iniziale dei dati è un problema non banale e viene fatto 48
in modo statico, ovvero, vengono distribuiti i processi e i dati ai singoli nodi. A run time poi, i protocolli di cache coherence cercheranno di realizzare un load balancing dinamico, ovvero di ripartire uniformemente il carico di lavoro tra i vari nodi. I principali vantaggi dei sistemi a memoria condivisa (non distribuita) sono: • Non si richiede partizionamento del codice o dei dati: non occorrono nuovi linguaggi o compilatori). • Non occorre spostare i dati quando i processi comunicano: la comunicazione fra processi può essere molto efficiente. Esistono ovviamente anche dei problemi nella realizzazione di queste architetture: • Occorrono costrutti di sincronizzazione per accedere alle strutture dati: è necessario garantire che dati condivisi siano sempre aggiornati per tutti i processi che li utilizzano. Se questo non accade si ottengono risultati errati ed errori difficili da trovare. • I problemi di contention portano a mancanza di scalabilità: come detto, se n processori devono accedere ad una stessa locazione di memoria, n-1 di questi devono attendere. Questo implica che non si possa andare oltre un certo numero di processori (quindi manca la scalabilità). Per ridurre questo problema si può introdurre una rete di interconnessione ad elevato throughput e delle cache locali per ridurre il numero di accessi alla memoria. Architetture a memoria distribuita. Questa classe di architetture, detta anche multicomputers, prevede più coppie processore-memoria (detti anche nodi) tra loro collegati mediante una rete di comunicazione. La memoria è fisicamente distribuita, ed anche lo spazio di indirizzamento è separato. In questo modo, si ovvia ad uno dei principali problemi delle architetture a memoria condivisa: la scalabilità. Ogni nodo accede alla propria parte di memoria, e questo significa che le informazioni che devono essere condivise, vanno replicate in tutte le memorie che lo richiedono. I nodi interagiscono tra loro scambiandosi dei messaggi, da qui la classificazione di questa categoria con il nome “architetture a scambio di messaggi”. Il numero di messaggi che i nodi si scambiano può essere molto grande, quindi la banda della rete di interconnessione è un punto critico fondamentale. Come detto la memoria ha un indirizzamento separato quindi, per accedere ad un dato remoto, si devono scambiare messaggi che possono essere visti in “due sensi”: dal punto di vista del lettore di dati (chiamata a procedura remota, …) oppure da quello dello scrittore di dati (se il processo che produce i dati sa quando altri nodi ne avranno bisogno). All’avvio, ogni processo viene allocato, con i suoi dati, nella memoria locale di un nodo. Se un processo ha bisogno di accedere ad una posizione di memoria remota invia un messaggio per la richiesta dei dati e nel frattempo ha due possibilità: • Viene sospeso e il processore viene allocato ad un altro processo. Questa soluzione richiede un meccanismo di context switching molto efficiente ed, inoltre, tanti processi da eseguire. • Continuare ad eseguire il codice fino a che non ha necessità dei dati richiesti (e allora va in uno stato di busy-wait). Questa soluzione richiede un meccanismo hw/sw efficiente per monitorare (polling) le comunicazioni in attesa. I principali vantaggi dei multicomuters sono: • Il problema della contention non è così “pesante” come nei sistemi a memoria condivisa. Questo permette di avere una buona scalabilità e di realizzare sistemi di calcolo massicciamente paralleli. • Le strutture dati non sono condivise. Questo permette di avere tecniche di sincronizzazione semplici (supportate da scambi di messaggi). Il principale svantaggio di queste soluzioni è che comunicare i dati tra i diversi processori diventa più complesso con il sistema a messaggi. Inoltre, comunicazioni frequenti possono sovraccaricare la rete. Quindi i principali problemi da affrontare sono: • Load balancing: distribuzione del lavoro ai vari processori. È un punto molto critico e spesso è lasciato all’utente. 49
• Deadlock: un possibile risultato della comunicazione e sincronizzazione mediante scambio di messaggi. Il protocollo di comunicazione risolve il problema a livello architetturale, l’utente deve risolverlo a livello sw. • Copia delle strutture dati: intense e frequenti copiature possono ridurre le prestazioni. Le reti di interconnessione. Le reti che interconnettono i processori di un sistema multiprocessore sono sostanzialmente suddivise in due categorie: • Statiche: collegamento fisso di unità di commutazione (tipicamente punto-punto). Sono anche dette reti dirette. Queste reti sono in genere utilizzate dai sistemi a memoria distribuita in cui i messaggi possono essere lunghi e “importanti”. Si presta quindi particolare attenzione ai protocolli di comunicazione. • Dinamiche: unità di commutazione attive possono essere posizionate per riconfigurare i tratti di interconnessione. Queste reti sono solitamente usate dai sistemi a memoria condivisa che si scambiano spesso dei brevi messaggi. Occorre evitare la contention e fare attenzione ai punti caldi (hot spots). La cache coherence nei sistemi multiprocessore. Il problema della cache coherence nasce sui sistemi a memoria condivisa (quelli cioè che hanno un unico spazio di indirizzamento) nel momento in cui si introducono, su ogni nodo, delle cache per ridurre i problemi di contention. Questi sistemi possono anche essere a memoria (condivisa) distribuita. Per capire meglio il problema della cache coherence supponiamo di essere su un sistema a memoria condivisa, dotato di due soli processori (A e B) ognuno dei quali ha la propria cache. Supponiamo che inizialmente nella memoria condivisa alla cella X sia contenuto il valore 1. Ad un certo istante il processore A richiede questo valore e copia la cella X all’interno della propria cache. Successivamente, anche B richiede lo stesso valore alla memoria condivisa, per cui copia il valore 1 nella propria cache. A questo punto le cache e la memoria condivisa sono tutte coerenti. Ora però, il processo che gira su A modifica il valore di X scrivendo 0 nella corrispondente cella della propria cache. Supponendo di avere una tecnica derivata dal write through, questo valore (lo 0) viene portato anche nella memoria condivisa. Senza ulteriori operazioni, la cache di B continuerà a contenere il valore di X pari a 1, ovvero le cache e la memoria condivisa non sono coerenti. Sulla base di questo semplice esempio, possiamo dare la definizione di memoria coerente. Un sistema di memoria è coerente se: • Una lettura da parte di un processore P in una posizione X che segue una scrittura da parte di P in X restituisce sempre il valore scritto da P, se nel frattempo non sono avvenute altre scritture in X da parte di altri processori. Questa proprietà garantisce il mantenimento dell’ordine di programma. • Una lettura da parte di un processore P in una posizione X che segue una scrittura in X da parte di un processore Q restituisce la scrittura di Q se lettura e scrittura sono separate da un tempo sufficiente e se non si verificano altre scritture in X tra i due accessi. Questa proprietà garantisce una vista coerente della memoria. • Le scritture nella stessa posizione sono serializzate, ovvero, se prima scrive il processore P e poi scrive il processore Q, l’accesso deve essere visto in questo stesso ordine da tutti i processori del sistema. Questa proprietà garantisce che ogni processo in ogni istante veda l’ultimo aggiornamento di una posizione di memoria. Queste tre semplici “regole” definiscono il modello di coerenza del sistema. Esiste un altro problema: quando (ovvero, a che istante) un processo “lettore” vedrà come valido un valore scritto da un processo “scrittore”. Questo problema è definito dal modello di consistenza della memoria. La differenza può essere sottile, ma notevole. La coerenza definisce il comportamento a fronte di 50
letture e scritture nella stessa cella di memoria. La consistenza si occupa invece di letture e scritture a diverse posizioni di memoria. I due aspetti sono quindi complementari. Affinché su un sistema multiprocessore vada tutto bene, siamo quindi costretti a forzare la coerenza. In un multiprocessore coerente le cache forniscono sia la migrazione che la replicazione degli elementi (dati) condivisi. La migrazione permette di spostare dei dati da una cache all’altra (cioè nella cache di partenza, dopo la migrazione, questi dati non saranno più presenti); in questo modo si ha una riduzione della latenza per l’accesso a dati condivisi allocati remotamente e delle richiesta di banda per la memoria condivisa. La replicazione permette di copiare dei dati presenti in una cache, in altre cache di processori che ne hanno bisogno (in questo modo i dati si trovano in più cache); ciò riduce la latenza degli accessi e la contention per un dato condiviso in lettura. Si può intuire che mantenere la coerenza, e cioè supportare la migrazione e la replicazione è molto complesso e influisce molto sulle prestazioni del sistema. I multiprocessori di piccole dimensioni adottano spesso meccanismi hw e protocolli che hanno lo scopo di mantenere la coerenza. Questi protocolli hanno il compito fondamentale di tenere traccia dello stato di qualsiasi condivisione di un blocco di dati. Approfondiamo i problemi legati alla cache coherence: • Condivisione di dati scrivibili: se alla struttura dati D accedono processi che risiedono su due processori P1 e P2, D viene caricata nelle cache C1 e C2; se il processo P1 modifica D in D’, C1 conterrà D’, ma C2 continuerà a contenere D: le copie di D diventano inconsistenti. • Migrazione dei processi (un processo può migrare, cioè essere “spostato” da un processore ad un altro perché, ad esempio, il processore su cui gira è sovraccarico e c’è un processore “libero” che può occuparsi del processo): sia D una struttura dati privata scrivibile posseduta dal processo A che gira su P1, il quale la modifica in D’ (C1 a questo punto contiene D’, mentre la memoria primaria contiene ancora D). Se A migra da P1 a P2 e poi legge D dalla memoria primaria, A sul processore P2 possederà il vecchio valore di D. • Attività di I/O: se il processore di I/O lavora direttamente sulla memoria primaria non vede il valore D’ modificato nelle cache locali. Sulla base di questi tre problemi possiamo individuare tre classi di strutture dati: le strutture a sola lettura (non provocano problemi di coerenza), le struttura dati scrivibili condivise (sono la principale sorgente di problemi di coerenza) e le strutture dati scrivibili private (creano problemi solo nel caso di migrazione dei processi). Ora che abbiamo chiarito tutti i problemi e gli aspetti della cache coherence, possiamo analizzare i protocolli di cache coherence. Un prima classificazione di questi protocolli va fatta tra protocolli hw e sw. Noi ci occuperemo prevalentemente dei protocolli hw i quali sono divisi in due grandi categorie: i protocolli a directory e i protocolli snoopy. A loro volta i protocolli a directory sono divisi in due classi: i protocolli a directory centralizzati o distribuiti e i protocolli a directory full map, limitati o concatenati. Al di là del protocollo possiamo avere infine due meccanismi per mantenere la coerenza: la politica di invalidazione e la politica di aggiornamento. Cominciamo illustrando appunto queste due politiche. Le politiche write-invalidate e write-update hanno lo scopo fondamentale di mantenere i vincoli di coerenza. La prima (write-invalidate) garantisce che il processo abbia accesso esclusivo ad un dato prima che il processo stesso inizi a scrivere. In pratica, quando un processo P deve accedere ad un dato in scrittura, si invia un segnale (su un bus dedicato) a tutti i processori con l’indicazione che il dato X (al quale P vuole accedere in scrittura) non è più valido perché sta per essere modificato. Una volta effettuata la scrittura non esistono più in tutto il sistema altre copie valide dello stesso dato. Se qualche altro processore avrà bisogno di quel dato sarà costretto a rivolgersi alla memoria centrale (che nel frattempo dovrà essere aggiornata da P) per ottenere una copia aggiornata del dato. Si noti che l’indirizzo del dato è identico per tutti poiché abbiamo supposto fin dall’inizio di essere su un sistema a memoria condivisa distribuita. L’invalidazione del dato è semplice: il bit di validità della cache relativo al dato viene semplicemente posto a zero. Se il processo che accede in cache vuole leggere proprio quel dato e vede che il bit di validità è zero, si ha un cache miss e il dato deve 51
essere recuperato da un accesso remoto. Questo modo di agire forza anche la serializzazione delle scritture. Supponiamo infatti che P e Q debbano scrivere lo stesso dato. Ci sarà una “corsa” tra i due, e il primo che può accedere al dato invaliderà tutti gli altri. Il secondo processore (quello che ha “perso”) prima di poter scrivere dovrà ottenere il dato aggiornato e poi invalidare di nuovo lo stesso per poterci scrivere. Un modo alternativo di operare è rappresentato dalla politica write-update (detta anche writebroadcast). Quando un dato viene modificato da un processo si aggiornano automaticamente tutte le copie del dato distribuite nel sistema. Per ridurre il numero di accessi alla rete di connessione si tiene traccia della condivisione del dato: non interessa sapere chi possiede quel dato, ma solo se altri processi lo posseggono. Se nessuno possiede il dato che è stato modificato allora non viene eseguito l’aggiornamento broadcast. Volendo fare un confronto tra queste due soluzioni possiamo dire che: • Più scritture ad una stessa parola senza letture interposte richiedono più comunicazioni di scrittura nel protocollo write-update, mentre è necessaria solo una invalidazione nel protocollo write-invalidate. • Se il blocco cache contiene più parole, il protocollo write-update necessita di una comunicazione per ogni parola del blocco poiché questo in genere lavora sulle parole. Il protocollo di tipo write-invalidate invece agisce sui blocchi di cache e quindi necessita di una sola comunicazione di invalidazione. • Il ritardo fra la scrittura di una parola in una CPU e la lettura del valore scritto da parte di un’altra CPU è di solito inferiore con lo schema write-update (con il write-invalidate infatti per leggere il dato aggiornato ci si deve rivolgere alla memoria principale). Poiché la banda di bus e memoria è una risorsa piuttosto scarsa in un sistema multiprocessore, il protocollo write-invalidate (che tra i due crea meno traffico) è di solito il prescelto tra le due soluzioni. Passiamo ora al protocollo di snooping. Ogni cache che ha una copia del dato da un blocco di memoria fisica ha anche una copia del suo stato di condivisione; in questo modo l’informazione relativa alla condivisione dei dati è distribuita. Le cache sono di norma collegate tra loro da un bus di memoria condivisa; tutti i controllori delle cache fanno un monitoraggio del bus (to snoop = “ficcare il naso”) per determinare se hanno una copia dei dati che in quel momento sono richiesti sul bus. I protocolli directory-based, invece, mantengono l’informazione sullo stato di condivisione dei dati in una sola “posizione” detta directory alla quale ci si può rivolgere per ottenere informazioni sulla posizione e la natura del dato ricercato. La directory che contiene le informazioni sui dati non deve necessariamente essere centralizzata, ma può anche essere condivisa, ovvero ogni nodo possiede una parte della directory. Questa soluzione è applicata ai sistemi con memoria condivisa e distribuita per mantenere la cache coherence; ogni directory deve tenere traccia delle cache che condividono l’indirizzo di memoria fisicamente presente nella parte di memoria del nodo. In questo modo nella directory è indicato quale altro processore condivide i dati memorizzati nel nodo. Il protocollo directory-based implementa due operazioni fondamentali: • Gestire una read miss • Gestire una scrittura in un blocco di dati condiviso e clean (cioè un blocco che, fino a quell’istante, non è mai stato scritto da nessun processo). Si noti che gestire una write miss in un blocco di dati condiviso è una combinazione delle due precedenti operazioni, infatti, se devo scrivere in un blocco che non è presente in cache ho di fatto un read miss (il blocco lo devo leggere da qualche altro nodo e portarlo in cache); una volta che il nodo è nella cache devo gestire la scrittura. Per poter implementare queste due operazioni la directory deve tenere traccia dello stato di ogni blocco di cache, che può essere di tre tipi: • Blocco condiviso nella cache di uno o più processori (il valore in memoria come in tutte le cache è aggiornato). 52
• Non in cache (nessun processore ha una copia del blocco). • Esclusivo: solo un processore ha il blocco e ci scrive (la copia di memoria non è più aggiornata). E’ necessario anche sapere quali processori hanno un blocco condiviso poiché al momento di una scrittura a questi va inviato un messaggio di write-invalidate. Per fare ciò la soluzione più semplice è adottare un vettore di bit (un bit per ogni processore) da associare ad ogni blocco di memoria. Quando un blocco è condiviso, ogni bit del vettore indica quali processori hanno lo stesso blocco; inoltre così facendo si può anche vedere se un blocco è esclusivo poiché un solo bit del vettore indicherà che il blocco è posseduto da altri processori. Semplificando il problema possiamo pensare che i tentativi di scrittura di dati condivisi nella cache dello scrivente generino sempre un write miss: in questo modo il processore si blocca fino a che non si completa un accesso. Nei protocolli directory-based la rete di interconnessione è in genere una rete complessa (non un unico bus di arbitraggio come nel caso dei protocolli snoopy) e si presta perciò allo scambio di messaggi, i quali spesso richiedono delle risposte esplicite. Dobbiamo allora distinguere i nodi in base a questa classificazione: • Nodo locale: nodo sul quale ha origine una richiesta. • Home node: nodo sul quale risiedono la posizione di memoria e l’elemento di directory di un indirizzo; lo spazio di indirizzo fisico è distribuito staticamente quindi il nodo contenente memoria e directory per un dato indirizzo fisico è noto (lo si può ad esempio ricavare dall’indirizzo). L’home node può anche coincidere con il nodo locale; se ad esempio un processo deve modificare un dato presente nella propria cache non può semplicemente modificarlo come in un normale sistema “mono-processore” poiché il dato potrebbe essere condiviso. È necessario allora accedere lo stesso al directory per verificare se il dato da modificare è condiviso. • Nodo remoto: nodo che ha una copia del blocco di cache, sia questo esclusivo (cioè è l’unica copia) o condiviso. Il nodo remoto può coincidere con il nodo locale e/o con l’home node: il protocollo base non cambia i messaggi interprocessore possono essere sostituiti con messaggi intraprocessore. Ancora, per semplificare il problema supponiamo che i messaggi siano ricevuti nello stesso ordine in cui sono stati inviati ed inoltre anche le azioni di risposta vengano attuate nello stesso ordine; questo garantisce anche che le invalidazioni inviate dal processore vengano onorate immediatamente (nella realtà queste ipotesi possono essere troppo semplificatrici). Di seguito diamo un elenco dei possibili messaggi inviati fra i nodi (P è il numero del processore richiedente, A è l’indirizzo richiesto e D i dati contenuti). Tipo Read miss
Sorgent e cache locale
Destinazion e home directory
Contenut o P,A
Write miss
cache locale
home directory
P,A
Invali- home cache remota A date directory
Funzione del messaggio Messaggio inviato nel caso in cui P debba leggere il dato di indirizzo A, ma questo non si trova nella sua cache. Quando l’home directory riceve questo messaggio registra P come condivisore del dato A. Messaggio inviato nel caso in cui P debba scrivere il dato di indirizzo A, ma questo non si trova nella sua cache. L’home directory che riceve questo messaggio si preoccupa di rendere P proprietario esclusivo del dato A1. Questo messaggio viene inviato a tutti i possessori del dato A per comunicare loro che il dato che
1
Si noti che per la semplificazione che abbiamo fatto, P invia un messaggio di write miss anche se il dato di indirizzo A si trova nella cache di P. Questo ha lo scopo comunque di rendere P proprietario esclusivo di A per poterci scrivere.
53
Fetch
home cache remota A directory Fetch/ home cache remota A Invali- directory date
hanno in possesso non è più valido perché qualche altro processo ha modificato il suo valore. Questo messaggio serve per prelevare dalla memoria il dato che è stato richiesto Questo messaggio serve per prelevare dalla memoria il dato che è stato richiesto e contemporaneamente invalidarlo presso tutti gli altri condivisori.
54
Data Value Reply Data Write Back
home cache locale directory
D
cache remota
A,D
home directory
Questo messaggio serve per inviare il dato richiesto dalla cache remota (la quale lo deve o leggere o scrivere). Questo messaggio serve ad aggiornare il dato presso la memoria condivisa in modo da mantenere la coerenza.
Per capire meglio come funziona il meccanismo a messaggi vediamo qualche esempio. Supponiamo che la nostra architettura sia composta da 4 nodi, ognuno dei quali possiede un processore Pi, una cache Ci, una parte della memoria condivisa MEMi, una parte del directory DIRi e la propria unità di input/output I/Oi (con i=1,2,3,4). Poniamo che P1 esegua un processo piuttosto “attivo” dal punto di vista delle scritture e delle letture. Infatti, P1 deve scrivere in A=75 il dato D=7 e in A=214 il dato D=9. Dopodiché deve leggere il dato alla posizione A=49 e quello alla posizione A=327. La cache C1 di P1 contiene già il valore D=8 relativo all’indirizzo A=75. La suddivisione della memoria condivisa è statica, per cui il nodo 1 (quello di P1,C1,MEM1,DIR1,I/O1) “possiede” gli indirizzi da 0 a 99; il nodo 2 (quello di P2,C2,MEM2,DIR2,I/O2) “possiede” gli indirizzi da 100 a 199; il nodo 3 (quello di P3,C3,MEM3,DIR3,I/O3) “possiede” gli indirizzi da 200 a 299; il nodo 4 (quello di P4,C4,MEM4,DIR4,I/O4) “possiede” gli indirizzi da 300 a 399. Vediamo quindi cosa accade caso per caso. 1. P1 deve scrivere D=7 in A=75. Il dato A=75 è già in C1, quindi C1 si rivolge a DIR1 inviando un messaggio di write miss (si ricordi la nostra semplificazione). C1, infatti, sa che essendo 75 l’indirizzo, il directory che tiene traccia dello stato di quel dato è il directory riferito alla memoria che va da 0 a 99. DIR1 quindi è a conoscenza di chi condivide insieme a P1 il dato A=75 e invia a tutti i condivisori un messaggio di invalidate. A questo punto C1 è proprietario esclusivo di A=75 e può scriverci il dato D=7 (non c’è bisogno di caricare il dato dalla memoria perché è già presente in cache). Ora C1 può inviare un messaggio di data write back a DIR1 il quale aggiorna lo stato della memoria MEM1 alla posizione A=75, rendendo C1 e MEM1 consistenti. Tutti i condivisori di A=75, invece, conterranno un dato non aggiornato, che infatti è marcato come “non valido”. 2. P1 deve scrivere D=9 in A=214. Il blocco A=214 non è in C1, quindi C1 invia un messaggio di write miss a DIR3 (infatti è il nodo 3 a possedere gli indirizzi da 200 a 299). DIR3 invia quindi un messaggio di fetch/invalidate con il quale carica da MEM3 il dato A=214 e contemporaneamente invia a tutti i condivisori di quel dato un messaggio di invalidazione poiché sta per essere modificato. A questo punto C1 è proprietario esclusivo di A=214. DIR3 invia quindi un messaggio di data value reply a C1 fornendogli il valore di A=214. C1 riceve il dato e può ora scriverci il valore D=9. Ora per mantenere la consistenza C1 invia un messaggio di data write back a DIR3 il quale, ricevuto il nuovo valore di A=214, si preoccupa di aggiornare MEM3. 3. P1 deve leggere A=49. Il dato non è in C1, quindi C1 invia un messaggio di read miss a DIR1. DIR1 registra P1 come condivisore del dato, invia un messaggio di fetch a MEM1 per ottenere il dato dalla memoria, quindi, con un messaggio di data value reply invia il dato a C1. P1 può ora leggere il valore di A=49. 4. P1 deve leggere A=327. Il dato non si trova in C1, quindi C1 invia un messaggio di read miss a DIR4. DIR4 registra P1 come condivisore del dato, invia un messaggio di fetch a MEM4 per ottenere A=327, ed infine invia con un messaggio di data value reply il valore di A=327 a C1. Ora P1 può leggere il dato. Si noti quindi come un read miss “locale” (cioè sullo stesso nodo, punto 3) debba essere gestito esattamente come un read miss “remoto” (cioè su un altro nodo, punto 4). Questo è necessario 55
affinché P1 venga registrato come condivisore di A=49, per fare in modo che se, ad esempio, P3 deve scrivere A=49, anche a P1 venga invalidata la propria copia del dato.
56