You are on page 1of 56

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
i ⋅ l i da cui:
N
N
TCPU = ∑ (CPI i ⋅ l i ) ⋅ TCLK ∑ (CPI i ⋅ li ) N
li
i =1
CPI = i =1
= ∑ (CPI i ⋅ )
CI i =1 CI

4
e cioè, ogni singolo CPIi viene moltiplicato per la frazione delle occorrenze dell’istruzione i nel
programma.

CI
DEF. Milioni di istruzioni per secondo (MIPS): operativamente è definito come: MIPS =
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
DEF. Media aritmetica dei tempi di esecuzione (TAM): T AM = ∑ Te sec,i dove Tesec,i indica il tempo
N i =1
di esecuzione dell’i-esimo programma che costituisce il carico di lavoro.

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
il CPI sarà pari a CPI e sec + ⋅MP.
# 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 I-
cache (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 fase 3 fase 4 fase 5
load il valore immediato
store esteso (operazione
jump svolta alla fase 2) il segnale di
branch viene sommato al Read/Write viene
i dati presenti nel
registro base (già inviato alla D-
in questa fase si registro di
predisposto nel cache. I dati
svolgono diverse lettura/scrittura
latch A durante la vengono trasferiti
operazioni vengono scritti nel
fase 2); il risultato dalla cache al
parallele. Mentre Register File.
viene scritto nel registro di
l’istruzione viene registro lettura/scrittura.
decodificata (cioè indirizzamento
si interpreta della D-cache.
l’OpCode), i 10 bit
il valore nel
che seguono
registro
l’OpCode vengono il segnale di
destinazione RD
interpretati come Read/Write viene
(già predisposto nel
indirizzi di due inviato alla D-
latch B nella fase
registri, e il loro cache. I dati
2) viene trasferito non attiva
contenuto viene presenti nel registro
al registro di
trasferito nei latch di lettura scrittura
lettura/scrittura
A e B. Nel vengono scritti in
della D-cache in
frattempo, gli memoria.
vista della scrittura
ultimi 26 bit
in memoria.
vengono
lo spiazzamento
interpretati come
esteso (già
un valore
calcolato alla fase
immediato ed estesi
2) viene sommato
l’istruzione viene a 32 bit. Tutto
al contenuto del non attiva non attiva
letta dalla memoria questo prima
PC. Il risultato
e caricata ancora che si
viene
nell’Instruction sappia qual è
istr. immediatamente
Register (IR) l’istruzione da
ALU riscritto nel PC.
eseguire! Si noti
si verifica la
però che non si
condizione; se
introduce una
questa è soddisfatta
perdita di tempo,
lo spiazzamento e quindi si deve
né sono necessarie
esteso (già fare il salto,
risorse aggiuntive.
calcolato alla fase l’indirizzo non attiva
Anzi, si può
2) viene sommato calcolato alla fase 3
introdurre uno
al contenuto del PC viene posto nel PC,
speedup: se al
altrimenti si
termine della
mantiene
decodifica si
l’indirizzo attuale.
scopre, ad esempio,
che l’istruzione è di i contenuti dei
tipo J i due registri registri sorgente
nel latch A e B non (già predisposti nei
servono, ma latch A e B nella
potrebbero servire fase 2) vengono
in futuro, inoltre, il inviati all’unità il risultato
valore immediato è aritmetico-logica dell’operazione
già stato esteso a insieme ai comandi non attiva viene scritto nel
32 bit e quindi è del campo registro
pronto per l’uso. Function; se un destinazione.
operando era
immediato, questo
è già pronto (è stato
esteso durante la
fase 2).

18
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:

EXE
IF ID (ALU) 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 D- i 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 D-
del 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 I-
cache 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 Pre-
False dipendenze Ridenominazione
parallela decodifica

Issue rate Dipendenze non risolte


Lancio
superscalare
Issue policy
Frequenza di issue

Shelving Blocking issue

Dispatching

Lettura degli Disponibilità


operandi degli operandi

Issue bound 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 8-
16 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 k-
j 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

FP Adder FP Multiplier
Memory Unit

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 write-
broadcast). 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 Sorgent Destinazion Contenut Funzione del messaggio


e e o
Read cache home P,A Messaggio inviato nel caso in cui P debba leggere
miss locale directory 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.
Write cache home P,A Messaggio inviato nel caso in cui P debba scrivere
miss locale directory 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.
Invali- home cache remota A Questo messaggio viene inviato a tutti i possessori
date directory 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
hanno in possesso non è più valido perché qualche
altro processo ha modificato il suo valore.
Fetch home cache remota A Questo messaggio serve per prelevare dalla
directory memoria il dato che è stato richiesto
Fetch/ home cache remota A Questo messaggio serve per prelevare dalla
Invali- directory memoria il dato che è stato richiesto e
date contemporaneamente invalidarlo presso tutti gli
altri condivisori.

54
Data home cache locale D Questo messaggio serve per inviare il dato
Value directory richiesto dalla cache remota (la quale lo deve o
Reply leggere o scrivere).
Data cache home A,D Questo messaggio serve ad aggiornare il dato
Write remota directory presso la memoria condivisa in modo da mantenere
Back 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

You might also like