29.05.2013 Views

Dispensa Calcolatori..

Dispensa Calcolatori..

Dispensa Calcolatori..

SHOW MORE
SHOW LESS

You also want an ePaper? Increase the reach of your titles

YUMPU automatically turns print PDFs into web optimized ePapers that Google loves.

<strong>Calcolatori</strong> Elettronici II<br />

23/03/2004<br />

Questa sopra è una curva che rappresenta le performance evolutive al passare degli anni.<br />

Quella in basso è la curva che dà ragione della crescita delle prestazioni se questa fosse soltanto<br />

dovuta all’innovazione tecnologica.<br />

Si vede che inizialmente le curve sono molto vicine e poi da un certo punto succede qualcosa, nel<br />

senso che il tasso di crescita della prestazione ha un brusco incremento, e come si vede poi si arriva<br />

al punto per cui, negli anni 2000, si ha un incremento al di sopra di quello che ci si poteva aspettare<br />

soltanto dall’innovazione tecnologica (fattore 10-15).<br />

Oggi occorrono 1000$, o anche meno, per un personal computer che ha più performance, memoria<br />

e disco di un computer che negli anni '80 era un supercomputer e che veniva venduto per<br />

1.000.000$. Questo dà l'idea di quello che è successo negli ultimi 20 anni.<br />

Che cosa ha prodotto questo?<br />

Le cause sono sostanzialmente due:<br />

1. I progressi dovuti all'innovazione tecnologica;<br />

2. L'innovazione nei principi di progettazione dei computer.<br />

Fino agli anni '70 queste due cause concorrevano pressoché allo stesso livello, cioè avevano la<br />

stessa importanza per quanto riguardava la crescita delle performance dei computers: normalmente<br />

si aveva un 25%-30% di tasso di crescita annuale delle prestazioni.<br />

Da un certo punto in poi è successo qualcosa che ha rivoluzionato quel trend, ed in particolare<br />

l’avvento dei microprocessori, che è stato dovuto all’innovazione tecnologica, alla tecnologia MOS,<br />

quindi alla grande capacità di integrazione che si è resa disponibile, e alla possibilità di integrare<br />

all’interno di un unico chip quella che normalmente viene chiamata CPU, che prima veniva<br />

realizzata a logica discreta. Questo ha fatto sì che i progettisti cavalcassero questa tendenza<br />

abbandonando via via la logica discreta nel realizzare i computer, e quindi facendo morire dei<br />

settori che erano i settori prevalenti dei minicomputer e dei mainframes (grossi computer). Questo<br />

ha fatto sì che l’incremento delle prestazioni dal 25%-30% l’anno passasse al 35%.<br />

1


In realtà però questo salto essenzialmente era dovuto, più che all'innovazione nelle tecniche di<br />

progettare un computer, quasi esclusivamente alla tecnologia. Ad un certo punto c'è stata una<br />

svolta: fino a quel punto si lavorava tipicamente in assembler, e questo significa che non era facile<br />

programmare, e poi quando veniva creato un nuovo microprocessore si cercava di renderlo<br />

compatibile con quello precedente, almeno per quanto riguardava l'esecuzione del codice; un<br />

esempio classico è quello della famiglia Intel (che è nata con l'8080, che era un microprocessore a 8<br />

bit, poi è nato l'8085, l'8086, l'80186, …, l'80486, Pentium), dove un codice sviluppato negli anni<br />

'80 ancora girerebbe su un Pentium. Questo era legato non al fatto che il progettista nel progettare il<br />

prossimo microprocessore era “innamorato” dell'architettura precedente, ma ad un fatto pratico,<br />

cioè al fatto di non sprecare tutte le risorse investite nel progettare il software che girava su quel<br />

microprocessore. Questo era un vincolo fortissimo per il progettista. A metà degli anni '80 però via<br />

via si è cominciato ad abbandonare il linguaggio assembler, perché i programmatori cominciavano<br />

ad usare sempre più i compilatori. Abbandonare il linguaggio assembler significava incominciare ad<br />

eliminare quel vincolo sulla compatibilità del codice: se ho un codice scritto in C e voglio passare<br />

da un processore all'altro ricompilo e non devo rifare il software. Però questo è uno degli aspetti;<br />

l'altro aspetto fondamentale è legato ai sistemi operativi: fino a quel tempo i sistemi operativi erano<br />

essenzialmente i sistemi operativi proprietari, e questo significava che quando l'IBM, per esempio,<br />

vendeva ad una banca un software che girava su quel sistema operativo, questa era bloccata a vita<br />

su quella piattaforma perché il software fa riferimento al sistema operativo.<br />

Poi è stato creato Unix: è stato concepito all'interno di un’università, quindi era un software libero,<br />

non risentiva di logiche di mercato e si poneva quindi come un sistema operativo che non era legato<br />

ad una particolare piattaforma hardware. Unix è stato già dall'inizio progettato, quasi tutto scritto in<br />

C e non più in assembler, per essere indipendente dalla piattaforma. Questo ha fatto sì che il<br />

progettista improvvisamente si trovasse di fronte ad una libertà inaspettata. A questo punto,<br />

Patterson ed Hennessy non dovendo più sottostare a quei vincoli potevano progettare un processore<br />

che aveva delle prestazioni molto più elevate delle architetture che in quel momento erano in<br />

circolazione. Questa logica ha fatto sì che si incominciasse ad affermare la nuova filosofia di<br />

processori, che è quella che si chiama RISC (reduced instruction set computer).<br />

A parità di tecnologia perché una filosofia RISC dovrebbe avere prestazioni migliori?<br />

Patterson ed Hennessy hanno fatto una considerazione: prima si programmava in assembler, e oltre<br />

al problema della compatibilità del codice c'erano altri problemi, cioè un programmatore assembler<br />

da un nuovo processore si aspetta un set di istruzioni molto ricco così ha una certa libertà nello<br />

scegliere il proprio stile di programmazione. Una volta che non si programma più in assembler non<br />

è più programmatore che utilizza il set di istruzioni del processore ma il compilatore, ovvero il<br />

progettista del compilatore; allora ci si è chiesti il perché continuare a progettare un processore con<br />

set di istruzioni ricchissimo (ricchissimo significa che da un punto di vista implementativo più<br />

esteso e complesso è il set di istruzioni più complesso è l'hardware che poi realizza quel set di<br />

istruzioni, e quindi occupa più area sul silicio) se poi nessuno più lo utilizza, o meglio non si sa<br />

quanto è utilizzato. Qual è il modo più semplice e diretto di capire se conviene o no progettare un<br />

set di istruzioni ricco visto che adesso ci sono i compilatori? Si cominciano a compilare tanti<br />

programmi diversi tra loro e vado a vedere ogni per programma compilato quali sono le istruzioni<br />

del processore che utilizza, e magari faccio una statistica. Facendo questo posso scoprire che, per<br />

esempio, un’istruzione pazzesca che qualcuno aveva concepito in quel microprocessore dal<br />

compilatore non veniva mai usata o quasi mai; ma allora se io la tolgo il compilatore entra in crisi<br />

oppure si può sempre trovare una strategia per cui quella istruzione io la faccio la stessa però<br />

usando altre istruzioni? La risposta naturalmente è sì. Siccome progettare un processore significa<br />

innanzitutto definire il suo set di istruzioni, e siccome il progettista sa che più complesso è il set di<br />

istruzioni più complesso è il processore, allora incomincia a vedere un determinato compilatore su<br />

quel processore che ha un set complesso di istruzioni quante ne utilizza, e quindi si fanno delle<br />

statistiche, delle misure sull'utilizzo del set di istruzioni. Facendo queste misure è emerso che<br />

moltissime istruzioni erano praticamente quasi mai utilizzate. Allora a questo punto la cosa più<br />

2


ovvia era ridurre il set di istruzioni all'essenziale; ma riducendo il set di istruzioni qual è il<br />

vantaggio che si ottiene? Se il set di istruzioni è meno complesso significa che l'hardware è meno<br />

complesso e quindi posso andare più veloce. Se io diminuisco il numero di transistor all'interno del<br />

chip per eseguire le istruzioni, i rimanenti transistor li posso utilizzare per metterci dentro la<br />

memoria oppure per realizzare il pipeline (un modo di eseguire più istruzioni contemporaneamente<br />

all'interno dello stesso processore). Normalmente il processore è molto più veloce della memoria,<br />

allora se all'interno del processore si sono liberati tanti transistor potrei utilizzare quell'aria di silicio<br />

libera per mettere un po’ di memoria dentro, per esempio dei registri; questo mi fa andare più<br />

veloce perché si risparmiano un sacco di accessi in memoria.<br />

Un aspetto è la memoria cache. Tutte le volte che si va memoria il processore deve rallentare<br />

moltissimo, allora la possibilità di inventarsi la gerarchia di memoria, cioè la memoria cache, è un<br />

qualcosa che fa pagare molto meno il prezzo al processore sulla lentezza della memoria.<br />

Facendo queste innovazioni si incomincia ad assistere ad un rate di crescita delle prestazioni di oltre<br />

il 50% per anno, e questo è quello che dà la spiegazione dell'andamento di quelle curve.<br />

Conseguenze:<br />

• Un microprocessore di oggi supera le prestazioni di un supercomputer di 10 anni fa<br />

• Dominanza di computer a microprocessore (PC+WS) sull'intero range dei computers<br />

(minicomputer e mainframe sostituiti da multiprocessore).<br />

Tutto questo è dovuto a questa innovazione nell'arte di progettare, innovazione che si basa sul<br />

principio fondamentale che è quello di un approccio quantitativo alla progettazione. Approccio<br />

quantitativo significa che prima di progettare qualcosa vado a fare delle misure vedo quello che già<br />

ho come viene utilizzato, e se ci sono cose che vengono utilizzate raramente queste sono le<br />

candidate ad essere buttate via, e questo significa inventarsi qualcosa di nuovo.<br />

Questa è una possibile rappresentazione della catena alimentare: i pesci più grossi mangiano i pesci<br />

più piccoli:<br />

Questa catena alimentare nel caso dell'informatica è stata ribaltata:<br />

Alcune conseguenze dell'approccio quantitativo sono:<br />

• Le prestazioni delle workstation migliorano del 50% per anno<br />

• Se si tiene conto del fattore costo un miglioramento costo-prestazioni del 70% per anno.<br />

Quando parliamo di computer che cosa intendiamo esattamente?<br />

Oggi ci sono tre segmenti di mercato che è abbastanza facile identificare:<br />

• Desktop computing (PC + WS)<br />

• Servers: file server, web server, ecc…<br />

• Embedded computers: parte più in crescita del mercato.<br />

3


Questi tre segmenti di mercato però hanno caratteristiche diverse da diversi punti di vista:<br />

Nell'ultima riga sono riportate le caratteristiche tipiche di ognuno dei tre settori: nel caso del<br />

desktop due parametri importanti sono il prezzo e le prestazioni (anche sulla grafica); per quanto<br />

riguarda il server si vuole un throughput elevato, cioè che riesca per esempio a processare un certo<br />

numero di milioni di richieste secondo, la disponibilità, che significa la capacità del sistema a<br />

continuare a mantenersi funzionante anche in presenza di guasti, e poi la scalabilità, ovvero la<br />

capacità del sistema ad essere espanso (un server normalmente prevede la possibilità di aumentare<br />

la capacità di memorizzazione, la possibilità di aggiungere processori, ecc…); per quanto riguarda i<br />

sistemi embedded questi sono caratterizzati sicuramente dal prezzo, il consumo di potenza,<br />

prestazioni per quella specifica applicazione.<br />

Dopo aver parlato di questa suddivisione e aver visto come i tre settori richiedono parametri di<br />

performance diversi, il compito del progettista è quello di determinare quali sono gli attributi della<br />

nuova macchina, e progettare per massimizzare la prestazioni rispettando i vincoli di costo e<br />

potenza, naturalmente collocandosi nel settore orientato (desktop, server o embedded).<br />

Un aspetto molto interessante è quello legato alla tendenza della tecnologia: perché un progettista di<br />

computer deve preoccuparsi moltissimo della tendenza tecnologica? Se io progettista ho oggi una<br />

certa tecnologia e il progetto del prossimo processore lo faccio con quello che ho a disposizione,<br />

perché mi devo preoccupare della tecnologia che avrò fra due anni?<br />

Ci sono dei dati che sono abbastanza consolidati che riguardano l'evoluzione della tecnologia:<br />

(capacity = capacità d’integrazione)<br />

C'è un parametro che assume un ruolo cruciale: time to market = 2 anni design + produzione. Il<br />

time to market è il tempo che si impiega ad immettere un nuovo prodotto sul mercato. Se io ho un<br />

time to market, per esempio per una workstation, di due anni e faccio il progetto con la tecnologia<br />

di oggi faccio un errore clamoroso perché se fra due anni il trend tecnologico è quello visto sopra<br />

allora la capacità, per esempio, sarà sicuramente maggiore di quella di oggi. Quindi bisogna<br />

guardare a cosa ci sarà disponibile quando il prodotto andrà in produzione.<br />

Altri aspetti legati alla tecnologia sono:<br />

• Feature size: minima size di un transistor o wire nelle direzioni x e y<br />

10µ (1971) → 0,08µ (2003)<br />

Il numero di transistor incrementa quadraticamente con la diminuzione della feature size,<br />

mentre la performance invece aumenta linearmente con la feature size.<br />

• All'aumentare della capacità di integrazione e della frequenza di funzionamento di questi<br />

dispositivi il ritardo di propagazione (wire delay) dei segnali incomincia ad essere rilevante:<br />

4


molti cicli di clock sono spesi per il ritardo sulle linee, e nel caso del Pentium IV due stage di<br />

pipeline su 20 sono consumati per la propagazione dei segnali attraverso il chip.<br />

• Potenza (power): l'incremento del numero di transistor per chip e la frequenza di switching<br />

comporta un incremento del consumo di potenza (qualche decina di W per un microprocessore<br />

anni '80, 100 W per il Pentium IV a 2GHz).<br />

Nel prossimo futuro la potenza sarà il limite principale.<br />

5


25/03/2004<br />

Abbiamo visto che una delle chiavi fondamentali che spiega l'elevato incremento delle performance<br />

nei computers è un nuovo approccio alla progettazione: un approccio di tipo quantitativo, quindi un<br />

approccio che sostanzialmente assume come paradigma quello di eseguire delle misure e sulla base<br />

di queste misure selezionare quelli che sono gli aspetti più rilevanti, quindi quelli che sono più<br />

suscettibili di essere migliorati garantendo un elevato livello di performance, e trascurando altri<br />

aspetti che, per quanto possono offrire scelte che soddisfano l'utente, magari poi sono molto poco<br />

utilizzati.<br />

Per fare queste misure questo approccio quantitativo utilizza vari strumenti che si basano su un<br />

insieme di programmi che si chiamano Benchmarks, sui Traces (queste informazioni vengono<br />

derivate a fronte dell'esecuzione di un programma, e per esempio questi traces ci dicono<br />

un'istruzione rispetto al totale quante volte viene eseguita), sugli instruction Mixes (mi dicono per<br />

esempio 30% di ALU e così via).<br />

Abbiamo visto, per esempio, nel caso dei sistemi embedded che uno dei requisiti fondamentali,<br />

oltre alla performance per quella determinata applicazione, per esempio era il consumo di potenza,<br />

ma si capisce bene è fondamentale anche l'area consumata sul silicio. Quindi sono stati messi a<br />

punto strumenti per la stima della potenza, dell'aria, del delay (tempo speso per eseguire un<br />

programma), ecc.<br />

Naturalmente poi ci sono strumenti che provano valutare i vari parametri di interesse attraverso<br />

teorie “tradizionali”, quale la teoria delle code, regole di tipo pratico, e leggi fondamentali.<br />

Oggi incominciamo a vedere cosa significa valutare alcuni parametri prestazionali dei sistemi<br />

partendo da alcune definizioni di base. Per noi misurare le prestazioni di un computer, per esempio<br />

in termini di velocità, significa valutare il tempo di esecuzione, cioè all'utente finale quello che<br />

interessa è quanto tempo viene speso da questo oggetto per eseguire una determinata applicazione,<br />

quindi si parla di ExTime (execution time): dire che un computer X è n volte più veloce di un<br />

computer Y significa dire che<br />

(per un web server non ci interessa l’ExTime, ma il throughput). A seconda che si parla di ExTime,<br />

oppure di throughput, oppure di qualsiasi altro parametro, devo stare attento a cosa mettere<br />

numeratore per dire che una cosa è più performante di quella precedente; questa ambiguità si<br />

elimina, quanto meno a livello di linguaggio, parlando di rapporto di prestazioni: se io dico che la<br />

macchina X è più performante della macchina Y di n volte, significa che<br />

Naturalmente in questo caso si ha che , se la mia performance è<br />

espressa in termini di tempo di esecuzione; se invece come performance intento il throughput ecco<br />

che la performance coincide con il throughput.<br />

Se X è n% volte più veloce di Y significa che<br />

dove<br />

> 1<br />

> 1<br />

,<br />

6


Esempio:<br />

Se Y impiega 15 secondi per eseguire un task e X impiega 10 secondi, quanto % X è più veloce?<br />

n=50%.<br />

Legge di Amdahl<br />

Questo è un principio che dovrebbe essere sempre seguito da un progettista, e in verità occorrerebbe<br />

seguirlo sempre a prescindere che il progetto riguardi un computer piuttosto che un'automobile, ecc.<br />

Make the common case fast! (rendi il caso più frequente veloce).<br />

“Più frequente” come va inteso? Va inteso che quando io utilizzo il sistema c'è una sua parte che è<br />

responsabile di molto del tempo di lavoro del sistema, allora intuitivamente un miglioramento<br />

apportato a questa parte ci ripaga sufficientemente.<br />

In miglioramento di prestazioni che può essere ottenuto migliorando una qualche attività, quindi<br />

rendendola più veloce, è limitato dalla frazione di tempo in cui tale attività ha luogo.<br />

SPEEDUP: misura di quanto più veloce un task gira sulla macchina enhanced. Quindi se io misuro<br />

uno Speedup = 1.5 significa che ho migliorato le performance del 50%.<br />

Vediamo come si ricava una qualche relazione che ci consenta quantitativamente di misurare qual è<br />

lo Speedup che si ottiene quando si apporta un miglioramento ad un sistema.<br />

Supponiamo di avere un programma che impiega il seguente tempo (la ExTime) per essere<br />

eseguito; analizzando il sistema vedo che c'è una sua parte che si<br />

può migliorare e in termini di tempo di esecuzione è<br />

responsabile di quella frazione di tempo (parte colorata) rispetto al totale. Questa parte a cui applico<br />

il miglioramento posso renderla più veloce, e questo significa che nel nuovo sistema quella parte si<br />

contrarrà, e quindi il tempo di esecuzione totale si accorcerà:<br />

Il rapporto tra il primo tempo di esecuzione e il secondo mi dà lo Speedupoverall (cioè Speedup<br />

complessivo).<br />

Le due grandezze che ci interessano sono: Speedupenhanced (Speedup Enhanced, cioè lo Speedup che<br />

posso ottenere solo della parte a cui applico il miglioramento); il rapporto tra il tempo da migliorare<br />

e l’ExTime totale si chiama FractionEnhanced (cioè la frazione di tempo a cui posso applicare il<br />

miglioramento, si misura sul sistema originario).<br />

Avendo definito queste due grandezze possiamo vedere come si misura lo Speedupoverall.<br />

Quest'ultimo non è altro che:<br />

(woE = without enhancement, wE = with<br />

enhancement).<br />

Il nuovo tempo di esecuzione è dato da:<br />

Così lo Speedupoverall è:<br />

Questa formula ci consente di fare un sacco di valutazioni per scoprire se un determinato<br />

miglioramento ripaga oppure no in termini di guadagno complessivo che si può ottenere.<br />

Nelle annunciato della legge di Amdahl abbiamo detto che il performance improvement è limitato<br />

dalla frazione di tempo in cui l'attività ha luogo; questa frazione di tempo è proprio la<br />

FractionEnhanced; ovvero il massimo Speedup ottenibile è limitato dalla FractionEnhanced. In che senso?<br />

7


Immaginiamo, caso irrealizzabile, di trovare un’idea che fa sì che una certa parte del sistema possa<br />

andare infinitamente più veloce di quanto va adesso; questo significa che lo Speedupenhanced sarebbe<br />

infinito e quindi il rapporto FractionEnhanced/Speedupenhanced tenderebbe a 0; più elevata è la<br />

FractionEnhanced maggiore è lo Speedupoverall.<br />

Da questa analisi semplicissima si può concludere che quello che in effetti ha un impatto enorme<br />

dal punto di vista di guadagno di prestazioni complessivo è la FractionEnhanced, ovvero la frazione di<br />

tempo suscettibile di essere migliorata: maggiore è questa frazione di tempo maggiore è il guadagno<br />

che si può ottenere, e questo ragionamento lo si fa indipendentemente da quanto veloce si vuole<br />

rendere.<br />

Esempio<br />

Prendiamo un computer che ha un certo processore in cui la parte relativa all’esecuzione delle<br />

istruzioni in floating point può essere migliorata facendola andare al doppio di velocità rispetto a<br />

quella attuale, quindi abbiamo uno Speedupenhanced pari a 2; qual è il guadagno che possiamo<br />

ottenere? Per rispondere a questa domanda dobbiamo stimare la FractionEnhanced. L’unità floating<br />

point viene utilizzata dai programmi, quindi non devo parlare in astratto, ma prendo un programma,<br />

suppongo che è il programma che mi interessa, e vedo quanto tempo viene speso (da questo<br />

programma) durante l’esecuzione sull’unità floating point rispetto al totale. In questo caso<br />

supponiamo che soltanto il 10% delle istruzioni eseguite sono di tipo floating point, ed è come dire<br />

che soltanto il 10% del tempo rispetto al totale del tempo di esecuzione, di un determinato<br />

programma, viene speso per la parte floating point. Questo 10% rappresenta la FractionEnhanced.<br />

Applicando le formule abbiamo:<br />

Quindi viene fuori che lo Speedup è pari al 5,3%.<br />

Esempio<br />

Dato un computer cerchiamo di rendere più veloce la CPU di 5 volte, ma questo ci costa 5 volte il<br />

costo originale della CPU. Quanto ci guadagno?<br />

Per vedere se questo investimento è economicamente vantaggioso devo vedere quanto ci guadagno<br />

in termini di performance e quanto mi costa questo guadagno di performance.<br />

I dati che abbiamo sono:<br />

la CPU nel sistema originario è responsabile del 50% del tempo totale di esecuzione (di un<br />

determinato programma); questo significa che l’altro 50% è dedicato all’I/O. Il costo della CPU è<br />

di 1/3 del costo totale del sistema.<br />

La prima cosa che facciamo è valutare lo Speedup:<br />

Quindi ottengo un miglioramento di performance complessivo pari al 67%.<br />

Quanto mi costa questo investimento? I 2/3 del sistema continueranno a costare quanto prima,<br />

mentre l’altro 1/3 costerà 5 volte di più:<br />

Quindi mi costerà 2,33 volte il costo originario.<br />

Così ho che il costo cresce molto di più delle performance.<br />

8


Esempio<br />

Supponiamo di avere un computer su cui viene eseguito una determinata applicazione. Questa<br />

applicazione ha delle operazioni in floating point alcune delle quali sono radici quadrate (FPSQR).<br />

In totale l’unità floating point è responsabile del 50% del tempo di esecuzione, e la sola FPSQR è<br />

responsabile del 10% del tempo di esecuzione totale. A questo punto c’è una gara tra due tipi di<br />

progettazione diversi:<br />

1) l’hardwareista dice che riesce a migliorare l’hardware del FPSQR in modo tale da farlo andare<br />

10 volte più veloce;<br />

2) il softwareista dice che può ottenere il doppio di velocità dell’intera unità floating point.<br />

Nel primo caso ho una FractionEnhanced che è pari a 0,2 e uno Speedup di 10, e allora ne segue che:<br />

Nel secondo caso ho una FractionEnhanced che è pari a 0,5 e uno Speedup di 2, e allora ne segue che:<br />

Quindi migliorano di più le prestazioni se aumento la velocità di tutta l’unità floating point anche se<br />

solo del doppio, rispetto al miglioramento della sola FPSQR anche se di 10 volte.<br />

Abbiamo detto che uno dei dilemmi che fa diventare matti i progettisti di computer è quello della<br />

lentezza della memoria. È un problema perché il computer nasce come una macchina per eseguire<br />

programmi e la CPU non fa altro che andare a leggere l'istruzione della memoria ed eseguirla.<br />

Quindi sicuramente per ogni istruzione bisogna fare quanto meno un accesso in memoria; questo<br />

significa che tutti i miglioramenti di prestazioni che si riescono ad apportare al processore possono<br />

essere vanificate dalla lentezza della memoria. Cosa si può fare per cercare di risolvere questo<br />

problema, o quanto meno di farlo pesare poco?<br />

Non potendo fare niente la tecnologia, qualcuno si è inventato una soluzione estremamente<br />

intelligente. Questa soluzione fa riferimento ad un principio che è il cosiddetto principio di<br />

località, e ad un altro principio che è molto legato all'elettronica. Per quanto riguarda l'elettronica<br />

c'è un principio che dice che: “smaller is faster” (più piccolo è più veloce). Sappiamo che quando<br />

parliamo di memorie RAM ci sono due tipologie: RAM statiche e RAM dinamiche. Le RAM<br />

statiche sono caratterizzate da una velocità più elevata delle RAM dinamiche, però le prime hanno il<br />

problema che consumano di più, perché sono più veloci, e la loro capacità di integrazione è molto<br />

più bassa delle seconde.<br />

Il principio di località è un principio che fa riferimento ad una considerazione base: quando eseguo<br />

un programma vado in memoria legge un istruzione, la eseguo e vado all'istruzione successiva, ecc.;<br />

queste istruzioni quando un programma viene caricato in memoria si succedono una dopo l'altra<br />

nella memoria; quindi quando io eseguo l'istruzione i-esima è probabile che la prossima da eseguire<br />

sta nella locazione di memoria immediatamente successiva. Questo è quello che ha fatto pensare a<br />

qualcuno che allora c'è una località nell'esecuzione del programma, e questo è quello che viene<br />

chiamato principio di località spaziale, ovvero quando io vado a referenziare un item<br />

probabilmente referenzierò gli item che stanno là intorno.<br />

C'è un altro aspetto di questo principio di località che, piuttosto che guardare alla località spaziale,<br />

guarda alla località temporale, ovvero se io vada referenziare un item (istruzione) in questo tempo, è<br />

probabile che nel prossimo futuro la referenzierò di nuovo (si pensi ai loop).<br />

Questa considerazione che tipo di idea potrebbe fare venire al progettista per risolvere questo<br />

problema del gap di prestazioni tra processore e memoria?<br />

9


Se io vado ad interporre una memoria statica, quindi una piccola memoria, tra processore e RAM<br />

dinamica, e in questa piccola memoria di volta in volta ci metto, sfruttando il principio di località, la<br />

parte di codice che viene referenziata, essendo veloce diminuisco in termini di velocità il gap tra<br />

processore e memoria. Quest’idea è furba soltanto se capita poco spesso che il processore andando<br />

in questa piccola memoria (memoria cache) non trova quello che cerca. Per verificare se questa cosa<br />

succede poco spesso oppure no si fanno delle misure su dei programmi di uso molto frequente. Ci<br />

sono delle misure su programmi molto utilizzati e si va scoprire che l’80%-90% dei riferimenti<br />

generati dal processore durante l'esecuzione di un programma cadono all'interno del 10%-20%<br />

dell'intero codice. Se questo è vero quel 20% di codice lo piglio e lo metto nella memoria statica.<br />

Da un punto di vista architetturale il nostro sistema si organizza nel seguente modo:<br />

abbiamo il processore all'interno del quale c'è un certo insieme di registri (che si può vedere come<br />

una piccola memoria incorporata all'interno del processore), poi quando il processore ma all'esterno<br />

per leggere dalla memoria qualcosa, incontra come prima cosa la memoria cache, che sta prima<br />

della main memory, e naturalmente dopo c’è la memoria di massa. Vista in questi termini è come se<br />

avessimo creato una gerarchia di memoria. Più alto è il livello, più questo è prossimo al processore<br />

più veloce è, e più costoso è.<br />

Esempio<br />

Cache cinque volte più veloce della main memory, e il 90% del tempo di CPU è speso in una<br />

frazione di codice che può interamente essere posto in cache.<br />

Lo Speedup che posso ottenere è:<br />

Questo significa che il sistema complessivamente sarà più veloce di 3,6 volte, cioè delle 360%.<br />

(secondo me del 260%)<br />

La formula che sintetizza il principio di Amdahl e che fa riferimento allo Speedupenhanced e alla<br />

FractionEnhanced ci serve a valutare il rapporto tra il vecchio ExTime e il nuovo ExTime. Laddove<br />

questi ExTime io potessi valutarli attraverso altre grandezze che magari in alcuni casi sono più<br />

facilmente misurabili è ovvio che ricorro ad un altro modo per valutarli.<br />

Cycles per Instruction<br />

A noi interessa vedere qual è il CPU time, cioè il tempo speso dalla CPU per eseguire un<br />

programma. Naturalmente in questo CPU time non è presente la parte eventualmente legata<br />

all’input/output.<br />

Il CPU time lo possiamo esprimere attraverso il seguente prodotto:<br />

10


Il tempo del processore durante il suo lavoro viene scandito da un clock con un periodo pari a Tck.<br />

Se io voglio misurare quanto tempo ho speso per eseguire un programma, ovviamente se conosco<br />

quant'è il periodo di clock, misuro quanti cicli di clock dall'inizio alla fine del programma sono<br />

trascorsi.<br />

CK cycles for a program a sua volta si può esprimere come Ic × CPI, dove Ic sta per Instruction<br />

Count, e CPI è il clock per instruction.<br />

Supponiamo di potere sapere quante istruzioni sono state eseguite, Ic, e conosco il numero di cicli di<br />

clock eseguiti per ogni istruzione, allora il numero di cicli di clock richiesti per un’istruzione<br />

moltiplicati per il numero di istruzioni mi dà il numero totale di cicli di clock del programma, che<br />

moltiplicato per il tempo di clock mi dà il CPUtime:<br />

Il CPI in realtà non è altro che un valore medio, ed è ottenuto come:<br />

Qua si parla di CPI medio. In realtà molto spesso io posso calcolare il CPI attraverso una media<br />

pesata che fa riferimento a categorie diverse di istruzioni: quando viene eseguito un programma ci<br />

saranno istruzioni che implicano l’esecuzione di operazioni logico-aritmetiche (ALU), poi ci<br />

saranno istruzioni di Branch (salto condizionato), poi ci saranno istruzioni di scambio di<br />

informazioni tra la memoria e il processore (Load/Store), ecc…; ammettendo che ogni categoria sia<br />

omogenea dal punto di vista del numero di cicli di clock richiesta per essere eseguita posso<br />

calcolare il CPI attraverso una media pesata:<br />

dove n rappresenta il numero di categorie distinte di istruzioni, ed Fi è la frequenza con cui la<br />

categoria i-esima è presente all’interno del running; la frequenza non è altro che il numero di volte<br />

in cui quella categoria di istruzioni è stata eseguita rispetto al numero di istruzioni totali eseguite:<br />

Ovviamente il CPUtime in questo caso è pari a:<br />

Il problema è stimare Fi, cioè durante un runnig quante volte è stata eseguita una determinata classe<br />

di istruzioni; questo si chiama instruction mix.<br />

È da notare che il CPIi dovrebbe essere misurato e non dedotto da quello che normalmente viene<br />

chiamato CPU technical reference manual, perché questo assume che tutto vada alla velocità del<br />

processore e non considera che alcuni cicli di clock possono essere spesi per degli accessi in<br />

memoria che è più lenta.<br />

Se è vero che il CPUtime è una misura delle prestazioni del nostro sistema ed è esprimibile dal<br />

prodotto di Ic, CPI e Tck, noi possiamo pensare che se vogliamo apportare un miglioramento,<br />

diminuire l’Ic del 30% è la stessa cosa, in termini di guadagno di performance, di fare più veloce il<br />

clock del 30% oppure ridurre del 30% il CPI medio. Allora si potrebbe pensare di investire sulla<br />

cosa che viene più facile migliorare. C’è un problema: questi tre fattori non sono tra di loro<br />

indipendenti, ma cercare di migliorare uno dei tre porta al peggioramento di qualcuno degli altri due<br />

o di entrambi. Quindi come sempre succede nella pratica bisogna trovare un compromesso tra<br />

esigenze molto spesso tra di loro in conflitto.<br />

11


Di seguito mostriamo una tabella che fa vedere da cosa dipendono l’Ic, il CPI e il Clock Rate:<br />

L’Ic dipende dal compilatore perché ogni compilatore lo stesso programma lo può tradurre in<br />

diversi modi; dipende dall’Instruction Set perché per esempio un’istruzione di Branch in alcuni<br />

processori è un’unica istruzione, e ci sono processori in cui il loro set di istruzioni non prevede di<br />

fare sia la verifica della condizione che il salto in un’unica istruzione, ma sono splittate su due<br />

istruzioni diverse.<br />

Il CPI indirettamente dipende dal compilatore perché a seconda di come compilo, e quindi dalla<br />

sequenza di istruzioni che produco, questo può portare a richiedere più cicli di clock per eseguire<br />

un’istruzione; dipende dall’Instruction Set perché se nel mio set di istruzioni includo un’istruzione<br />

che fa un insieme di operazioni è ovvio che ha bisogno di più cicli di clock per essere eseguita;<br />

dipende dall’Organizzazione che è l’organizzazione architetturale che io scelgo per implementare<br />

tutte le attività che deve eseguire il processore.<br />

Il Clock Rate dipende dall’Organizzazione perché più semplice è l’hardware più veloce si può<br />

rendere; ed è ovvio che la Tecnologia incide sulla frequenza di clock.<br />

Si vede che il miglioramento della tecnologia porta solo benefici.<br />

Come si vede se io penso di abbassare il CPI agendo sull’organizzazione bisogna capire cosa<br />

succede al clock rate; per esempio se io trovo una soluzione che mi porta le istruzioni dell’ALU da<br />

1.5 cicli di clock ad 1 ciclo di clock, e però per implementare questa soluzione scopro che<br />

l’hardware si è complicato e quindi la frequenza di clock con cui opero devo abbassarla,<br />

automaticamente ho migliorato il CPI ed ho peggiorato il Clock Rate.<br />

12


30/04/2004<br />

Esempio<br />

Facciamo riferimento ad una macchina base A in cui all’interno dell’instruction set tutte le volte che<br />

dobbiamo implementare un if, cioè un salto condizionato, in realtà per come è fatto l’instruction set<br />

questo richiede l’esecuzione di due istruzioni: COMPARE + BRANCH.<br />

Immaginando di avere un programma che giri su questa macchina, attraverso delle misure sono<br />

state dedotte le seguenti frequenze di esecuzione delle varie classi di istruzioni:<br />

alla fine otteniamo un CPI<br />

medio di 1,2. Significa che<br />

per eseguire quel<br />

programma mediamente<br />

spendiamo 1,2 cicli di<br />

clock per ogni istruzione.<br />

A questo punto un progettista pensa di apportare una modifica: proviamo a modificare l’instruction<br />

set di questo processore in modo tale che l’istruzione COMPARE venga incorporata all’interno<br />

dell’istruzione di BRANCH, cioè in poche parole la fase di valutazione della condizione appartiene<br />

all’istruzione di BRANCH. Per fare questo però l’organizzazione interna del processore,<br />

l’organizzazione hardware, è tale che la frequenza di clock operativa deve modificarsi; in<br />

particolare a seguito di questa modifica bisogna allungare, rispetto alla versione base, il periodo di<br />

clock di 1,25, cioè del 25%. La domanda è: conviene questa modifica?<br />

Supponendo di ignorare il problema legato al costo, cioè quanto costa fare questa modifica, ma ci<br />

concentriamo soltanto sulla performance, noi dobbiamo andare a vedere se il tempo speso per<br />

eseguire lo stesso programma sulla macchina A o sulla macchina B (quella modificata) varia, e<br />

come varia.<br />

Cercando di ricavarsi di nuovo quel tipo di tabella, però per la nuova macchina, adesso dobbiamo<br />

vedere qual è la nuova frequenza di BRANCH, e la frequenza delle rimanenti istruzioni:<br />

Sappiamo che il CPUtime della macchina base è: CPUtimeA = IcA × 1,2 × TckA.<br />

Noi sappiamo che sulla macchina B l’IcB varia rispetto a quello della macchina A perché in<br />

quest’ultima c’erano due istruzioni per ogni BRANCH; questo significa che adesso il numero di<br />

istruzioni si contrae del 20%: IcB = IcA – 20%IcA = 0,8IcA.<br />

La frequenza di BRANCH è data dal numero di occorrenze del BRANCH rispetto all’IcB<br />

complessivo:<br />

Così la tabella che si ottiene è:<br />

quindi il nuovo CPI medio è 1,25.<br />

13


A questo punto il CPUtimeB è:<br />

CPUtimeB = IcB × CPIB × 1,25 × TckA = 0,8IcA × 1,25 × 1,25TckA = 1,25 × IcA × TckA.<br />

Questo significa che il programma viene eseguito più velocemente nella macchina A.<br />

Quando si parla di misure delle performance c'è un indice, che è stato utilizzato moltissimo un po'<br />

di anni fa, tuttora viene ancora utilizzato, ma è caduto in disuso, che è il cosiddetto MIPS: milioni di<br />

istruzioni per secondo. MIPS = instruction count / Time × 10 6 = Clock Rate / CPI × 10 6 .<br />

Se io confronto due CPU è una ha un numero di MIPS maggiore dell'altra allora la prima è più<br />

veloce della seconda. Questa cosa non è detto che sia una cosa vera: è facilmente dimostrabile che<br />

una CPU con un numero di MIPS può portare ad un CPUtime maggiore piuttosto che minore. Se<br />

due CPU hanno un set diverso di istruzioni, lo stesso programma compilato su una macchina<br />

potrebbe presentare un MIPS, quando viene eseguito, maggiore perché per esempio sono istruzioni<br />

semplici quindi maggiori come numero di istruzioni piuttosto che in un'altra macchina dove ci sono<br />

istruzioni più complesse.<br />

I MFLOP/s sono milioni di floating point operation per second.<br />

MFLOP/s = FP Operation / Time × 10 6 . Anche in questo caso vale lo stesso ragionamento fatto<br />

prima.<br />

Esempio<br />

Supponiamo di avere una macchia base di cui sono state ottenute, a fronte dell'esecuzione di un<br />

programma, le seguenti statistiche:<br />

Questa macchina base che tipo di set di istruzioni ha?<br />

Ha un set di istruzioni che si chiamano register/register, oppure si può dire che è una macchina di<br />

tipo Load/Store. Una macchina di tipo Load/Store è una macchina in cui qualsiasi operazione che<br />

coinvolge l'unità logica-aritmetica (ALU) può essere eseguita soltanto se gli operandi stanno<br />

entrambi all'interno del processore, cioè sono nei registri del processore. Questo significa che, per<br />

esempio, quando scriviamo un programma in C e c’è la somma fra due variabili, queste variabili se<br />

quando è stato compilato il programma stanno in memoria, prima di potere eseguire la somma<br />

bisogna prevedere che il valore di queste variabili venga caricato con un’operazione di load<br />

all’interno di registri del processore, e successivamente può essere eseguita la somma; il risultato<br />

della somma presuppone che poi ci sia un’operazione di store, cioè venga scritto in memoria.<br />

Questa è un’architettura load/store. In un’architettura di questo tipo non è possibile eseguire, per<br />

esempio, la somma tra un operando che sta all’interno di un registro del processore e un operando<br />

che sta all’interno della memoria.<br />

Adesso rispetto ad una macchina di tipo load/store supponiamo di voler modificare l’architettura di<br />

questa macchina modificando l’instruction set e aggiungendo una nuova classe di istruzioni di tipo<br />

register/memory ovvero che mi consentono di fare operazioni ALU anche con operandi che stanno<br />

uno in un registro e uno in memoria. Questo tipo di istruzioni richiedono due cicli di clock, a<br />

differenza di una qualunque istruzione ALU che invece richiedeva un ciclo di clock.<br />

14


Il problema è: a fronte dello stesso programma, compilato sulle due macchine, cosa ci guadagno<br />

modificando il set di istruzioni introducendo questo tipo di istruzione? In particolare la domanda è:<br />

quale frazione di load nella macchina base deve essere eliminata perché questa modifica incominci<br />

a dare un guadagno?<br />

Questo significa che, se adesso ho istruzioni di tipo register/memory, tutta una parte di operazioni<br />

che facevo nella macchina base, operazioni che coinvolgevano l’ALU, che richiedevano delle load<br />

verranno eliminate nella nuova macchina perché non è necessario fare delle load esplicite.<br />

In pratica probabilmente riduco l’Ic, e riducendo l’Ic, siccome CPUtime = Ic × CPI × Tck, se non<br />

peggiorano le altre due componenti può darsi che avrò un CPUtime più basso, quindi che la nuova<br />

macchina con questo nuovo set di istruzioni sia più performante della vecchia macchina.<br />

È ovvio che in qualche modo la performance di una macchina dipenderà da quante load<br />

scompaiono; allora in questo caso il nostro obiettivo è valutare qual è la percentuale di load che<br />

deve essere eliminata perché questo tipo di instruction set architecture possa incominciare a fornire<br />

un guadagno rispetto alla macchina base. I dati relativi alla macchina base sono riportati sopra.<br />

Se io metto anche l’istruzione RegMem e chiamiamo X il numero di istruzioni RegMem eseguite<br />

abbiamo che X è il numero di load che si riducono rispetto alla macchina originaria, ma queste<br />

istruzioni di tipo RegMem le utilizzo per fare operazioni di tipo ALU, quindi anche le istruzioni<br />

ALU si riducono di X:<br />

facendo questa<br />

modifica purtroppo<br />

il branch avrà<br />

2 bisogno di tre cicli<br />

di clock.<br />

Calcolando il nuovo CPI otterrei 1.7-X; in realtà X è la frazione di istruzioni espressa però rispetto<br />

al vecchio instruction count. Noi vogliamo calcolare il CPI della nuova macchina, quindi questo<br />

CPI deve essere normalizzato rispetto al nuovo instruction count, ovvero il vecchio instruction<br />

count moltiplicato per 1-X, questo perché ognuna delle nuove frequenze è divisa per 1-X:<br />

F’ALU=N’ALU/I’C=(NALU–NRegMem )/IC(1–X)=NALU/IC(1–X)–NRegMem/IC(1–X)=FALU/(1–X)–X/(1–X).<br />

Vado a trovare il valore di X per cui i due CPUtime sono uguali:<br />

⇒ 1.00 × 1.5 = (1 – X) × (1.7 – X)/(1 – X) (ClockOld = ClockNew)<br />

⇒ 1.5 =1.7 – X ⇒ X = 0.2<br />

X deve essere almeno uguale a 0.2, cioè tutte le load che erano presenti dovrebbero essere<br />

eliminate, affinché la modifica non produca una perdita di performance.<br />

Noi abbiamo considerato il CPUtime immaginando che tutto vada alla velocità della CPU, e quindi<br />

che la CPU non debba aspettare memoria, ecc.<br />

In realtà le cose non stanno così: quando la CPU accede in memoria normalmente deve aspettare.<br />

Abbiamo visto che per mitigare questo problema è stata inventata la memoria cache. Allora tutte le<br />

volte che andando in memoria la CPU trova quello che sta cercando nella cache di fatto non deve<br />

aspettare (immaginando che la cache vada alla stessa velocità del processore); naturalmente questo<br />

non può succedere sempre perché la cache è piccola e quindi difficilmente riuscirà a contenere tutto<br />

quello che serve; questo significa che delle volte ci sarà un miss (mancato successo); in tal caso si<br />

/<br />

15


deve aspettare che il dato che non sta in cache debba essere reperito nel livello di memoria<br />

successivo, main memory, spostato nella cache e quindi il processore può leggere quel dato se si<br />

trattava di un’operazione di lettura. Questo significa che il CPUtime, se lo devo calcolare<br />

correttamente quando c’è una gerarchia di memoria, lo devo esprimere nel seguente modo:<br />

Quindi il tempo di CPU per eseguire un determinato programma sarà pari al numero di cicli della<br />

CPU se tutto andasse bene dilatato di un numero di cicli di stallo, nel senso che il processore deve<br />

bloccare la propria attività aspettando che qualcosa arrivi dalla memoria. Questo come si vede fa sì<br />

che il numero di cicli di clock totale per eseguire quel programma in presenza di una memoria reale<br />

aumenti rispetto al numero di cicli di clock strettamente richiesti dal processore.<br />

Proviamo a esprimere il MemoryStallCycles in qualche modo: questi cicli di clock di stallo si<br />

verificano tutte le volte che andando in memoria si verifica un miss. Se moltiplico il numero di miss<br />

per il miss penalty, ovvero per la penalità che pago per spostare il dato dalla main memory alla<br />

cache espresso in numero di cicli di clock, ottengo il numero totale di cicli di clock di stallo. Se io<br />

voglio mettere in evidenza l’instruction count, il numero di miss non è altro che una frazione di Ic,<br />

ovvero il numero di miss per instruction. Posso definire una nuova grandezza: il cosiddetto miss<br />

rate. Il miss rate non è altro che la frazione di miss che sperimento rispetto al numero totale di<br />

accessi in memoria: se vado 100 volte in memoria (cache) e 15 volte su queste 100 volte c’è un<br />

miss allora il miss rate è il 15%. Allora possiamo scrivere:<br />

dove mem.ref.per.instr. è il numero di riferimenti medio per istruzione, cioè per ogni istruzione<br />

quanti riferimenti in memoria si fanno.<br />

Esempio<br />

Supponiamo di avere una macchina A e supponiamo di avere un programma in cui ci sono il 40% di<br />

istruzioni load/store, un CPI medio pari a 2, in cui però ci siano tutti cache hits (tutti gli accessi in<br />

memoria hanno successo). Questa macchina la voglio confrontare con una macchina B che ha il 2%<br />

di miss rate, cioè il 2% delle volte che vado in cache non trovo quello che voglio; tutte le volte che<br />

succede questo il miss penalty è di 25 cicli di clock.<br />

Nel caso della macchina A abbiamo:<br />

Per quanto riguarda la macchina B abbiamo:<br />

Il mem.ref.per.instr. sarà 1 perché tutte le istruzioni eseguite richiedono un accesso in memoria, e<br />

poi ci sono il 40% delle istruzioni che sono di tipo load/store che richiedono due accessi: uno per<br />

leggere l’istruzione e un altro per eseguire o la load o la store, quindi il numero di riferimenti medi è<br />

1+0,4.<br />

Così il CPUtime è:<br />

Se facciamo il rapporto otteniamo:<br />

Così abbiamo un degrado di performance del 35%.<br />

,<br />

16


Abbiamo parlato di approcci quantitativi alla progettazione basati su misure per vedere quanto una<br />

certa alternativa piuttosto che un’altra è utilizzata, e abbiamo visto come valutare.<br />

Siccome parliamo di computer, cos’è che ci consente di valutare le prestazioni?<br />

Fare girare dei programmi e vedere quanto tempo impiega il computer per eseguirlo.<br />

Se ci mettiamo nella prospettiva in cui normalmente si mettono i produttori di computer si capisce<br />

che è auspicabile che il proprio prodotto è migliore di quello che produce qualcun altro. Solo che<br />

dire “migliore” non è facile.<br />

Incominciamo da un punto di vista logico a definire cosa ci consentirebbe di dire se un computer è<br />

migliore oppure no. Il produttore per dire che un computer è meglio di un altro dovrebbe dimostrare<br />

che per eseguire certi programmi il suo computer impiega meno tempo di un altro; però non si sa a<br />

quali programmi fare riferimento, perché non si sa ogni utente quali programmi usa.<br />

Il primo problema quindi è: quali programmi usare per valutare le prestazioni? E per quali tipologie<br />

di utenti?<br />

Qual è la condizione ideale impossibile da realizzare?<br />

La condizione è quella di utilizzare un workload reale, quindi fatto da programmi reali, che non è<br />

altro che l’insieme di applicazioni e di comandi di sistema operativo che vengono dati durante<br />

l’utilizzo normale da parte di quell’utente. Questo perché gli utenti sono tanti e ognuno ha esigenze<br />

abbastanza diverse.<br />

La soluzione che è stata individuata già da un po’ di anni è quella di utilizzare le cosiddette<br />

benchmark suites, cioè delle collezioni di programmi che in qualche modo siano rappresentativi<br />

dei diversi scenari di utilizzo dei computer.<br />

Che tipo di programmi costituiscono una benchmark suite? Ci sono varie tipologie di programmi.<br />

• Toy benchmarks, sono dei software semplicissimi, 10-100 linee di programma, fatti per<br />

stimolare certi parti del sistema: sieve, puzzle, quicksort.<br />

• Synthetic benchmark, che non sono dei programmi reali, cioè che non risolvono nessun<br />

problema reale: whetstone, dhrystone.<br />

• Kernels, che sono dei pezzi di programmi reali, tipicamente per esempio pezzi di programmi di<br />

un sistema operativo o kernel di qualche applicazione particolare: livermore loops.<br />

• Programmi reali: gcc, spice, ecc.<br />

Dopo aver litigato per tanti anni alla fine i costruttori sempre cercano di trovare un accordo, e in<br />

genere lo scenario in cui si cerca di sintetizzare queste liti e questo accordo è quello degli organismi<br />

di standardizzazione internazionali. A livello di standardizzazione è stato proposto il cosiddetto<br />

SPEC (Standard Performance Evaluation Corporation), che è riconosciuto da tutti i costruttori e che<br />

specifica quali sono i programmi che bisogna utilizzare per valutare le prestazioni di una macchina.<br />

Considerando il tipo di differenziazione del mercato nel settore dei computer (desktop, server ed<br />

embedded) SPEC ha cercato di differenziare le suites per valutare le prestazioni dei vari settori:<br />

CPU intensive significa che stimolano prevalentemente la CPU, quindi valutano le prestazioni<br />

prevalentemente del processore. I Graphic intensive che cercano di valutare le prestazioni dal punto<br />

di vista della grafica. Gli SPEC FS (spec filesystem) sono basati sulla valutazione del numero di<br />

transazioni per secondo che è in grado di eseguire un server.<br />

17


Tipicamente un benchmark esce ogni 3 anni.<br />

SPEC CPU2000<br />

Naturalmente c’è un ampio settore di mercato, che è il settore di mercato più promettente, che è<br />

quello dei PC, per cui sono nati anche i benchmark per PC:<br />

• Business Winstone: è uno script che lancia Netscape e diversi prodotti di Office per cercare di<br />

simulare un workload reale di un tipico pc user;<br />

• CC Winstone: simula un ambiente di applicazioni per la creazione di contenuti multimediali<br />

(Photoshop, Premiere, Navigator, ecc.);<br />

• Winbench: insieme di kernel per il test di CPU, sistema video, dischi.<br />

Per quanto riguarda i sistemi embedded è nato il consorzio EEMBC che ha creato la suite chiamata<br />

EDN che include: automotive industrial, consumer, networking, office automation,<br />

elecommunication.<br />

Si fanno molti giochi con i benchmark:<br />

• ottenere dei risultati migliori su una macchina rispetto ad un’altra facendo girare lo stesso<br />

benchmark suite sui due sistemi senza dire come sono equipaggiati;<br />

• ottenere performance migliori ottimizzando dei compilatori per determinati programmi;<br />

18


• workload utilizzati in modo arbitrario: quando per esempio ho una suite di benchmark con i<br />

programmi A, B, C, D, e il programma A è quello più veloce allora quando faccio girare questa<br />

suite di benchmark, se non ho delle costrizioni particolari, potrei fare girare molte volte il<br />

programma A e poche volte gli altri programmi; quindi prevale la performance del programma<br />

A.<br />

19


01/04/2004<br />

Abbiamo detto che proprio perché è abbastanza complicato individuare un workload che sia<br />

rappresentativo per ogni utente sono nati dei benchmark suites. Un benchmark suite abbiamo detto<br />

che è costituito da un insieme di programmi molto diversi tra di loro, quindi si capisce che non è<br />

facile stabilire qual è il giusto mixing di questi programmi per valutare le prestazioni.<br />

Allora c’è il problema di cercare di stabilire quando si fa girare un benchmark che tipo di rapporto<br />

di prestazioni devo andare ad ottenere.<br />

Una delle cose che molto raramente avviene nel campo dei computer è quello di rispettare il<br />

cosiddetto principio di riproducibilità: includere tutto ciò che consente ad altri di replicare gli<br />

esperimenti fatti.<br />

Nel caso degli SPEC benchmark un report richiede:<br />

- una descrizione quasi completa della macchina (configurazione hardware e software);<br />

- flag di compilazione: quando si usa un programma come gcc per compilare, per esempio, è<br />

necessario settare dei flag in modo tale che tutti devono utilizzare quei flag per compilare;<br />

- pubblicazione dei risultati sia delle performance di base (baseline) sia quelle ottimizzate.<br />

Nella performance baseline viene imposto di utilizzare un particolare tipo di compilatore e un set<br />

di flag da utilizzare nella compilazione per tutti i programmi nello stesso linguaggio.<br />

Per quanto riguarda la performance di picco (peak performance) c’è una maggiore libertà in modo<br />

da potere fare un tuning delle prestazioni attraverso, per esempio, compilatori proprietari o flag<br />

specifici, cioè che non sono imposti.<br />

Esempio di baseline performance<br />

Per quanto riguarda l’affidabilità dei benchmark come predittori della performance reale riportiamo<br />

un esempio:<br />

c’è un programma che si chiama matrix300 (SPEC 89), un software che fa il prodotto tra matrici,<br />

che spende il 99% del tempo di esecuzione su una linea di codice. ottimizzando il loop più interno<br />

attraverso un compilatore per una IBM PowerStation 550 si ottiene un miglioramento di un fattore 9<br />

20


nella performance. In questo modo però non sto testando la macchina ma sto semplicemente<br />

testando la performance del compilatore.<br />

Andiamo ora a capire come si possono misurare le prestazioni a fronte dell’esecuzione di alcuni<br />

programmi.<br />

Quando faccio girare un programma posso dire che la performance è il tempo di esecuzione del<br />

programma; quando mi pongo il problema di trovare un indice globale di prestazione per la mia<br />

macchina è ovvio che non mi conviene dire che la mia macchina per fare girare, per esempio, il gcc<br />

piuttosto che un’altra applicazione impiega tot tempo; poi se quella applicazione non è<br />

rappresentativa per l’utente questo di per sé non è un’informazione che è appetibile per il mercato.<br />

Quindi bisognerebbe tirare fuori un indice di performance globale ricavato dal running di una suite<br />

di benchmark, cioè di programmi abbastanza rappresentativi. Ma qual è quest’indice globale?<br />

Vediamo quali si potrebbero utilizzare, e sulla base di questi eventuali indici globali cercare di<br />

capire se è possibile rispondere ad una domanda: quest’indice globale di performance mi consente<br />

di dire che una macchina è più veloce di un’altra?<br />

Esempio<br />

Abbiamo tre computer e supponiamo di avere una suite di benchmark fatta da due programmi: P1 e<br />

P2. Facendo girare questi due programmi sui tre computer impiego i secondi indicati nella seguente<br />

tabella:<br />

A questo punto bisognerebbe cercare di capire qual è l’indice globale di performance utile ai fini di<br />

dire quale delle tre macchine è più veloce.<br />

Ovviamente per il programma P1 la macchina più veloce è A, e a sua volta B è più veloce di C; per<br />

il programma P2 la macchina più veloce è C, e B è più veloce di A. Quindi non c’è una tendenza<br />

che mi porta a dire che una macchina è più veloce delle altre.<br />

Una misura più consistente è quella, per esempio, di considerare il total execution time. Se io vado a<br />

considerare questo indice trovo i tempi indicati nella tabella sopra. Questo mi porterebbe a dire che<br />

il computer C è 2,75 volte più veloce del computer B e 25 volte del computer A; e il computer B è<br />

9,1 volte più veloce del computer A.<br />

Quindi in questo caso il tempo di esecuzione totale è una misura consistente e ci consente di<br />

affermare quello detto sopra; però questa cosa è vera solo se io faccio girare su queste tre macchine<br />

i programmi P1 e P2 lo stesso numero di volte.<br />

Il problema è che non è detto che io faccia girare lo stesso numero di volte P1 e P2.<br />

Se il numero di running di ogni singolo programma costituente la benchmark allora il total<br />

execution time oppure la media dei tempi di esecuzione sono abbastanza rappresentativi delle<br />

performance.<br />

Nel caso in cui il numero di running di P1 è diverso dal numero di running di P2 si può ricorrere a<br />

due soluzioni:<br />

1) media aritmetica pesata<br />

2) media geometrica.<br />

La media aritmetica pesata è uguale a: , dove Ti è il tempo di esecuzione del programma<br />

i-esimo all’interno del benchmark.<br />

21


Se io ho ni che è il numero di running di Pi nel workload e ∑i ni = n, il peso o la frequenza relativa<br />

del programma i-esimo non è altro che wi = ni / n.<br />

Vediamo alcuni esempi relativamente alle macchine A, B e C:<br />

La pesatura w(1) fa si che pesa nello stesso modo i due programmi: così otteniamo quello che<br />

avevamo ottenuto prima, cioè che il computer C è più veloce di B, e B è più veloce di A, e se si<br />

fanno i rapporti si ottengono gli stessi valori di prima.<br />

Nella pesatura w(2) fisso i pesi in modo tale che siano inversamente proporzionali ai tempi di<br />

esecuzione dei programmi P1 e P2 sul computer B, cioè faccio girare più volte il programma che<br />

richiede meno tempo. In questo modo ottengo che il computer B è più veloce di A e di C, e C è più<br />

veloce di A.<br />

Con la pesatura w(3) fisso i pesi in modo inversamente proporzionale al tempo di esecuzione di P1<br />

e P2 sulla macchina A. In questo modo ottengo che il computer A è più veloce di B e C, e B è più<br />

veloce di C.<br />

Sempre parlando di indici di prestazione globale quando il numero di running dei vari programmi è<br />

diverso piuttosto che la media aritmetica pesata un altro tipo di media che si potrebbe utilizzare è<br />

quella geometrica. In particolare si considera normalmente la media geometrica dei tempi di<br />

esecuzione normalizzati: ho una suite di benchmark e voglio misurare le prestazioni sulla macchina<br />

A, allora devo fare girare il programma sulla macchina A e ottengo un tempo di esecuzione che<br />

devo normalizzare rispetto al tempo di esecuzione dello stesso programma su una macchina<br />

campione (per esempio come macchina campione viene utilizzata per la SPEC una SPARCstation).<br />

In questo caso la media geometrica dei tempi normalizzati è:<br />

Si potrebbe erroneamente pensare che si possa predire la performance di un programma sulla mia<br />

macchina moltiplicando π per la performance del programma sulla macchina campione, essendo<br />

noti sia il primo che la seconda.<br />

Un’altra cosa da notare è che quando si utilizzano i tempi di esecuzione normalizzati e si utilizza la<br />

media aritmetica di questi ultimi si può arrivare a dei paradossi. Per esempio: supponiamo di avere<br />

al solito i computer A, B e C, e i due programmi P1 e P2; normalizzando i tempi di esecuzione<br />

22


considerando le varie macchine come macchina campione si ottengono i valori riportati in tabella.<br />

Se io considero la media aritmetica dei tempi normalizzati o rispetto ad A, B o C, questa fornisce<br />

delle misure assolutamente inconsistenti, nel senso che nel primo caso viene fuori che A è il<br />

computer più veloce, nel secondo caso è B il più veloce, è nel terzo caso e C il più veloce; quindi il<br />

computer più veloce è quello rispetto a cui normalizzo. Quindi non bisogna mai considerare la<br />

media aritmetica dei tempi di esecuzione normalizzati, ma considerare la media geometrica. Infatti<br />

la media geometrica è consistente: indipendentemente dalla macchina campione otteniamo sempre<br />

misure consistenti, in questo caso C è sempre il computer più veloce, e anche i rapporti sono sempre<br />

uguali.<br />

Quindi la media geometrica dei tempi di esecuzione normalizzati è consistente indipendentemente<br />

dalla macchina di riferimento, e non dipende dal numero di running dei programmi individuali.<br />

Uno dei problemi con la media geometrica è che non consente di predire il tempo di esecuzione. Un<br />

altro problema è che quando si considerano tre o più macchine non esiste nessun workload che sia<br />

compatibile con la performance predetta dalla media geometrica. Questo significa che in qualche<br />

modo si vanifica la ragione per cui questa media è stata introdotta.<br />

Un altro problema con la media geometrica è: se per esempio io voglio fare un’ottimizzazione,<br />

ovvero voglio migliorare sulla mia macchina le prestazioni e ho due possibilità, per esempio passare<br />

da 2s a 1s per un programma piuttosto che da 1000s a 500s per un altro programma, se io considero<br />

la media geometrica il miglioramento che io vado a valutare è identico, cioè dire dato lo stesso peso<br />

ai due abbattimenti dei tempi di esecuzione. Quindi se io mi metto nei panni di quello che vuole<br />

truccare le carte e volessi ottenere quello stesso miglioramento di performance globale valutato<br />

attraverso la media geometrica, supponendo che voglio dimezzare il tempo di esecuzione, fra tutti i<br />

programmi vado a scegliere quello su cui è più facile farlo.<br />

Cosa bisogna fare per cercare di dare delle informazioni consistenti e corrette? Bisogna misurare un<br />

workload reale e pesare i programmi con nelle loro frequenze di esecuzione reali.<br />

Quando si dà come misura delle prestazioni un indice globale, questo tende a nascondere delle<br />

informazioni importanti, e in alcuni casi potrebbe non essere il migliore indicatore della<br />

performance per un'applicazione d'utente. Per cui assieme all'indice globale sarebbe bene fornire<br />

anche i risultati (tempo di esecuzione, frequenza con cui è stato fatto girare un programma del<br />

benchmark, ecc…) dei singoli benchmark costituenti il workload.<br />

Sistema di elaborazione<br />

Un sistema di elaborazione per noi non è altro che costituito da tre entità:<br />

statica<br />

In particolare un sistema di elaborazione lo possiamo definire come una macchina in grado di<br />

eseguire programmi espressi in un determinato linguaggio di programmazione.<br />

23


La macchina è un'entità attiva, dinamica (che evolve nel tempo), mentre il programma è una entità<br />

statica. Durante l'esecuzione di un programma questo rimane quello che è, mentre la macchina,<br />

rappresentata da tastiera, monitor e dall'insieme di variabili (che dobbiamo considerare appartenenti<br />

alla macchina) viene modificata (ad esempio il valore delle variabili viene modificato).<br />

Nel nostro modello noi assumiamo normalmente che un'istruzione costituisce una azione atomica<br />

per il nostro sistema, cioè significa che se noi facciamo riferimento allo stato della nostra macchina<br />

(insieme di variabili, e in generale tutto ciò che ha all'interno della macchina memorizza<br />

informazioni) l’istruzione ci fa passare da uno stato all'altro; quindi significa che eseguire un<br />

programma equivale a fare compiere alla nostra macchina una traiettoria di stati all'interno dello<br />

spazio dei possibili stati che la macchina può assumere.<br />

Quello a cui siamo stati abituati a vedere come modello di un sistema di elaborazione è quello che<br />

viene chiamato modello funzionale: il modello che descrive la macchina come un puro esecutore<br />

del proprio linguaggio. Da questo punto di vista tutte le macchine Pascal, oppure C, ecc…, sono<br />

funzionalmente identiche.<br />

Il modello realizzativo di un sistema di elaborazione fa riferimento a come quest’ultimo è fatto,<br />

cioè che tipo di componenti fisici ci sono, come sono collegati tra di loro, ecc.<br />

Questo significa che due macchine che hanno lo stesso modello funzionale non è affatto detto che<br />

abbiano lo stesso modello realizzativo:<br />

Parlando del modello funzionale possiamo dire che la traiettoria di stato che viene percorsa dalla<br />

nostra macchina è di tipo deterministica, cioè è predicibile sapere quali sono tutti gli stati che<br />

attraverserà la nostra macchina se noi conosciamo tre elementi: programma in esecuzione, i dati che<br />

forniamo al nostro programma e lo stato iniziale. Questa affermazione assume un’importanza<br />

notevole perché non è detto che sia così.<br />

Da un punto di vista schematico quello che abbiamo detto sul modello funzionale è rappresentabile<br />

nel seguente modo:<br />

Esempio<br />

dove M = macchina, L = linguaggio, P = programma.<br />

In generale in realtà un sistema di elaborazione è costituito da una gerarchia di macchine, non da<br />

una singola macchina. Questa gerarchia di macchine è organizzata attraverso il seguente principio:<br />

se io mi metto ad un certo livello di questa gerarchia di macchine posso salire di livello, e realizzo<br />

una macchina che sta ad un livello superiore a partire dalla macchina del livello immediatamente<br />

inferiore. Questa affermazione che sembra banale è quella che è stata uno dei motivi della<br />

rivoluzione nel campo dell’informatica, o quantomeno che ha consentito di estendere l’uso dei<br />

calcolatori ad una quantità di utenti maggiore.<br />

24


Realizzo una macchina sopra un’altra macchina a partire o da un compilatore (traduttore) o da un<br />

interprete. (Il compilatore traduce il nostro codice in un altro linguaggio, l’interprete legge il codice<br />

lo interpreta e lo esegue). Il compilatore è più efficiente perché questo traduce il codice una sola<br />

volta, mentre l’interprete ogni volta che si deve far girare il programma interpreta il codice.<br />

In che senso realizzo una macchina sopra un’altra macchina?<br />

Supponiamo di avere una macchina M1 caratterizzata da un proprio<br />

linguaggio L1; su questa macchina posso realizzare una macchina M2,<br />

che gerarchicamente è superiore ad essa, con un linguaggio L2<br />

semplicemente costruendo un programma P12(L1) espresso nel<br />

linguaggio L1, e questo programma non è altro che o un compilatore o<br />

un interprete. Ovviamente il programma che può girare sulla macchina<br />

M2 sarà scritto nel linguaggio L2: P(L2).<br />

Il senso di realizzare una gerarchia di macchine è quello di fare sì che<br />

la macchina sia più facilmente utilizzabile.<br />

Naturalmente se salire di livello significa rendere più facile l’utilizzo<br />

all’uomo, questo allo stesso tempo può penalizzare le prestazioni.<br />

Adesso scendiamo di livello e arriviamo nell’ambito della gerarchia di macchine a quella che viene<br />

chiamata macchina processo. La macchina processo è quella macchina che esegue i programmi<br />

che tipicamente producono i compilatori. Il linguaggio della macchina processo è un linguaggio di<br />

tipo binario. Una caratteristica della macchina processo è che si incomincia a perdere quella che<br />

viene chiamata identità funzionale tra le varie macchine: noi abbiamo detto che tutte le macchine C,<br />

per esempio, sono funzionalmente identiche, mentre la stessa cosa non vale più per le macchine<br />

processo, cioè un programma espresso nel linguaggio di una macchina processo non può girare su<br />

un’altra macchina processo; questo avviene perché la macchina processo è quel livello all’interno<br />

della gerarchia al di sotto del quale non è più possibile nascondere i dettagli realizzativi della<br />

macchina, cioè le differenze reali tra le varie macchine. Questo significa che ogni macchina<br />

processo ha un proprio linguaggio binario, che dipende dal sistema di elaborazione. Sebbene questo<br />

sia vero tutte le macchine processo sono caratterizzate da un insieme di elementi che le rendono<br />

simili. Questi elementi sono:<br />

• sono dotate di una memoria di processo, che sostanzialmente è uguale un po’ per tutte;<br />

• richiedono che il programma, che poi verrà eseguito dalla stessa macchina processo, debba<br />

risiedere nella memoria di processo;<br />

• sono caratterizzate dal fatto che utilizzano per eseguire i programmi un registro che si chiama<br />

Program Counter;<br />

• sono dotate di un meccanismo di esecuzione delle istruzioni del programma che è analogo;<br />

• stato del processo: contenuto di memoria + contenuto dei registri.<br />

La memoria di processo su per giù è la seguente:<br />

25


La memoria non è altro che un sistema che è in grado di memorizzare informazioni espresse in<br />

formato binario. Se il suo scopo è quello di memorizzare informazioni significa che è un sistema<br />

che per essere usato mi deve offrire dei servizi; questi servizi mi devono consentire di memorizzare<br />

informazioni, ma se memorizzo informazioni lo faccio perché poi voglio andare a reperirle. Questo<br />

sistema è organizzato come un insieme di locazioni (ognuna è una riga), e ciascuna locazione (o<br />

cella di memoria, o parola di memoria) è in grado di memorizzare un certo insieme di bit. Una<br />

caratteristica del sistema memoria è che tutte le locazioni di memoria costituenti il sistema hanno la<br />

stessa dimensione in bit, nel nostro caso k bit (k=1, 8 ,16, 32, 64).<br />

Per utilizzare i servizi che vengono offerti dal sistema memoria è necessario specificare alcune<br />

cose. Quando voglio andare a memorizzare un informazione, poiché questo sistema ha N diverse<br />

locazioni, devo specificare al sistema dove voglio che l’informazione venga memorizzata; questo lo<br />

specifico attraverso un indirizzo, utilizzando la porta degli indirizzi che il sistema memoria mette a<br />

disposizione. Inoltre se si vuole depositare una specifica informazione, questa la si dà al sistema<br />

memoria attraverso la porta dei dati.<br />

La capacità di memoria esprime la quantità di informazioni in bit che è memorizzabile in essa<br />

(1Mbyte = 2 20 byte, ovvero 2 20 word di un byte).<br />

Quando avviene la scrittura di una particolare locazione, questa fa sì che il contenuto precedente<br />

scompaia, cioè si altera il contenuto informativo di quella locazione. La lettura, viceversa, mi<br />

consente di accedere ad una locazione ottenere l’informazione che c'è all'interno di quella locazione<br />

mantenendo nella stessa locazione la stessa informazione; quindi significa che la lettura non altera<br />

l’informazione contenuta all'interno di una locazione di memoria.<br />

In scrittura:<br />

• sulla porta degli indirizzi devo fornire l'indirizzo, ovvero la posizione su cui memorizzare la<br />

parola;<br />

• sulla porta dei dati devo fornire l'informazione da scrivere.<br />

A questo punto la memoria prende quello che c'è sulla porta dei dati è lo va a memorizzare<br />

all'interno della locazione selezionata dall'indirizzo presente sulla porta degli indirizzi:<br />

In lettura:<br />

• sulla porta degli indirizzi devo specificare l'indirizzo della locazione da leggere;<br />

• sulla porta dei dati si ottiene la parola memorizzata in quell'indirizzo.<br />

L’indirizzo deve specificare in modo univoco ogni singola locazione di memoria. Ad esempio se<br />

ho una memoria da 1 Kbyte (2 10 byte) occorrono 10 bit (log2 2 10 = 10) per specificare un indirizzo.<br />

26


Come fa il sistema memoria sulla base di quei 10 bit a dire a quale locazione si fa riferimento? Non<br />

fa altro che usare un decodificatore, che è un circuito combinatorio che riceve un ingresso, in questo<br />

caso di 10 bit, e fornisce un’uscita, in questo caso 1024. Facciamo un esempio con un decodificare<br />

binario a 3 bit:<br />

bit più significativo<br />

0<br />

0<br />

1<br />

DEC<br />

bit meno significativo<br />

0<br />

1<br />

2<br />

3<br />

4<br />

5<br />

6<br />

7<br />

Della macchina processo la parte che esegue i programmi è l’unità centrale di processamento<br />

(CPU). Essa non è altro che l’esecutore di cui si serve la macchina processo per eseguire i<br />

programmi che stanno nella memoria di processo; nello svolgere questo ruolo di esecutore coordina<br />

anche tutti i vari blocchi che costituiscono la nostra macchina.<br />

Questa CPU è caratterizzata da un proprio set di istruzioni, ovvero dall’insieme di istruzioni che<br />

essa è in grado di eseguire. Ognuna di queste istruzioni è codificata in binario, e ovviamente questa<br />

codifica binaria deve essere riconoscibile da quella particolare CPU. Questo ci fa capire perché<br />

macchine processo diverse hanno linguaggi binari diversi. Ovviamente un programma può essere<br />

eseguito dalla CPU se è costituito da istruzioni appartenenti al set di istruzioni della CPU, e inoltre<br />

se tutte le istruzioni che costituiscono il programma sono codificate in quel linguaggio binario e<br />

memorizzate sequenzialmente nella memoria centrale. Quindi un programma eseguibile dalla<br />

macchina processo lo possiamo immaginare fatto così:<br />

Il modo stesso di andare a memorizzare il programma in memoria<br />

implicitamente per l’esecutore contiene l’informazione di quale è<br />

l’ordine di esecuzione delle istruzioni.<br />

Formato delle istruzioni<br />

Abbiamo detto che è codificata in binario; questo significa che ogni istruzione del set di istruzioni<br />

di una determinata CPU è costituita da una stringa di 1 e 0 con una certa lunghezza. Tutte le<br />

macchine processo, per quanto ognuna ha un linguaggio binario diverso e tecniche di codifica<br />

diverse, si assomigliano su degli elementi comuni, ovvero tutte utilizzano una tecnica di codifica<br />

che consente di individuare sempre due campi all’interno dell’istruzione codificata: un campo che si<br />

chiama codice operativo e un campo che si chiama operandi<br />

codice operativo operandi<br />

27


La prima parte dell’istruzione (codice operativo) specifica all’esecutore di che tipo di istruzione si<br />

tratta, cioè caratterizza l’istruzione; gli operandi specificano gli oggetti su cui quell’operazione deve<br />

essere eseguita. Gli operandi o rappresentano i dati stessi o rappresentano il modo di riferimento ai<br />

dati su cui fare l’operazione; per esempio se un dato sta in memoria e l’altro sta in un registro il<br />

campo operandi dell’istruzione conterrà l’indirizzo del dato che sta in memoria e l’indirizzo del<br />

registro, ovvero il nome del registro (qualcosa che individui il registro su cui sta il dato su cui fare<br />

l’operazione). Il codice operativo per ogni istruzione dello stesso tipo (per esempio somma) non è<br />

sempre lo stesso perché deve specificare sì il tipo di istruzione, ma specifica anche come<br />

interpretare il campo operandi.<br />

La CPU normalmente è costituita da due componenti: unità di controllo e unità logico-aritmentica<br />

(ALU). L’unità di controllo non fa altro che essere la parte attiva della CPU e a tutti gli effetti è la<br />

parte “intelligente” dell’esecutore: legge l’istruzione, la interpreta, la esegue, decide quale sarà la<br />

prossima istruzione da eseguire, ecc.<br />

L’unità logico-aritmentica è una sorta di “schiavetto” asservito all’unità di controllo, cioè<br />

quest’ultima a fronte dell’istruzione che sta eseguendo decide cosa farle fare.<br />

All’interno di questa CPU ci sono un insieme di registri, che servono per memorizzare le<br />

informazioni; ci sono in particolare due registri che hanno un ruolo particolare: Program Counter e<br />

Instruction Register. Questi registri sono presenti all’interno di qualsiasi CPU per quanto diverse<br />

queste siano. Il PC contiene l’indirizzo della prossima istruzione da eseguire; l’IR è un registro che<br />

viene utilizzato dall’unità di controllo ogni qualvolta quest’ultima si accinge ad eseguire una nuova<br />

istruzione. L’unità di controllo legge l’istruzione dalla memoria e la deposita nell’IR.<br />

L’unità centrale e l’ALU cooperano per eseguire istruzioni; normalmente cooperano e utilizzano la<br />

memoria in un processo che possiamo distinguere in quattro fasi che porta al completamento di un<br />

ciclo macchina. Un ciclo macchina racchiude un insieme di attività che sono caratteristiche durante<br />

il processo di esecuzione dell’istruzione.<br />

Questo ciclo macchina è fatto da quattro fasi:<br />

1) la prima fase è quella in cui l’unità di controllo va a prelevare dalla memoria la prossima<br />

istruzione da eseguire; questa istruzione letta dalla memoria viene all’interno dell’unità di<br />

controllo depositata nel registro IR;<br />

2) poi avviene la decodifica dell’istruzione;<br />

3) la fase 3 è quella dell’esecuzione, dove per esempio se ci sono da fare dei conti vado ad usare la<br />

parte di ALU coinvolta per l’esecuzione di quell’istruzione;<br />

4) la fase 4 è quella di memorizzazione dei risultati, ovviamente laddove ci siano dei risultati da<br />

memorizzare.<br />

28


02/04/2004<br />

Ritorniamo al meccanismo di esecuzione e diamo qualche dettaglio in più: supponiamo che la<br />

seguente sia la nostra memoria di processo e all’interno di questa a partire da questa locazione sia<br />

memorizzato un programma che deve essere<br />

PC<br />

}<br />

} 1a<br />

2 a<br />

}<br />

3 a<br />

eseguito e supponiamo che ogni istruzione<br />

codificata in linguaggio binario di quella<br />

macchina processo in lunghezza coincida<br />

con la dimensione di una locazione di<br />

memoria. Vediamo di capire cosa avviene<br />

durante l’esecuzione. Abbiamo detto che<br />

l’unità di controllo utilizza il PC per<br />

stabilire da quale locazione di memoria<br />

andare a leggere la prossima istruzione.<br />

Questo significa che durante il ciclo<br />

macchina di esecuzione di un istruzione ci<br />

deve essere un’attività specifica che va ad aggiornare il PC. Quando viene fatto questo? Quando c’è<br />

la fase di lettura dell’istruzione e di caricamento all’interno dell’IR la CPU sa che la prossima<br />

istruzione da eseguire starà all’indirizzo successivo perché si assume che la successione in memoria<br />

delle istruzioni coincida con l’ordine di esecuzione del programma stesso; per cui se ho letto<br />

quell’indirizzo la prossima istruzione la andrò a leggere al prossimo indirizzo. Quindi tra la fase di<br />

lettura dell’istruzione e caricamento all’interno dell’IR e la fase di decodifica avviene sempre<br />

l’aggiornamento del PC: incremento di 1 del PC. Questo è vero solo se io vado sempre in sequenza,<br />

però noi sappiamo che non sempre avviene questo all’interno di un programma perché ogni tanto ci<br />

può essere qualche diramazione (branch). Come può funzionare il fatto intanto il PC si incrementa<br />

di 1 e poi magari non devo andare a leggere l’istruzione successiva, ma devo andare a un certo<br />

numero di locazioni dopo? Tutto questo è compatibile perché tutto sommato non conviene fare delle<br />

eccezioni, cioè se io ho già l’hardware implementato che ogni volta che leggo un’istruzione<br />

incrementa il PC non devo preoccuparmi di non farlo quando si verifica che l’istruzione appena<br />

letta per esempio è un branch, perché questo significa fare delle aggiunte che spesso complicano la<br />

parte realizzativa. La CPU continua a comportarsi sempre nello stesso modo, cioè incrementa il PC;<br />

però abbiamo detto che questo avviene tra la fase di lettura e quella di decodifica, poi c’è la fase di<br />

esecuzione dell’istruzione, ma nel caso in cui l’istruzione è un’istruzione di branch la fase di<br />

esecuzione è quella di testare una condizione, e se questa è vera allora devo eseguire il salto, ma<br />

eseguire il salto vuol dire andare a caricare nel PC il valore dell’indirizzo target di salto.<br />

Le cose funzionano così in tutte le macchine processo.<br />

Non necessariamente dobbiamo fare l’assunzione che l’istruzione occupi un’unica locazione di<br />

memoria; ci sono casi, ed è tipico delle architetture CISC, in cui l’istruzione non ha una lunghezza<br />

fissa: ci può essere un’istruzione che per essere codificata richiede 32 bit e un’istruzione che<br />

richiede 64 bit per esempio. Quindi immaginando che ognuna delle locazioni della memoria<br />

disegnata sopra sia di 32 bit, si può avere che la prima istruzione richieda 3 locazioni di memoria<br />

(96 bit), la seconda richieda 2 locazioni e la terza 1 locazione, ecc. In questo caso sembrerebbe che<br />

ci sia una difficoltà aggiuntiva, ovvero il PC non si deve incrementare sempre della stessa quantità<br />

per passare all’istruzione successiva in memoria. Questo come si gestisce? L’aggiornamento del PC<br />

avviene sempre nella fase di decodifica dell’istruzione; questo perché quando ho letto l’istruzione,<br />

l’ho depositata nell’IR e a partire da questo l’ho decodificata, e averla decodificata significa averla<br />

identificata, cioè ho capito che istruzione è, e quindi so anche quanto è lunga questa istruzione. Se il<br />

codice operativo è più lungo di una parola, nella prima parola deve essere contenuta l’informazione<br />

di quanto è lunga l’istruzione e quante altre locazioni si devono leggere per avere a disposizione<br />

l’intero codice operativo.<br />

29


Abbiamo visto che il linguaggio della macchina processo è un linguaggio binario; si capisce che per<br />

chi deve fare programmi nel linguaggio della macchina processo questa è una cosa scocciante.<br />

Proprio per questa ragione e per superare questa difficoltà c’è uno strato che sta immediatamente<br />

prima della macchina processo, e che quindi realizza una macchina sopra la macchina processo, che<br />

si chiama macchina assembler.<br />

La macchina assembler sostanzialmente mantiene quasi tutte le caratteristiche della macchina<br />

processo, nel senso che macchine assembler di modelli realizzativi diversi sono diverse, però<br />

rispetto alla macchina processo offre una maggiore facilità di programmazione. Questa facilità<br />

deriva dal fatto che tutte le istruzioni previste nella macchina processo vengono codificate nella<br />

macchina assembler in modo mnemonico: se nella macchina processo l’istruzione somma è 1100,<br />

per esempio, nella macchina assembler dico che quest’istruzione si chiama “somma”, cioè associo<br />

ai codici operativi un codice mnemonico, cioè ci ricorda l’operazione che fa l’istruzione e quindi<br />

rende più facilmente usabile questa istruzione. L’istruzione somma non solo contiene il codice<br />

operativo che ci dice che è un’istruzione di somma, ma contiene ovviamente degli operandi, che<br />

nella macchina processo non sono altro che un insieme di bit che possono rappresentare un<br />

indirizzo piuttosto che gli operandi veri e propri dell’istruzione, ecc. Nella macchina assembler per<br />

eliminare questa scocciatura si dà la possibilità di identificare gli operandi con dei simboli. Questo<br />

comporta che la realizzazione della macchina assembler sulla macchina processo avviene attraverso<br />

uno strato di software che è ancora una volta un traduttore; quindi significa che quando faccio un<br />

programma in assembler per generare il codice per la macchina processo devo passare attraverso un<br />

processo di traduzione, in questo caso si dice attraverso una fase di assemblaggio.<br />

Parliamo dello strato che sta ancora sotto la macchina processo, cioè la macchina hardware. Noi<br />

abbiamo detto che una macchina viene realizzata sopra un’altra macchina per effetto di un software;<br />

se diciamo che al di sotto della macchina processo c’è la macchina HW significa che la macchina<br />

processo non era l’ultima nella gerarchia, ma allora qual è lo strato software che fa sì che io realizzi<br />

la macchina processo sulla macchina HW? Lo strato software è il sistema operativo. Il sistema<br />

operativo è un programma fatto da tanti pezzi ognuno responsabile di determinate attività. Il sistema<br />

operativo più che realizzare la macchina processo sulla stessa macchina HW è in grado di realizzare<br />

tante macchine processo, ognuna con la propria memoria e il proprio PC, sulla stessa macchina<br />

HW.<br />

Da un punto di vista logico l’architettura la possiamo immaginare in questo modo: ho un<br />

programma 1 che viene eseguito dalla macchina processo 1, un programma 2 che viene eseguito<br />

dalla macchina processo 2, …, un programma N che viene eseguito dalla macchina processo N.<br />

Naturalmente questi programmi sono espressi in linguaggio binario.<br />

Le N macchine processo attraverso questo strato di software, che è il sistema operativo, vengono<br />

realizzate a partire dalla macchina hardware:<br />

30


Naturalmente se stiamo parlando di quel modello architetturale, in cui c’è la gerarchia di macchine,<br />

ogni macchina è caratterizzata da un proprio linguaggio, quindi se c’è la macchina HW ci deve<br />

essere il linguaggio della macchina HW. Anche il linguaggio della macchina HW è un linguaggio<br />

binario.<br />

Se il sistema operativo realizza N macchine processo sulla stessa macchina HW significa che un<br />

programma che viene eseguito da una macchina processo ha come visibilità il fatto che tutte le<br />

risorse sono dedicate ad un altro programma. Il programma che viene eseguito da una determinata<br />

macchina processo fa sì che questa evolva in funzione delle istruzioni del programma; ma se la<br />

macchina processo funziona così come abbiamo detto significa anche che un’istruzione di quel<br />

programma viene eseguita attraverso il meccanismo del PC; quindi se ci sono N processi significa<br />

che ci saranno N PC, e non solo perché un programma spesso fa riferimento alle operazioni di I/O,<br />

quindi da un punto di vista logico è come se ogni macchina processo fosse dotata del proprio PC, da<br />

una propria memoria di processo dove sta caricato il programma in esecuzione e poi da unità<br />

ingresso/uscita (come se avesse l’unità monitor, l’unità tastiera, ecc…):<br />

Realizzando N macchine processo sulla stessa macchina HW si sfruttano al meglio le risorse<br />

hardware, cioè le risorse disponibili nella macchina HW vengono sfruttate al meglio; questo<br />

significa che normalmente si fa in modo che non stiano ferme o inutilizzate inutilmente.<br />

Durante l’esecuzione di un programma se c’è ad un certo punto un operazione di input/output per<br />

esempio, devo congelare quel programma e attivarne un altro; però poi non ho finito perché quel<br />

programma che era stato congelato si potrebbe scongelare, ma se si scongela un programma si deve<br />

congelare un altro programma. Una cosa del genere come può avvenire e dov’è la complessità nel<br />

far avvenire questo?<br />

Una macchina HW ha un solo esecutore reale, quindi stiamo cercando di inventare la possibilità di<br />

condividere quest’esecutore fra più programmi, però deve valere una regola: se l’esecutore è<br />

dedicato ad un programma, non può essere dedicato ad un altro programma.<br />

Tutte le volte che certe istruzioni della macchina processo devono essere eseguite dalla macchina<br />

HW, si ha che la loro esecuzione è “truccata”, cioè quella che per la macchina processo è<br />

l’istruzione “leggi” in realtà la macchina HW la interpreta come un salto ad un certo indirizzo dove<br />

31


c’è un pezzo di software del sistema operativo; quindi viene congelato il programma e il sistema<br />

operativo può decidere di scongelarne un altro; ma congelare il programma vuol dire che poi perché<br />

tutto funzioni prima o poi quando lo riscongelo questo non si deve essere accorto di essere stato<br />

congelato, quindi il sistema operativo deve salvare tutto un insieme di informazioni che poi<br />

consentono di svegliare di nuovo il programma e ridargli il suo contesto (PC, registri, variabili,<br />

ecc…).<br />

Il sistema operativo è in buona parte costituito da sequenze di istruzioni che sono i servizi di<br />

sistema, che svolgono azioni richieste da un processo garantendo che l’esecuzione del servizio sia<br />

per il processo equivalente all’esecuzione di una normale istruzione (anche quando il processo<br />

viene momentaneamente sospeso).<br />

A livello di macchina HW non esiste più il principio del determinismo. Esiste il meccanismo delle<br />

interruzioni asincrone che ci danno il non determinismo.<br />

Il meccanismo delle interruzioni porta la macchina ad eseguire un salto ad un indirizzo prefissato<br />

ogni qual volta si verificano particolari eventi (battere un tasto). Si parla di interruzioni asincrone<br />

quando non sono prevedibili, cioè sono relative ad eventi esterni.<br />

Quindi esistono due meccanismi di interruzione di interruzione:<br />

• sincrona: causata da un evento interno all’esecuzione del programma e che determina un salto a<br />

un servizio di sistema;<br />

• asincrona: causata da un evento esterno alla macchina; essa interrompe il flusso di istruzioni che<br />

la macchina stava eseguendo e determina un salto a un indirizzo del sistema operativo.<br />

La macchina HW è costituita da sottosistemi funzionali collegati dal sistema di comunicazione:<br />

Ciascun sottosistema è realizzato con dei componenti elementari, le porte logiche, la cui<br />

combinazione da luogo a circuiti più complessi, quali reti combinatorie e sequenziali. Non si può<br />

comunque affermare che i livelli inferiori alla macchina HW sono certamente hw mentre i livelli<br />

superiori sono certamente software.<br />

32


06/04/2004<br />

Noi non studieremo un intero sistema di elaborazione, ma l’attenzione sarà focalizzata soltanto su<br />

un blocco principale che è il processore, la CPU. Quale CPU scegliere come caso di studio? Non si<br />

fa riferimento ad un’architettura commerciale, ma si utilizza un’architettura finta: DLX. Questo<br />

processore è simile a molte architetture commerciali.<br />

Il processore DLX è pronunciato delux, e la sigla deriva da: (AMD 29K, DECstation 3100, HP 850,<br />

IBM 801, Intel i860, MIPS M/120A, MIPS M/1000, Motorola 88K, RISC I, SGI 4D/60,<br />

SPARCstation-1, Sun-4/110, Sun-4/260)/13 = 560 = DLX.<br />

Il DLX è un’architettura RISC, quindi semplicissimo dal punto di vista hardware; una cosa che<br />

caratterizza le architetture RISC è che sono macchine Load/Store, che significa che poiché il set di<br />

istruzioni deve essere molto limitato le operazioni possono essere eseguite soltanto tra operandi<br />

contenuti all’interno del processore (nei registri).<br />

Il DLX ha due banchi di registri: un banco di registri lo chiameremo da ora in poi GPR (generalpurpose<br />

registers) e l’altro lo chiameremo FPR (floating-point registers). Entrambi i banchi di<br />

registri sono formati da 32 registri. I registri hanno tutti la stessa dimensione e questa dimensione è<br />

di 32 bit; si dice quindi che questo processore ha un parallelismo di 32 bit, cioè le operazioni<br />

vengono eseguite su numeri codificati con 32 bit. La memoria è indirizzabile a byte, il suo<br />

ordinamento è di tipo Big Endian, e gli indirizzi sono a 32 bit, cioè abbiamo uno spazio di<br />

indirizzamento di 32 bit.<br />

Concentriamo la nostra attenzione sui registri GPR.<br />

I registri GPR contengono gli operandi delle istruzioni, cioè le istruzioni del set di istruzioni di<br />

questo processore operano su dati contenuti nei registri. Il DLX può eseguire sia calcoli con numeri<br />

interi che calcoli con numeri reali. GPR è il banco dei registri che contiene gli operandi interi,<br />

mentre FPR è quello che contiene gli operandi reali.<br />

Come abbiamo detto il banco GPR contiene 32 registri di 32 bit, e ogni registro verrà identificato da<br />

una lettera seguito da un indice: R0, R1, …, R31. Abbiamo istruzioni del DLX che consentono di<br />

modificare il contenuto di qualsiasi registro ad esclusione del registro R0, che è un registro che può<br />

essere soltanto letto e contiene il valore 0.<br />

Vediamo a cosa può essere utile il registro R0:<br />

supponiamo di voler inizializzare un registro con un valore; se io ho un’istruzione di somma si può<br />

evitare di usare un’istruzione di “move”, cioè di spostamento di una costante in un registro? La<br />

risposta è no. Supponiamo di voler inizializzare registro R1 con il valore 7: R1 ← 7. Se non<br />

conoscessi l’assembly potrei pensare che ci sia un’istruzione del tipo: MOVE R1, 7, cioè<br />

un’istruzione in cui specifico un registro di destinazione e una costante numerica. (Molti processori<br />

hanno questa istruzione). Hennessy e Patterson hanno detto che questa istruzione non è<br />

indispensabile perché può essere simulata con un’altra istruzione: ADDI Rd, Rs, immediate;<br />

questa istruzione somma il contenuto del registro Rs con la costante immediate e il risultato lo<br />

deposita nel registro Rd. Quindi se io voglio scrivere 7 nel registro R1 posso fare così: ADDI R1,<br />

R0, 7. Un registro speciale è anche il registro R31: esistono delle istruzioni di jump and link, e il<br />

registro R31 serve per ricordare l’indirizzo a cui ritornare dopo queste istruzioni.<br />

I bit di ogni registro li indicheremo in questo modo: il bit più a sinistra sarà il bit 0 e quello più a<br />

destra sarà il bit 31. I 32 bit li posso partizionare in 4 byte e anche questi sono ordinati nello stesso<br />

modo dei bit:<br />

Le istruzioni ALU sono le istruzioni che coinvolgono l’unità logico-aritmetica, ed operano<br />

sull’intero registro, cioè sui 32 bit; non ci sono istruzioni ALU che operano su parti di registro.<br />

Le istruzioni Load/Store sono le istruzioni di comunicazione con la memoria e possono operare sia<br />

sull’intero registro (word), sia sul mezzo registro (half word) e sia su un byte:<br />

33


Le istruzioni load/store su mezza parola o su un byte utilizzeranno sempre i byte meno significativi.<br />

Passiamo ai registri FPR.<br />

Il DLX contiene istruzioni per effettuare calcoli in virgola mobile sia in precisione singola (32 bit),<br />

sia in precisione doppia (64 bit). Ma se abbiamo registri a 32 bit come facciamo a memorizzare<br />

operandi a 64 bit? Le istruzioni che operano su operandi a 64 bit considerano i registri come<br />

appaiati l’uno con l’altro: cioè un banco di 32 registri a 32 bit lo posso anche vedere come un banco<br />

di 16 registri a 64 bit; dico che F0 ed F1 li considero uniti e formano un registro a 64 bit che<br />

chiamerò F0<br />

Esistono istruzioni che operano con operandi a 64 bit. Per esempio:<br />

ADDD F0, F8, F12<br />

F8 F9<br />

(F0 è un registro come gli altri e non contiene lo 0 come R0). In questo caso F0 = F0 + F1, F8 = F8<br />

+ F9, F12 = F12 + F13.<br />

Oltre ai registri considerati abbiamo anche altri registri speciali che non possiamo manipolare<br />

direttamente:<br />

• PC (Program Counter), è un registro che contiene l’indirizzo della prossima istruzione da<br />

eseguire; non abbiamo istruzioni che lo modificano esplicitamente, però esistono tante altre<br />

istruzioni, tipicamente le istruzioni di salto, che lo modificano;<br />

• IAR (Interrupt Address Registrer);<br />

• FPSR (Floating-Point Status Register).<br />

Come vengono ordinati i byte in memoria?<br />

Il DLX ordina i byte in memoria in un modo che si chiama Big Endian. Tipicamente i dati possono<br />

essere ordinati in due modi: una modalità si chiama Big Endian e un’altra modalità si chiama Little<br />

Endian. Supponiamo di voler scrivere all’indirizzo 0 della memoria il numero in esadecimale<br />

0×AABBCCDD, e consideriamo che quest’ultima sia divisa in byte:<br />

34


Nel DLX la memoria è indirizzata al byte, cioè la più piccola quantità che posso indirizzare è il<br />

byte. Il byte lo posso scrivere a qualsiasi indirizzo della memoria. Invece ho limitazioni per la<br />

scrittura delle half-word e per la scrittura delle word. Una half-word non la posso scrivere a partire<br />

da un indirizzo qualsiasi della memoria, ma le posso scrivere soltanto a partire da indirizzi pari. Per<br />

esempio consideriamo la seguente istruzione: store half-word<br />

SH indp, R5 (indp indica l’indirizzo di memoria in cui voglio scrivere, R5 è il registro che<br />

contiene il dato che voglio scrivere in memoria). L’indirizzo deve essere multiplo di 2. Si può<br />

scrivere SH 31, R5, però il contenuto di R5 verrà memorizzato a partire dalla locazione 30. Quindi<br />

l’indirizzo reale a partire dal quale vengono scritti i 16 bit è:<br />

indr = indp & 0×FFFFFFFE<br />

cioè viene fatto un and logico tra l’indizzo che dà il programmatore e il numero 0×FFFFFFFE.<br />

Facendo così approssimiamo al numero pari immediatamente precedente (se è dispari).<br />

Ragionamento analogo avviene per le store word (SW) che possono essere memorizzate a partire da<br />

un indirizzo multiplo di 4: per fare questo si fa così ⇒ indr = indp & 0×FFFFFFFC.<br />

L’instruction set del DLX conta 92 istruzioni divise in 6 classi:<br />

• Load & Store instructions<br />

• Move instructions<br />

• Arithmetic and logical instructions<br />

• Floating-point instructions<br />

• Jump & branch instructions<br />

• Special instructions<br />

Un’altra caratteristica delle macchine RISC è che il formato delle istruzioni, nella maggior parte dei<br />

casi, ha un formato delle istruzioni fisso. Nel DLX il formato delle istruzioni è di 32 bit.<br />

Abbiamo tre tipi di istruzioni:<br />

• I-type (Immediate)<br />

• R-type (Register)<br />

• J-type (Jamp)<br />

I-type Instruction<br />

I primi 6 bit rappresentano il codice operativo dell’istruzione, e questo vale per tutti e tre i tipi di<br />

istruzioni. Poi abbiamo 5 bit che codificano l’indirizzo sorgente e 5 bit per il registro destinazione;<br />

gli ultimi 16 bit codificano l’immediate.<br />

Alle istruzioni di tipo I appartengono tutte le istruzioni che ammettono come operando un<br />

immediate (immediato, costante numerica).<br />

Consideriamo l’istruzione: AND R1, R2, R3, che vuol dire fare un and logico tra R2 ed R3 e<br />

memorizzare il risultato in R1 ⇒ R1 ← R2 & R3; un’istruzione di questo tipo non è un’istruzione<br />

di tipo I, perché nessuno dei due operandi è una costante. Le istruzioni di tipo I sono istruzioni del<br />

tipo: ANDI R1, R2, 94 oppure ANDI R1, R2, 423000. Quest’ultimo esempio non lo posso tradurre<br />

in un’istruzione perché 423000 non posso codificarlo in 16 bit.<br />

35


Esempi<br />

addi r1, r2, 5 ; r1 = r2 + signext(5), rd = r1 e rs1 = r2, immediate = 0000000000000101<br />

5 è un numero a 16 bit, mentre r2 è un numero a 32 bit, quindi dentro al processore ci sarà un<br />

sommatore i cui ingressi saranno a 32 bit; allora verrà esteso in segno il numero a 16 bit:<br />

16<br />

immediate<br />

sign<br />

Ext<br />

addi r1, r2, -5 ; r1 = r2 + signext(-5), rd = r1 e rs1 = r2, immediate = 1111111111111011.<br />

32<br />

32<br />

+<br />

36


13/04/2004<br />

R-type Instruction<br />

Nelle istruzioni di tipo R entrambi gli operandi si trovano all’interno di un registro. Il formato delle<br />

istruzioni di tipo R è il seguente:<br />

Abbiamo 6 bit che codificano il codice operativo dell’istruzione, 5 bit che codificano<br />

rispettivamente i registri sorgente e il registro destinazione, 5 bit che non vengono utilizzati, e gli<br />

ultimi 6 bit rappresentano la funzione. Questo significa che le istruzioni di tipo R, che sono per<br />

esempio le istruzioni di tipo somma, sottrazione, le operazioni logiche che avvengono sui registri,<br />

sono caratterizzate da un unico codice operativo, cioè il codice operativo dell’add, per esempio, è<br />

uguale al codice operativo della sub, così come è uguale a quello della and, e così via. Per<br />

discriminare l’una dall’altra si utilizzano gli ultimi 6 bit dell’istruzione.<br />

Esistono due tipi di istruzioni di tipo R:<br />

1. Istruzioni di tipo R che operano sui registri GPR<br />

2. Istruzioni di tipo R che operano sui registri FPR<br />

Queste sono caratterizzate da un codice operativo diverso. Nel caso delle istruzioni di tipo R che<br />

operano sui registri FPR solo 5 bit sono utilizzati per discriminare tra le varie funzioni.<br />

J-type Instruction<br />

Il formato delle istruzioni di tipo J è il seguente:<br />

Abbiamo che 6 bit codificano il tipo di istruzione: jump (J), jump & link (JAL), TRAP, ecc…; i<br />

restanti 26 bit individuano una sorta di spiazzamento, che è l’indirizzo target del salto. Per esempio<br />

un’istruzione del tipo “J target”, dove target è una costante numerica, non fa altro che modificare il<br />

PC di una quantità pari a target, cioè deve spostarsi della quantità target, avanti o indietro (se target<br />

è negativo), rispetto a dove si trova in quel momento: PC = PC + sigext(target).<br />

Vediamo alcune istruzioni dell’instruction set.<br />

Load & Store Instruction<br />

Abbiamo due categorie di istruzioni Load/Store:<br />

1. Load/Store che operano sui registri GPR<br />

2. Load/Store che operano sui registri FPR<br />

Tutte le istruzioni load/store appartengono alla classe delle istruzioni di tipo I.<br />

Sia per una load che per una store io devo indirizzare la memoria, e l’indirizzo viene calcolato in<br />

questo modo: viene sommato al contenuto dell’indirizzo sorgente l’immediate ⇒<br />

effective_address = (rs) + sigext(immediate).<br />

Abbiamo in tutto 5 tipi di load e 3 tipi di store:<br />

• LB (load byte), LBU (load byte unsigned), LH (load half-word), LHU (load half-word<br />

unsigned), LW (load word). Per le load che agisco sui byte o sulle half-word dobbiamo<br />

considerare che questi devono essere scritti nei registri che però sono a 32 bit:<br />

Reg<br />

0 0 0<br />

32<br />

8<br />

37


questi 8 bit vengono scritti nella parte meno significativa del registro. Con la LBU i rimanenti<br />

bit vengono scritti con 0, mentre con la LB vengono settati col bit più significativo degli otto bit<br />

che prelevo dalla memoria. Lo stesso vale per le LH e LHU.<br />

• SB (store byte), SH (store half-word), SW (store word): si preleva dal registro un byte, una halfword<br />

o una word e si scrive in memoria.<br />

Il formato delle istruzioni di load e store è il seguente:<br />

LB/LBU/LH/LHU/LW rd, immediate(rs1)<br />

SB/SH/SW immediate(rs1), rd<br />

Per le load il primo parametro identifica il registro di destinazione, e il secondo parametro identifica<br />

l’indirizzo della memoria.<br />

Esempio: LW R7, 54(R0) Questa istruzione accede all’indirizzo di memoria 54 + R0 (ovvero 0),<br />

preleva una word a quest’indirizzo e la memorizza in R7.<br />

Le store memorizzano all’indirizzo immediate(rs1) della memoria il contenuto del registro passato<br />

come secondo parametro (rd).<br />

Esempio: SW 4(R2), R7 Se R2 vale 20 ed R7 vale 10, questa istruzione va all’indirizzo 20+4=24<br />

in memoria, e a partire da questo scrive la word 10.<br />

Esempio di Store Byte<br />

sb 5(r1), r2<br />

supponiamo che r1=9 ed r2=ff. Quello che avviene è la seguente cosa:<br />

Esempio di Load Byte e Load Byte Unsigned<br />

lb r3, 5(r1)<br />

supponiamo che r1=9. Quello che accade è la seguente cosa:<br />

Move Instructions<br />

Le istruzioni move sono istruzioni di tipo R, e servono a trasferire il contenuto di un registro in un<br />

altro registro entrambi appartenenti allo stesso banco dei registri; oppure sono istruzioni che<br />

permettono di trasferire il contenuto di un registro FPR in un registro GPR o viceversa.<br />

• movi2s, movs2i: GPR ↔ IAR<br />

movi2s rd, rs1 ; rd ∈ RS, rs1∈ IAR<br />

movs2i rd, rs1 ; rd ∈ GPR, rs1∈ SR<br />

38


• movf, movd: FPR ↔ FPR<br />

movf rd, rs1 ; rd, rs1∈ FPR<br />

movd rd, rs1 ; rd, rs1∈ FPR even-numbered<br />

per esempio “movf f0, f4” non fa altro che copiare il contenuto del registro f4 nel registro f0:<br />

f0<br />

f1<br />

f2<br />

f3<br />

f4<br />

f5<br />

.<br />

f31<br />

“movd f0, f4” copia i registri f4-f5 nei registri f0-f1<br />

f0<br />

f1<br />

f2<br />

f3<br />

f4<br />

f5<br />

. .<br />

f31<br />

• movfp2i, movi2fp: GPR ↔ FPR<br />

movfp2i rd, rs1 ; rd ∈ GPR, rs1∈ FPR<br />

movi2fp rd, rs1 ; rd ∈ FPR, rs1∈ GPR<br />

. .<br />

. .<br />

per esempio “movi2fp f4, r9” prende il contenuto del registro r9 e lo copia nel registro f4;<br />

questa è una copia bit a bit e non una conversione; quindi per esempio se r9 contiene il numero<br />

72 dentro f4 ho la codifica binaria di 72 e non ho 72 espresso in floating-point.<br />

Arithmetic and Logical Instructions<br />

Abbiamo 4 categorie che ricadono nella classe delle istruzioni aritmetico-logiche:<br />

• istruzioni aritmetiche;<br />

• istruzioni logiche;<br />

• istruzioni di shift dei dati (spostamento dei dati);<br />

• istruzioni di confronto.<br />

Di ogni istruzione abbiamo sia la versione di tipo R che la versione di tipo I.<br />

add, sub: somma e sottrazione. Il formato, ad esempio per la somma, è: add r1, r2, r3. Quello<br />

che fa è sommare i contenuti di r2 ed r3 e il risultato lo mette in r1. Il contenuto dei registri<br />

sorgente è considerato come rappresentato in complemento a due.<br />

Di queste due esistono anche le versioni unsigned.<br />

addu, subu. I contenuti dei registri è visto come numeri senza segno, ovvero è visto come<br />

numero binario naturale puro, e non in complemento a due. Il formato è come quello di prima:<br />

addu r1, r2, r3.<br />

39


Per tutte e quattro le istruzioni sopra abbiamo la versione di tipo I:<br />

addi, subi, addui, subui. Per esempio: addi r1, r2, #17.<br />

Abbiamo anche le istruzioni di moltiplicazione e divisione:<br />

mult, multu, div, divu. Queste operano soltanto su registri di tipo FPR. Il formato è il seguente:<br />

mult f1, f2, f3.<br />

Istruzioni logiche<br />

Le istruzioni logiche sono delle istruzioni che operano a livello di bit.<br />

and, or, xor. Il formato è il seguente: and r1, r2, r3, dove r1 è il registro destinazione ed r2 e r3<br />

sono i registri sorgente.<br />

Di queste abbiamo anche la versione di tipo I.<br />

andi, ori, xori. Il formato è: andi r1, r2, #16, per esempio.<br />

Un’altra istruzione che appartiene a questa classe è l’istruzione<br />

LHI (load high immediate). In questo caso load non si riferisce alla memoria. È un’istruzione di<br />

tipo I. Quando abbiamo visto le istruzioni di tipo I abbiamo detto che l’immediate è un numero<br />

a 16 bit; come facciamo a caricare in un registro un numero più grande di 16 bit? Per caricare un<br />

immediate lungo in un registro si utilizza questa istruzione. Il formato è: lhi r1, 0×ff00, dove r1<br />

è il registro destinazione. L’immediate viene caricato non nella parte meno significativa di r1,<br />

ma nella parte più significativa. Quindi se io scrivo lhi r1, 0×AABB, questa farà la seguente<br />

cosa:<br />

AA BB 00 00 R1<br />

Quindi se io volessi caricare nel registro R1 il numero 0×AABBCCDD basterebbe prima<br />

caricare la parte alta del numero nella parte alta del registro attraverso la lhi e poi fare:<br />

addui r1, r1, 0×CCDD, oppure ori r1, r1, 0×CCDD<br />

AA BB 00 00 R1<br />

OR<br />

00 00 CC DD<br />

AA BB CC DD<br />

Istruzioni di shift<br />

Queste istruzioni fanno scorrere il contenuto di un registro; questo può essere fatto scorrere o a<br />

destra o a sinistra. Abbiamo tre tipi di shift:<br />

sll (shift left logico), srl (shift right logico), sra (shift right aritmetico).<br />

Il formato è: sll r1, r2, r3, dove r1 è il registro destinazione, r2 è il registro sorgente, e r3 è la<br />

quantità di bit da far scorrere.<br />

Abbiamo anche la versione con immediate:<br />

slli, srli, srai. Il formato è: slli r1, r2, #3. A seguito di questa istruzione accade:<br />

R2 0 …………… 0 0 1 0 1<br />

tutti i bit vengono spostati di tre posti a sinistra, e i tre<br />

bit lasciati vuoti vengono settati a 0.<br />

R1<br />

……… 0 0 1 0 1 0 0 0<br />

Per lo shift a destra è lo stesso: se ho il numero 1011 e faccio lo shift di una posizione a destra<br />

srl(1)<br />

ottengo ⇒ 1011 → 0101. Anche in questo caso i bit lasciati vuoti vengono settati a 0.<br />

40


Se io ho un numero e ne faccio lo shift di n posti a sinistra, è come se moltiplicassi quel numero<br />

per 2 n . Analogamente se io faccio lo shift di un numero di n posti a destra è come dividere il<br />

numero per 2 n ; questo vale se il numero è rappresentato in binario naturale.<br />

Nello shift aritmetico la posizione che viene liberata verrà inizializzata col bit che ha<br />

sra(1)<br />

abbandonato quella posizione: 1011 → 1101.<br />

Se il numero è rappresentato in complemento a due con questa istruzione è come dividere il<br />

numero per una potenza di 2:<br />

1 0 1 1 -5<br />

0 1 0 1 5<br />

1 1 0 1 -3<br />

srl(1)<br />

sra(1)<br />

Istruzioni di confronto<br />

slt (set less than), sgt (set greater than), sle (set less equal), sge (set greater equal), seq (set<br />

equal), sne (set not equal).<br />

slt r1, r2, r3 ; (r2


Il formato è il seguente: ltf f0, f1. Riportiamo soltanto gli operandi da confrontare; il risultato di<br />

questa operazione viene memorizzato implicitamente in un registro ad un solo bit, ed è il registro<br />

FPSR (float-point status register): (f0


.float f1, f2, …, fn Con questa alloco float<br />

.double d1, d2, …, dn Con questa alloco double<br />

C’è una direttiva che forza l’allineamento dei dati ad indirizzi di memoria multipli di una certa base:<br />

.align Esempio:<br />

.data 100<br />

.byte 0×ff<br />

.align 2 ; allinea ad un indirizzo multiplo di 2 2<br />

.word 0×aabbccdd<br />

Quello che avviene in memoria è:<br />

.ascii Memorizza la stringa in memoria. Per esempio:<br />

.data 100<br />

.ascii “Hello!”<br />

Quello che accade è:<br />

.asciiz Memorizza la stringa in memoria e pone l’ultimo byte a 0. Per esempio:<br />

.data 100<br />

.ascii “Hello!”<br />

Quello che accade è:<br />

.space Riserva n byte in memoria senza inizializzarli. Per esempio:<br />

.data 100<br />

.space 5<br />

.byte 0×ff<br />

Quello che accade è:<br />

43


15/04/2004<br />

Nella seguente figura è illustrato quello che è chiamato datapath del processore, la via dei dati:<br />

Questo include tutto il percorso che i dati all’interno del processore possono seguire. Quando parlo<br />

di dati è in senso lato, cioè le istruzioni che vengono lette per il processore di fatto sono dei dati da<br />

manipolare opportunamente. Gli elementi che vengono rappresentati sono: tutti gli elementi che<br />

sono in grado di memorizzare informazioni durante questo percorso, e alcuni elementi che<br />

manipolano i dati. Abbiamo due elementi ALU; i rettangoli sono degli elementi di memoria dove<br />

possono essere memorizzate delle informazioni, e gli ovali con la scritta MUX sono dei multiplexer.<br />

In questo disegno non è rappresentata la parte di controllo del processore.<br />

Vediamo passo passo come viene letta, decodificata ed eseguita ognuna delle istruzioni del DLX<br />

che utilizzano questo datapath. In realtà dobbiamo vederla al contrario: noi abbiamo definito il set<br />

di istruzioni del DLX, questo datapath nasce dopo aver definito questo set di istruzioni.<br />

Partiamo dalla prima fase (figura a<br />

sinistra): la prima cosa di cui ci<br />

occupiamo è la lettura dell’istruzione.<br />

C’è un PC che indirizza la memoria,<br />

l’informazione letta dalla memoria<br />

viene prelevata e va a finire all’interno<br />

del processore e in particolare va a<br />

finire nel registro IR. (I collegamenti<br />

fra i vari blocchi dobbiamo<br />

immaginare che siano tante linee<br />

quanti sono i bit del PC).<br />

È da precisare che la Instruction<br />

Memory e la Data Memory<br />

normalmente non stanno all’interno<br />

del processore.<br />

Si vede che l’uscita del PC va ad<br />

blocco ALU, che in questo caso viene<br />

utilizzato solo come un sommatore;<br />

nell’altro input dell’ALU c’è il<br />

numero 4; quindi viene fatta la somma<br />

del contenuto del PC e il numero 4.<br />

Viene considerato 4 perché ogni<br />

44


istruzione occupa 4 byte, e quindi poi si punterà alla prossima istruzione. In parallelo alla lettura<br />

dell’istruzione il sommatore entra in<br />

azione e produce la somma tra il<br />

valore del PC e 4; questo valore<br />

calcolato viene memorizzato in un<br />

registro che si chiama NPC (new<br />

PC).<br />

Questa parte appena descritta si<br />

chiama Instruction fetch: le<br />

operazioni sopra descritte sono<br />

quelle relative al fetch<br />

dell’istruzione, cioè alla lettura<br />

dell’istruzione dalla memoria e alla<br />

memorizzazione al proprio interno<br />

con l’aggiornamento del PC.<br />

Adesso andiamo nella fase di Instruction decode/register fetch. All’interno di questa parte di<br />

architettura che è quella preposta ad eseguire l’operazione di decodifica c’è il banco dei registri del<br />

DLX. La decodifica avviene in una parte non rappresentata qui, che è la logica di controllo. La<br />

decodifica presuppone che il codice operativo contenuto nell’IR venga decodificato e come risultato<br />

di questa decodifica vengono prodotti i segnali di controllo che abilitano alcune parti del processore<br />

a fare alcune cose. La cosa interessante è che mentre faccio la decodifica io posso in parallelo<br />

quello che viene chiamato register<br />

fetch: è quello che riguarda la lettura<br />

di quei registri (operandi sorgente)<br />

all’interno del banco dei registri per<br />

potere utilizzare questi valori durante<br />

l’esecuzione dell’istruzione. Se<br />

ancora non è avvenuta la decodifica<br />

dell’istruzione come faccio a leggere<br />

gli operandi sorgenti? Questo è<br />

possibile nel caso del DLX per un<br />

motivo molto semplice: la codifica<br />

dell’istruzione nel DLX è tale che se<br />

ci dovessero essere degli operandi<br />

sorgenti questi sicuramente saranno<br />

codificati in un campo di bit che<br />

sono sempre gli stessi (dal bit 6 al<br />

10, e da 11 a 15). Si fa questo perché<br />

un pezzo del processore sta facendo<br />

la decodifica e nel frattempo nel caso<br />

in cui dovessero servire quei registri<br />

io li prelevo; nel caso non servono<br />

45


non vengono utilizzati. Lo stesso non si può dire delle scritture, perché se io scrivo su un registro<br />

perdo il vecchio contenuto che non è più recuperabile.<br />

I valori letti dai potenziali registri sorgenti vengono memorizzati nei due registri A e B, che non<br />

appartengono al banco dei registri accessibili all’utente; in particolare in A viene messo il valore del<br />

registro sorgente codificato nei bit da 6 a 10, e in B quello del registro sorgente codificato nei bit da<br />

11 a 15. Se si dovesse trattare di un’istruzione in cui devo utilizzare un’immediate, questo sta nei bit<br />

da 16 a 31 del registro IR, lo prelevo,<br />

lo estendo in segno (da 16 bit a 32<br />

bit) e questi 32 bit vengono<br />

memorizzati all’interno del registro<br />

Imm (immediate); anche questo è un<br />

registro che l’utente non vede, ma<br />

serve solo per eseguire le istruzioni.<br />

Quindi in questa fase ho prodotto tre<br />

informazioni: registro A, registro B e<br />

Immediate. Queste tre informazioni<br />

potenzialmente, qualcuna di queste o<br />

nessuna di queste, potrebbero essere<br />

utilizzate nella fase successiva.<br />

Finita questa fase si passa alla fase<br />

che si chiama Execution/effective<br />

address calculation.<br />

L’istruzione appena decodificata può<br />

essere: un branch, un’istruzione<br />

ALU (register-register ALU<br />

instruction, oppure un’istruzione con<br />

operando immediato), oppure<br />

un’istruzione di load/store. A<br />

seconda di quale tipologia di<br />

istruzione è stata decodificata, nella fase di execution verranno effettuate alcune operazioni.<br />

Vediamo per ognuna di queste quale<br />

parte di architettura viene coinvolta.<br />

Branch:<br />

dobbiamo calcolare se la condizione<br />

è vera o falsa e calcolare l’indirizzo<br />

di salto. Nelle istruzioni di branch il<br />

salto è indicato col displacement:<br />

all’indirizzo a cui punta il PC, che è<br />

quello dell’istruzione seguente,<br />

bisogna sommare il displacement, lo<br />

spiazzamento che mi va ad<br />

individuare l’istruzione target del<br />

salto.<br />

La prima operazione eseguita è la<br />

verifica che il registro A valga 0 o se<br />

sia diverso da 0 e questo lo si fa<br />

attraverso un comparatore. Il<br />

risultato di questa verifica viene<br />

posto in un registro che si chiama<br />

Cond, ed è un registro ad un solo bit.<br />

46


In parallelo viene calcolato<br />

l’indirizzo target del salto sommando<br />

l’immediate al NPC. Il risultato della<br />

somma viene messo all’interno di un<br />

registro, anche questo un registro di<br />

lavoro, che si chiama ALUOutput.<br />

Quindi se si tratta di un’istruzione di<br />

branch alla fine si producono questi<br />

due risultati: l’indirizzo del salto e la<br />

condizione, che a seconda che sia<br />

vera o falsa farà fare il salto oppure<br />

no.<br />

Register-register ALU instruction:<br />

in A e in B ho i due operandi sorgenti. Nel caso di una istruzione di questo tipo Func è codificata<br />

nella parte finale dell’IR (bit da 21 a<br />

31); questo campo decodificato<br />

durante la fase di decodifica non fa<br />

altro che andare a dire all’ALU quale<br />

operazione effettuare. Alla fine il<br />

risultato di queste operazioni viene<br />

memorizzato dentro il registro<br />

ALUOutput.<br />

I-type instruction:<br />

in questo caso vengono considerati i registri A e Imm, e viene effettuata un’operazione tra questi.<br />

L’operazione da fare al solito è codificata nel codice operativo, e attraverso la decodifica viene<br />

generato un opportuno segnale di controllo che fa sì che l’ALU effettui questa operazione.<br />

47


Al solito il risultato viene<br />

memorizzato nel registro<br />

ALUOutput.<br />

Nel caso di un’istruzione di load o di<br />

store devo utilizzare l’ALU per<br />

calcolare l’indirizzo di memoria a cui<br />

devo accedere. In questo caso ad<br />

ALUOutput assegno la somma tra A<br />

ed Imm:<br />

ALUOutput A + Imm.<br />

Ognuna di queste fasi che si stanno descrivendo avviene in un ciclo di clock. Quindi finora sono<br />

trascorsi tre cicli di clock. Nel quarto ciclo di clock viene coinvolta la parte di architettura che<br />

riguarda il Memory access. In questa fase viene eseguita la fase di load o di store se si tratta di<br />

un’operazione di load o di store, mentre se si tratta di un’operazione I-type o R-type in questa fase,<br />

quindi durante questo ciclo di clock, non si deve fare niente. Se l’istruzione era un branch in Cond<br />

avevamo la condizione, e in ALUOutput avevamo l’indirizzo target del salto. Supponendo che si<br />

tratti di un branch vediamo cosa succede: se la condizione è vera dobbiamo aggiornare il PC col<br />

valore che c’è in ALUOutput; se la<br />

condizione è falsa il PC viene<br />

aggiornato col contenuto di NPC.<br />

48


Se si tratta di un’operazione di load<br />

(nel caso di load/store ALUOutput<br />

contiene l’indirizzo di memoria su<br />

cui fare o la load o la store)<br />

ALUOutput indirizza la Data<br />

memory, il contenuto della locazione<br />

indirizzata viene letto e memorizzato<br />

in un registro temporaneo LMD<br />

(load memory data).<br />

Se si tratta di un’operazione di store<br />

l’operando che deve essere scritto in<br />

memoria si troverà sicuramente nel<br />

registro B, e l’indirizzo dove<br />

memorizzare questo operando si<br />

trova in ALUOutput.<br />

Nel caso di una load o di una store in<br />

ogni caso il PC viene aggiornato col<br />

valore del NPC. Il PC viene<br />

aggiornato al quarto ciclo di clock<br />

perché l’esito del brach per come è<br />

fatto questo datapath lo sappiamo<br />

solo alla fine della fase di execution.<br />

Questa che stiamo descrivendo si<br />

chiama versione sequenziale del<br />

DLX.<br />

Al quinto ciclo di clock che cosa rimane da fare?<br />

Se ho un’istruzione di branch questa viene completata nel quarto ciclo di clock. In tutti gli altri casi<br />

il risultato va a finire in un registro. La fase di memorizzazione del risultato se si tratta di<br />

un’istruzione ALU va eseguita andando ad individuare qual è il registro di destinazione e andandovi<br />

a copiare il risultato che sta in ALUOutput. Se invece non è un’istruzione ALU ma un’istruzione di<br />

load il dato che ho letto sta in LMD:<br />

49


questa è quella che si chiama fase di<br />

Write back.<br />

Il registro di destinazione viene<br />

individuato grazie al fatto che in IR è<br />

ancora presente l’istruzione, e questa<br />

contiene il registro destinazione.<br />

Questo è come è fatto un processore sequenziale RISC. Se non fosse RISC più o meno le parti sono<br />

simili, e quello che è molto più complicato è la parte di controllo, perché tipicamente i processori<br />

CISC non hanno un formato delle istruzioni fisso, quindi c’è una quantità di opzioni elevata, e tutte<br />

queste opzioni devono essere tenute in conto nella logica di controllo.<br />

Quella che abbiamo visto viene chiamata implementazione multiciclo del DLX. Multiciclo perché<br />

le varie operazioni sulla stessa istruzione avvengono in un certo numero di cicli. In particolare<br />

normalmente ogni istruzione viene eseguita in cinque cicli di clock. Questo è vero tranne che per<br />

due istruzioni: branch e store finiscono in quattro cicli di clock.<br />

Dato questo tipo di implementazione del DLX e noto che è il set di istruzioni del DLX, se io volessi<br />

calcolare il CPI medio per l’esecuzione di un programma, probabilmente questo CPI è prossimo a 5.<br />

Se per esempio avessi un programma in esecuzione e scopro che c’è un 12% di istruzioni di branch,<br />

naturalmente il CPI non sarà pari a 5, ma sarà CPI = 4*12/100 + 5*88/100 = 4.88. Naturalmente se<br />

questo programma presentasse il 15% di store il CPI medio sarebbe:<br />

CPI = 0.15*4 + 0.12*4 + 0.73*5 = 4.73.<br />

Ci sono delle soluzione che fanno impiegare meno tempo per eseguire le istruzioni del DLX?<br />

Ovvero si può riorganizzare il datapath in modo diverso per guadagnarci in termini di velocità?<br />

La risposta è che si possono fare diverse cose. Una delle cose che si può fare è: abbiamo visto che<br />

nel caso di istruzioni ALU (sia R-type che I-type), il quarto ciclo di clock, ovvero la fase di MEM,<br />

dove normalmente avviene una load o una store, per questo tipo di istruzioni è una fase<br />

assolutamente inutile perché non viene svolta alcuna operazione. Una modifica che si potrebbe fare<br />

all’architettura vista prima è quella che se si sa che è un’istruzione ALU la fase di write back la si fa<br />

al quarto ciclo di clock. In tal caso per esempio se di istruzioni ALU ne abbiamo il 44% otteniamo<br />

un CPI pari a: CPI = 4.44.<br />

Altri miglioramenti che si possono apportare sono dal punto di vista hardware. Abbiamo visto che<br />

nel datapath del DLX sequenziale ci sono due ALU: nell’instruction fetch e nella fase di execute.<br />

Questi due ALU possono essere unificati perché sono utilizzati in periodi di tempo distinti. Se viene<br />

fatta questa modifica i multiplexer davanti all’ALU dovrebbero permettere un ingresso in più.<br />

Un’altra cosa che si potrebbe fare è quella di unificare la memoria. Noi abbiamo utilizzato due<br />

50


memorie: Instruction memory e Data memory. Risparmiamo perché c’è un unico bus di indirizzi e<br />

un solo bus di dati che partono dal processore; quindi risparmiamo hardware.<br />

Tutte queste cose che si potrebbero fare in realtà non le facciamo, perché passare dalla versione che<br />

abbiamo alla versione pipeline è estremamente semplice.<br />

Pipeline<br />

Immaginiamo di avere una<br />

lavanderia organizzata in tre fasi:<br />

lavaggio, asciugatura e stiratura.<br />

Supponiamo di avere quattro<br />

utenti che richiedono questo<br />

servizio, e supponiamo che la fase<br />

di lavaggio duri 30 minuti, la fase<br />

di asciugatura duri 40 minuti e la<br />

fase di stiratura duri 20 minuti.<br />

Vediamo cosa succede come<br />

tempo di smaltimento degli utenti<br />

se lavoriamo in modo<br />

sequenziale.<br />

Ogni utente può entrare solo<br />

quando quello prima di lui è uscito, cioè quando ha eseguito tutte e tre le fasi. Per avere che l’utente<br />

D esca bisogna aspettare un tempo pari a: 30+40+20+30+40+20+30+40+20+30+40+20= 6 ore.<br />

Nella versione pipeline ogni<br />

utente entra in una fase quando<br />

l’utente precedente ha finito<br />

questa fase. Ovviamente il tutto<br />

è molto più veloce e il tempo<br />

impiegato affinché esca l’utente<br />

D è di 3 ore e 30 minuti.<br />

Quello che varia non è il tempo<br />

per eseguire le tre fasi, ma il<br />

tempo d’attesa per essere<br />

servito.<br />

La condizione ideale è che il pipeline sia bilanciato, cioè tutti gli stadi consumino lo stesso tempo.<br />

In questo caso è come se io producessi un’unità di prodotto ogni frazione di tempo che è la stessa<br />

frazione di tempo che viene consumata in uno qualunque degli stadi del pipeline. Se ci mettiamo<br />

all’uscita del sistema e supponiamo che per produrre un’automobile ci sono 6 stadi di 1 ora<br />

ciascuno a regime abbiamo che viene prodotta un’auto ogni ora. Quindi bisogna bilanciare per<br />

quanto possibile gli stadi del pipeline, perché se gli stadi non sono bilanciati e c’è uno stadio che<br />

dura molto più degli altri questo diventa la latenza che condiziona la performance complessiva del<br />

sistema; quindi è inutile ridurre il tempo di uno stadio solo, perché comunque se c’è uno stadio che<br />

è molto lento è questo che condiziona il pipeline. In ogni caso bisogna minimizzare la latenza per<br />

ogni stadio, ma bisogna anche cercare di renderli uguali.<br />

Quali sono i concetti fondamentali del pipeline?<br />

Posso eseguire una sovrapposizione temporale tra diverse fasi di lavoro che vengono svolte su unità<br />

di prodotto diverse. Ma per le istruzioni?<br />

Per una stessa istruzione le varie fasi devono essere svolte in sequenza, però fasi di esecuzioni<br />

diverse possono essere svolte in parallelo su istruzioni diverse: per esempio mentre si fa la<br />

51


decodifica di un’istruzione si può fare il fetch dell’istruzione successiva. Naturalmente il tempo di<br />

esecuzione della singola istruzione non varia, però il tempo medio di esecuzione delle istruzioni si<br />

riduce di un fattore N (nel caso ideale) se tutte le fasi richiedessero lo stesso tempo di esecuzione.<br />

Nel caso del DLX che abbiamo esaminato, se questo potesse andare bene per il pipeline, tutte le fasi<br />

richiedono un periodo di clock e quindi sembrerebbe che tutti gli stadi sono tra di loro bilanciati,<br />

quindi il tempo di esecuzione medio si ridurrebbe di N. Il throughput migliorerebbe di N perché nel<br />

caso pipeline vedremmo uscire un’istruzione ogni ciclo di clock (a regime); questo significa che<br />

avremmo un CPIpipe = 1 (contro un CPIunpipe = N). Se scriviamo la formula del CPUtime nei due casi<br />

abbiamo:<br />

Quindi sostanzialmente abbiamo un fattore di miglioramento pari ad N. Naturalmente questo<br />

avviene nel caso ideale.<br />

Vediamo come si può organizzare il pipeline per l’esecuzione delle istruzioni. In verticale abbiamo<br />

le istruzioni che devono<br />

essere eseguite e in<br />

orizzontale abbiamo i cicli di<br />

clock.<br />

Immaginiamo di fare<br />

riferimento alla versione<br />

sequenziale del DLX.<br />

Nel primo ciclo di clock<br />

viene fatto il fetch<br />

dell’istruzione i; nel secondo<br />

ciclo di clock l’istruzione i passa alla fase di decode, e siccome le risorse hardware che fanno il<br />

fetch sono libere faccio il fetch della seconda istruzione; così dentro il processore ci sono<br />

contemporaneamente due istruzioni:<br />

Procedendo con i cicli di clock arriviamo al quinto ciclo di clock dove esce l’istruzione i. Da questo<br />

momento in poi ogni ciclo di clock ci sarà un write back:<br />

quindi ogni colpo di clock<br />

esce un’istruzione, e di<br />

conseguenza ho un CPI<br />

medio pari a 1.<br />

Bisogna capire se ci sono dei potenziali conflitti sulle risorse.<br />

52


Se manteniamo l’instruction memory e il data memory separati non possono esserci conflitti. In<br />

questo caso l’instruction memory deve essere rispetto a prima 5 volte più veloce perché prima al<br />

massimo leggevo un’istruzione ogni 5 cicli di clock.<br />

Abbiamo visto che nella versione sequenziale durante l’esecuzione il banco dei registri del DLX<br />

veniva usato nella fase di register fetch (secondo ciclo di clock) e nella fase di write back (quinto<br />

ciclo di clock); con una pipeline piena l’istruzione che è nella fase di write back richiederebbe di<br />

accedere al banco dei registri per andare a scrivere un risultato su un registro destinazione, ma<br />

l’istruzione che è al secondo colpo di clock accede ai registri per fare il register fetch; così ho che<br />

due istruzioni diverse usano la stessa risorsa, cioè il banco dei registri, per fare cose diverse.<br />

Un altro problema è quello che riguarda il PC. Prima succedeva che il PC veniva aggiornato al<br />

quarto ciclo di clock; nel caso del pipeline ogni colpo di clock deve essere aggiornato il PC. Ma se<br />

si aggiorna il PC ogni colpo di clock, e per esempio entra un’istruzione di branch che ha la<br />

condizione vera (ma che si sa al quarto ciclo di clock) succede che dopo di questa entrano altre<br />

istruzioni che non dovevano entrare nel processore.<br />

I registri A, B e Imm nello stesso ciclo di clock sono utilizzati nella fase di execution dall’istruzione<br />

i e scritti nella fase di decode dall’istruzione i+1.<br />

L’IR viene scritto nella fase di fetch; questo viene usato in tempi diversi: nella fase di write back un<br />

pezzo di IR serve per dirci qual è il registro destinazione, ma IR nel frattempo è stato sovrascritto<br />

altre quattro volte quindi non ho come recuperare il registro dove scrivere.<br />

Di conseguenza per la versione pipeline il datapath che abbiamo presentato così come è non può<br />

essere utilizzato.<br />

Vediamo una rappresentazione in cui andiamo a rappresentare piuttosto che il nome della fase la<br />

risorsa coinvolta per eseguire quella fase:<br />

entra un’istruzione che coinvolge<br />

l’instruction memory, poi passa<br />

ad utilizzare la risorsa registro e<br />

la risorsa memoria viene usata<br />

dall’istruzione successiva.<br />

Andando avanti, al quarto colpo<br />

di clock se avessimo usato una<br />

memoria unificata avremmo<br />

avuto un conflitto. Al quinto<br />

colpo di clock abbiamo un<br />

conflitto sui registri. Questo si<br />

chiama conflitto strutturale.<br />

Questo conflitto strutturale da<br />

questo punto in poi potrebbe<br />

esserci sempre se c’è una write<br />

back.<br />

Il conflitto strutturale si ha ogni volta che una risorsa viene utilizzata in due fasi diverse per due<br />

istruzioni diverse.<br />

53


Vediamo un programma scritto in assembly:<br />

16/04/2004<br />

; Inizializza un vettore con 5 valori interi e ne visualizza la<br />

somma<br />

; Sezione dati<br />

.data<br />

vett: .word 12<br />

.word 6<br />

.word 19<br />

.word 7<br />

.word 6<br />

msg_somma: .asciiz "\nLa somma e' %d"<br />

.align 2<br />

msg_sm_addr: .word msg_somma<br />

somma: .space 4<br />

.text<br />

.global main<br />

corrisponde a questo indirizzo<br />

main: addi r3,r0,5<br />

addi r2,r0,0<br />

addi r4,r0,0<br />

loop_somma: lw r5,vett(r2)<br />

subi r3,r3,1<br />

add r4,r4,r5<br />

addi r2,r2,4<br />

bnez r3,loop_somma<br />

stampa: sw somma(r0),r4<br />

addi r14,r0, msg_sm_addr<br />

trap 5<br />

fine: trap 0<br />

Ogni programma assembly, in questo caso DLX, inizia con una direttiva specifica: .data.<br />

Questa direttiva vuol dire che da quel punto sta cominciando la sezione in cui noi andiamo ad<br />

allocare i dati del programma, e non le istruzioni. Un’altra cosa importante oltre alle direttive sono<br />

le etichette, che sono delle parole, degli identificatori, con cui noi per comodità indichiamo delle<br />

zone del codice. Quando questo programma verrà trasformato in una serie di parole da mettere in<br />

memoria queste etichette corrisponderanno a degli indirizzi di memoria.<br />

Questo programma memorizza in memoria 5 valori interi, quindi 5 valori a 32 bit. Siccome noi<br />

vogliamo descrivere qual è la zona del codice dove andiamo a depositare questo vettore mettiamo<br />

l’etichetta vett:; è inutile mettere 5 etichette perché con quella sola sappiamo dove si trovano tutti<br />

gli altri elementi del vettore. Subito dopo questa etichetta ci sono delle direttive, .word, che ci<br />

dicono che quello che segue è un numero che deve essere codificato con una parola di 32 bit da<br />

mettere in memoria. Quindi quando il programma verrà avviato se andiamo all’indirizzo di<br />

memoria corrispondente all’etichetta “vett” troveremo quei 5 valori interi. Dopo segue un’altra<br />

54


etichetta: msg_somma:. Questo è un altro dato perché siamo dentro la sezione “.data”. Siccome noi<br />

vogliamo che questo programma ci visualizzi un output abbiamo bisogno di invocare una qualche<br />

funzione. In C abbiamo la printf: questa come parametri ha una stringa di formattazione (“il numero<br />

è: %d”, per esempio) e l’argomento che è il numero da stampare (quello a cui si riferisce %d).<br />

Questa stringa di formattazione non può stare nella parte del codice, ma deve stare da qualche parte<br />

in memoria e poi in qualche modo verrà invocata. Dopo le 5 word del vettore inizia l’allocazione,<br />

tramite la direttiva .asciiz, di una stringa (ci serve la .asciiz perché dobbiamo dire dove finisce la<br />

stringa). Questa stringa può finire in un byte qualunque di una word, quindi ho bisogno di una<br />

direttiva di allineamento, .align 2, perché vogliamo che la prossima cosa che allochiamo, sempre tra<br />

i dati, venga allocata in un indirizzo che è multiplo di 4, cioè all’inizio di una word. In generale la<br />

direttiva .align n fa sì che il successivo indirizzo sia multiplo di 2 n . Subito dopo abbiamo una word<br />

che contiene il valore dell’etichetta msg_somma, ovvero l’indirizzo a cui abbiamo iniziato ad<br />

allocare la stringa (puntatore in C). Successivamente indichiamo che vogliamo lasciati 4 byte liberi<br />

con la direttiva .space 4.<br />

Con la direttiva .text finisce la sezione dei dati e inizia quella del codice vero e proprio del<br />

programma. La direttiva .global dice che l’etichetta che segue è un’etichetta che deve essere visibile<br />

anche ad altri moduli eventualmente linkati col nostro programma.<br />

A partire dall’etichetta main: abbiamo le istruzioni vere e proprie. Siccome noi vogliamo scandire<br />

un vettore e sommarne gli elementi, abbiamo bisogno di un indice che ci dice quanti sono gli<br />

elementi da leggere (in C: for(i=0;i


; Equivalente assembly DLX del codice C :<br />

; printf("Hello! \n real %f , integer %d\n", 1.234, 43543);<br />

.data<br />

msg: .asciiz "Hello! \n real %f , integer %d\n"<br />

.align 2<br />

msg_addr: .word msg<br />

.double 1.234<br />

.word 43543<br />

.text<br />

addi r14,r0,msg_addr<br />

trap 5<br />

trap 0<br />

20/04/2004<br />

N.B. La chiamata della trap altera il valore del registro r1.<br />

Vediamo adesso l’utilizzo di una routine esterna. Immaginiamo che abbiamo la necessità di leggere<br />

da tastiera un numero intero senza segno. Noi teoricamente dovremmo utilizzare la trap associata<br />

alla read. Qualcuno ha utilizzato le chiamate di sistema e ha realizzato il codice il cui risultato è<br />

leggere in un certo registro un valore intero che noi immettiamo da tastiera. Il sorgente di questo<br />

programma è il seguente:<br />

;*********** WINDLX Ex.1: Read a positive integer number *************<br />

;*********** (c) 1991 Günther Raidl *************<br />

;*********** Modified 1992 Maziar Khosravipour *************<br />

;-----------------------------------------------------------------------------<br />

;Subprogram call by symbol "InputUnsigned"<br />

;expect the address of a zero-terminated prompt string in R1<br />

;returns the read value in R1<br />

;changes the contents of registers R1,R13,R14<br />

;-----------------------------------------------------------------------------<br />

.data<br />

;*** Data for Read-Trap<br />

ReadBuffer: .space 80<br />

ReadPar: .word 0,ReadBuffer,80<br />

;*** Data for Printf-Trap<br />

PrintfPar: .space 4<br />

SaveR2: .space 4<br />

SaveR3: .space 4<br />

SaveR4: .space 4<br />

SaveR5: .space 4<br />

InputUnsigned:<br />

.text<br />

.global InputUnsigned<br />

;*** save register contents<br />

sw SaveR2,r2<br />

56


sw SaveR3,r3<br />

sw SaveR4,r4<br />

sw SaveR5,r5<br />

;*** Prompt<br />

sw PrintfPar,r1<br />

addi r14,r0,PrintfPar<br />

trap 5<br />

;*** call Trap-3 to read line<br />

addi r14,r0,ReadPar<br />

trap 3<br />

;*** determine value<br />

addi r2,r0,ReadBuffer<br />

addi r1,r0,0<br />

addi r4,r0,10 ;Decimal system<br />

Loop: ;*** reads digits to end of line<br />

lbu r3,0(r2)<br />

seqi r5,r3,10 ;LF -> Exit<br />

bnez r5,Finish<br />

subi r3,r3,48 ;´0´<br />

multu r1,r1,r4 ;Shift decimal<br />

add r1,r1,r3<br />

addi r2,r2,1 ;increment pointer<br />

j Loop<br />

Finish: ;*** restore old register contents<br />

lw r2,SaveR2<br />

lw r3,SaveR3<br />

lw r4,SaveR4<br />

lw r5,SaveR5<br />

jr r31 ; Return<br />

Noi vogliamo utilizzare questo codice in modo tale da evitare che ogni volta che dobbiamo leggere<br />

un intero senza segno dobbiamo andare ad implementare tutta una serie di cose scomode.<br />

Questo programma definisce un’etichetta globale, in modo tale che se saltiamo a questa etichetta<br />

comincia ad essere eseguito tutto il codice che svolge per noi il compito di leggere da tastiera un<br />

numero senza segno. Per invocare questa procedura faremo un jal (nel registro r31 viene<br />

memorizzato l’indirizzo a cui si dovrà ritornare) : jal InputUnsigned. Noi dobbiamo conoscere<br />

alcune cose: prima che lo chiamiamo dobbiamo settare dei registri e poi dobbiamo sapere in quale<br />

registro memorizza il numero letto da tastiera. Per quanto riguarda l’input dobbiamo mettere in r1<br />

l’indirizzo della stringa di formattazione usata per fare la domanda: R1 indirizzo stringa; per<br />

quanto riguarda l’output abbiamo che il numero letto da tastiera viene memorizzato sempre in r1.<br />

Quando viene utilizzata questa routine avviene che vengono utilizzati dei registri e quindi vengono<br />

cambiati i valori di questi registri: r13 ed r14.<br />

Esempio:<br />

; Questo programma legge 5 numeri e ne visualizza la somma<br />

; Sezione dati<br />

57


.data<br />

vett: .space 20<br />

msg_lett: .asciiz "\nInserire un numero:"<br />

msg_somma: .asciiz "\nLa somma e' %d"<br />

.align 2<br />

msg_sm_addr: .word msg_somma<br />

somma: .space 4<br />

.text<br />

.global main<br />

main: addi r3,r0,5<br />

addi r2,r0, 0<br />

loop_lett: addi r1,r0,msg_lett<br />

jal InputUnsigned<br />

sw vett(r2), r1<br />

addi r2,r2,4<br />

subi r3,r3,1<br />

bnez r3, loop_lett<br />

calcolo: addi r3,r0,5<br />

addi r2,r0,0<br />

addi r4,r0,0<br />

loop_somma: lw r5,vett(r2)<br />

subi r3,r3,1<br />

add r4,r4,r5<br />

addi r2,r2,4<br />

bnez r3,loop_somma<br />

stampa: sw somma(r0),r4<br />

addi r14,r0, msg_sm_addr<br />

trap 5<br />

fine: trap 0<br />

Vediamo adesso un esempio di utilizzo di registri FPR.<br />

Esempio:<br />

;*********** WINDLX Ex.3: Factorial *************<br />

;*********** (c) 1991 Günther Raidl *************<br />

;*********** Modified: 1992 Maziar Khosravipour*************<br />

;--------------------------------------------------------------------------<br />

; Program begin at symbol main<br />

; requires module INPUT<br />

; read a number from stdin and calculate the factorial (type: double)<br />

; the result is written to stdout<br />

;--------------------------------------------------------------------------<br />

.data<br />

Prompt: .asciiz "An integer value >1 : "<br />

58


PrintfFormat: .asciiz "Factorial = %g\n\n"<br />

.align 2<br />

PrintfPar: .word PrintfFormat<br />

PrintfValue: .space 8<br />

main:<br />

.text<br />

.global main<br />

;*** Read value from stdin into R1<br />

addi r1,r0,Prompt<br />

jal InputUnsigned<br />

;*** init values<br />

movi2fp f10,r1 ;R1 -> D0 D0..Count register<br />

cvti2d f0,f10<br />

addi r2,r0,1 ;1 -> D2 D2..result<br />

movi2fp f11,r2<br />

cvti2d f2,f11<br />

movd f4,f2 ;1-> D4 D4..Constant 1<br />

;*** Break loop if D0 = 1<br />

Loop: led f0,f4 ;D0


Vediamo la versione pipeline del DLX:<br />

22/04/2004<br />

Questa versione somiglia molto a quella sequenziale, ma tra i vari stadi vengono interposti dei<br />

blocchi che si chiamano pipeline register. Molti dei problemi che abbiamo evidenziato la scorsa<br />

volta sono relativi al fatto che contemporaneamente io utilizzo la stessa risorsa per istruzioni di<br />

verse. Per evitare questo tipo di problema, se io per esempio sono nello stadio di execution e quindi<br />

mi servono i valori di A e di B che avevo scritto nella fase di register fetch, se questi valori li<br />

conservo da qualche parte libero A e B per poterci scrivere di nuovo. Ogni volta che io leggo<br />

un’istruzione il registro IR viene scritto, e allora questo IR lo conservo e lo faccio viaggiare assieme<br />

all’istruzione, così quello che serve all’istruzione se lo porta dietro. È come se questo parallelismo<br />

nell’esecuzione delle istruzioni da un punto di vista logico richiedesse che ogni fase eseguita<br />

all’interno della pipe su una determinata istruzione avesse bisogno del proprio contesto. Per<br />

realizzare questo tipo di operazione sono stati introdotti questi pipiline register. Quando io scrivo su<br />

IR, questo diventa un campo del pipeline register. Questi pipeline register hanno come campi i<br />

registri che usavamo nella versione sequenziale. Ogni pipeline register ha un suo nome: IF/ID<br />

(instruction fetch/instruction decode), cioè il registro pipeline interfaccia tra lo stadio di fetch e<br />

quello di decode; ID/EX (instruction decode/execute); EX/MEM; MEM/WB.<br />

Facendo in questo modo molti dei problemi visti vengono risolti, ma non tutti.<br />

Adesso passo passo andiamo ad analizzare<br />

cosa succede nella versione pipeline.<br />

All’inizio ho il PC che indirizza<br />

l’instruction memory; il contenuto di<br />

questa va copiato all’interno del pipeline<br />

register, e in particolare nel campo IR di<br />

questo registro: IF/ID.IR Mem[PC].<br />

Questo significa che IR, che è un registro<br />

a 32 bit è un campo di IF/ID, e a questo<br />

campo gli assegno come valore quello che<br />

ho letto dalla memoria all’indirizzo del<br />

PC.<br />

60


A differenza di quanto avveniva nella<br />

versione sequenziale del DLX stavolta<br />

sono costretto ad aggiornare il PC<br />

immediatamente, cioè dopo aver letto<br />

l’istruzione. Questo lo devo fare perché<br />

il prossimo colpo di clock devo leggere<br />

un’altra istruzione. In particolare a<br />

seconda del valore della condizione che<br />

c’è nel registro EX/MEM devo<br />

aggiornare il PC o con PC+4 o con un<br />

altro indirizzo se la condizione<br />

dell’istruzione, che si trova nella fase di<br />

execute e che vado a valutare nella fase<br />

di MEM, è vera (quindi avevo un<br />

branch):<br />

IF/ID.NPC,PCif(EX/MEM.cond)(EX/<br />

MEM.ALUOutput) else (PC+4).<br />

Quindi ogni volta che leggo<br />

un’istruzione devo aggiornare sia il PC che il NPC; entrambi li aggiorno o sommando 4 al quello<br />

che già era il PC oppure andando a copiare il valore di indirizzo target calcolato per un’istruzione<br />

che eventualmente già era entrata nel pipeline e che era un’istruzione di branch. Questo significa<br />

che l’esito di un’istruzione di branch lo conosco soltanto qua; ma così nel frattempo sono entrate<br />

altre istruzioni agli indirizzi successivi che non dovevano entrare.<br />

Nella fase di decodifica in parallelo avvengono due cose: la decodifica dell’istruzione e il register<br />

fetch.<br />

Adesso l’IR che si trova nel pipeline<br />

register IF/ID mi serve per indirizzare il<br />

banco dei registri, fare il register fetch e<br />

andare a produrre i valori da memorizzare<br />

su A, B e Imm (preceduto dall’estensione<br />

in segno):<br />

ID/EX.A Regs[IF/ID.IR6..10]<br />

ID/EX.B Regs[IF/ID.IR11..15]<br />

ID/EX.Imm(IF/ID.IR16)##IF/ID.IR16..32<br />

Se succedesse solo questo quando vado<br />

avanti entra una nuova istruzione che va a<br />

scrivere su IR, quindi perderei l’IR<br />

dell’istruzione che si trova nella fase di<br />

decode. Per evitare questo nella fase di<br />

decode ricopio anche l’IR e il NPC.<br />

Andiamo alla fase di execution. Come sappiamo questa fase può riguardare diverse operazioni a<br />

seconda del codice operativo. In particolare possiamo avere un’istruzione ALU (register-register<br />

oppure register-immediate), un’istruzione Load/Store, oppure un Branch.<br />

Vediamo cosa accade in ognuno di questi casi.<br />

61


Se è un’istruzione ALU register-register<br />

nel campo ALUOutput del registro<br />

pipeline EX/MEM viene messo il<br />

risultato dell’operazione; nel caso in cui<br />

è un’istruzione ALU register-immediate<br />

avviene la stessa cosa con la differenza<br />

che non vengono utilizzati gli operandi<br />

A e B, ma A e Imm:<br />

1) EX/MEM.ALUOutput ID/EX.A<br />

func ID/EX.B<br />

2) EX/MEM.ALUOutput ID/EX.A<br />

op ID/EX.Imm<br />

Sempre nell’ipotesi che si tratti di un’istruzione ALU devo propagare IR e devo settare a 0 il<br />

registro di condizione; setto a zero questo registro perché essendo un’istruzione ALU non devo<br />

preoccuparmi di verificare la<br />

condizione, però da questo valore<br />

dipende l’aggiornamento del PC e<br />

settando a 0 questo registro il PC<br />

verrà incrementato di 4:<br />

EX/MEM.IR ID/EX.IR<br />

EX/MEM.Cond 0<br />

N.B. Non è necessario copiare il NPC<br />

perché questo se serve serve nella<br />

fase di execution per calcolare<br />

l’indirizzo di salto e non nelle<br />

successive.<br />

Se non è una ALU instruction ma una Load/Store avviene la stessa cosa che avveniva per la<br />

versione sequenziale (calcolare l’indirizzo<br />

per accedere al prossimo stadio alla<br />

memoria); inoltre essendo un’istruzione di<br />

load/store devo settare a 0 il registro di<br />

condizione e portare avanti il campo B:<br />

EX/MEM.IR ID/EX.IR<br />

EX/MEM.ALUOutput ID/EX.A +<br />

ID/EX.Imm<br />

EX/MEM.cond 0<br />

EX/MEM.B ID/EX.B<br />

62


Nell’ipotesi in cui sia un’istruzione di<br />

salto nella fase di execution calcolerò il<br />

valore della condizione e il valore<br />

dell’indirizzo effettivo a cui saltare nel<br />

caso si verifichi la condizione:<br />

EX/MEM.ALUOutput ID/EX.NPC +<br />

ID/EX.Imm<br />

EX/MEM.cond ID/EX.A op 0<br />

Ovviamente ricopio anche l’IR:<br />

EX/MEM.IR ID/EX.IR<br />

Passando alla fase di Memory access mi sposto il registro IR; se l’istruzione che è entrata nella fase<br />

di MEM è un’istruzione ALU sposto il campo ALUOutput:<br />

MEM/WB.IR EX/MEM.IR<br />

MEM/WB.ALUOutput EX/MEM.ALUOutput<br />

Se è un’istruzione di Load/Store nella fase di MEM devo o leggere o scrivere la memoria:<br />

MEM/WB.IR EX/MEM.IR<br />

MEM/WB.LMD Mem[EX/MEMALUOutput]<br />

oppure<br />

Mem[EX/MEMALUOutput] EX/MEM.B<br />

63


Nella fase di Write Back possono succedere due cose a seconda che sia un’istruzione ALU o<br />

un’istruzione Load. In entrambi i casi vado a fare la scrittura del risultato sul banco dei registri.<br />

Nel caso di un’istruzione ALU abbiamo:<br />

Nel caso di un’istruzione di Load abbiamo:<br />

Regs[MEM/WB.IR 16..20] MEM/WB.ALUOutput<br />

oppure<br />

Regs[MEM/WB.IR 11..15] MEM/WB.ALUOutput<br />

Regs[MEM/WB.IR11..15] MEM/WB.LMD<br />

Nella fase di write back per indirizzare il registro destinazione sul banco dei registri ho bisogno di<br />

alcuni bit del registro IR di questa stessa istruzione; ecco perché mi trasporto il registro IR fino alla<br />

fase di write back.<br />

Dobbiamo vedere come passare dall’architettura sequenziale a quella pipeline.<br />

Ci sono vari problemi. Sostanzialmente ci sono tre problemi che sono quelli che vengono chiamati<br />

Hazards.<br />

I registri vengono letti ogni volta nella fase di decode e vengono scritti ogni colpo di clock per<br />

l’istruzione che si trova nella fase di write back; questo ci dà un conflitto strutturale. Lo stesso tipo<br />

di conflitto c’è se la memoria è unificata. Questi si chiamano structural hazards (hazards<br />

strutturali), cioè sostanzialmente legati al problema che la stessa risorsa viene utilizzata per<br />

istruzioni diverse per fare operazioni diverse.<br />

Poi ci sono i control hazards, che sono legati all’aggiornamento del PC: come si fa quando è<br />

entrata un’istruzione di branch, visto che l’esito di questa istruzione lo conosco nella fase di MEM e<br />

nel frattempo sono entrate altre tre istruzioni? L’azzardo consiste nell’aver fatto un fetch ogni ciclo<br />

di clock.<br />

Infine c’è quello che viene chiamato data hazard, che è legato al problema della dipendenza fra i<br />

dati: se per esempio ho due istruzioni consecutive che utilizzano un registro, e l’istruzione che entra<br />

64


per prima produce il valore di questo registro (operando destinazione), mentre l’istruzione che entra<br />

per seconda consuma il valore di questo registro (operando sorgente), si ha che il valore di quel<br />

registro viene prodotto nella fase di write back per la prima istruzione, ma quando questa istruzione<br />

è nella fase di write back la seconda istruzione è nella fase di execution, quindi ha già utilizzato il<br />

contenuto del registro che non è quello corretto<br />

i add r1,r2,r3 ← r1 viene utilizzato come registro destinazione<br />

i+1 add r5,r6,r1 ← r1 viene utilizzato come registro sorgente<br />

Quindi l’architettura pipeline avrebbe sconvolto la logica di esecuzione rispetto a quella prevista nel<br />

programma fatto dal programmatore.<br />

Hazard Strutturali<br />

Vediamo per ogni colpo di clock quali sono le risorse del processore utilizzate dalle varie istruzioni<br />

all’interno della pipe.<br />

Al primo ciclo di clock viene coinvolta la memoria (che immaginiamo unificata); al secondo colpo<br />

di clock la prima istruzione entra nella fase di decode, e quindi la risorsa coinvolta è Reg, e la<br />

seconda istruzione coinvolge la MEM per il fetch; al terzo colpo di clock abbiamo tre istruzioni<br />

dentro il pipeline: la prima istruzione utilizza l’ALU, la seconda coinvolge i registri e la terza<br />

utilizza la MEM. Al quarto colpo di clock se la prima istruzione è una Load o una Store utilizzerà la<br />

MEM, e quindi ci sarà un conflitto con l’istruzione che deve fare il fetch. Questo è un hazard<br />

strutturale.<br />

Poiché la prima istruzione è già dentro, quello che si può<br />

fare è ritardare il fetch di un ciclo di clock, in modo tale<br />

che la prima istruzione faccia l’accesso in memoria, la<br />

libera e poi si può fare il fetch dell’altra; questo significa<br />

ritardare tutto di un colpo di clock. Così la quarta<br />

istruzione entrerà nel quinto ciclo di clock. Si dice che<br />

abbiamo introdotto uno stallo, ovvero è stato stallato per<br />

un ciclo di clock il pipeline. Se la seconda istruzione<br />

fosse una Load/Store si introdurrebbero due stalli.<br />

65


Un’altra rappresentazione è la seguente:<br />

Vediamo da un punto di vista concreto come si fa ad introdurre uno stallo.<br />

Supponiamo che nel nostro pipeline sia entrata un’istruzione di Load o di Store e che sia arrivata<br />

nella fase di execution:<br />

Non appena questa istruzione arriva nella fase di MEM si genererebbe un conflitto con l’istruzione<br />

che entra nella fase di fetch (i+3):<br />

Per introdurre uno stallo noi dobbiamo fare in modo che il fetch dell’istruzione i+3 venga fatto al<br />

prossimo colpo di clock. Il PC contiene l’indirizzo dell’istruzione i+3 e con questo indirizzerebbe la<br />

66


memoria, ma non lo deve fare perché nel frattempo la memoria è indirizzata dall’ALUOutput che<br />

mi dice se deve fare una Load o una Store; quindi è come se io disabilitassi l’indirizzamento della<br />

memoria da parte del PC; ma il PC quando finisce la fase di fetch si incrementerebbe all’indirizzo<br />

di i+4, ma così i+3, che non è stata fatta avanzare, verrebbe persa. Quindi devo impedire che il PC<br />

si incrementi, e per fare questo invece che sommare 4 gli sommo 0. Quindi al prossimo colpo di<br />

clock faccio il fetch dell’istruzione i+3 che prima non era riuscito a fare; ovvero l’istruzione i+3<br />

non è stata scritta sul registro IR, ma su questo è stata scritta una Not operation (un’istruzione che<br />

non fa niente):<br />

Per eliminare il conflitto sulla memoria basta utilizzare memorie saparate. Per quanto riguarda i<br />

registri abbiamo un conflitto strutturale perché un’istruzione li usa nella fase di decode e una nella<br />

fase di write back. Per risolvere questo problema vengono utilizzate delle tecniche per<br />

implementare i registri che consentono a questi di essere letti e scritti all’interno di un ciclo di<br />

clock; in particolare nella prima metà del ciclo di clock si esegue la scrittura (write back) e nella<br />

seconda si esegue la lettura (register fetch). Con queste due tecniche abbiamo eliminato qualsiasi<br />

tipo di hazard strutturale.<br />

Data hazard<br />

Supponiamo di avere il seguente segmento di codice:<br />

nella prima istruzione r1 è<br />

un operando destinazione;<br />

in tutte le altre istruzioni r1<br />

è un operando sorgente. Il<br />

valore di r1 viene prodotto<br />

nella fase di write back<br />

della prima istruzione<br />

(viene aggiornato il<br />

registro), ovvero nel quinto<br />

ciclo di clock. Alla<br />

seconda istruzione r1 serve<br />

nella fase di register fetch<br />

ovvero nel terzo ciclo di<br />

clock, quindi il valore di r1<br />

che vado a leggere non è<br />

quello aggiornato e di<br />

conseguenza la seconda<br />

istruzione non verrà<br />

67


eseguita correttamente. Alla terza istruzione r1 serve nel quarto ciclo di clock quindi anche questa<br />

istruzione non viene eseguita correttamente. Alla quarta istruzione r1 serve al quinto ciclo di clock,<br />

e se utilizziamo la tecnica di scrivere nella prima metà del ciclo di clock e leggere nella seconda<br />

metà questa istruzione viene eseguita correttamente. La quinta istruzione viene eseguita<br />

correttamente.<br />

Siccome non posso permettermi di eseguire le istruzioni in modo non corretto devo introdurre degli<br />

stalli: quando faccio il register fetch di un registro ancora non corretto devo evitare che questo<br />

valore si propaghi alla fase successiva; questo è come dire che io ritardo la fese di register fetch fino<br />

a quando non sono sicuro che il valore che andrò a leggere non sia quello corretto:<br />

una volta che ho il valore corretto di r1 posso andare avanti con l’istruzione sub r4,r1,r5:<br />

Facendo così ho perso due colpi di clock (ho introdotto due stalli), perché piuttosto che fare un<br />

decode ne ho dovuti fare tre.<br />

Supponiamo di avere una situazione del seguente tipo:<br />

l’istruzione I–2 nella fase di<br />

MEM, l’istruzione I–1 nella<br />

fase di execution, l’istruzione<br />

I nella fase di decode e<br />

l’istruzione I+1 nella fase di<br />

fetch.<br />

Se c’è un data hazard si<br />

verifica con qualcosa che<br />

avviene nella fase di decode,<br />

perché è qui che faccio il<br />

register fetch, e mi preoccupo<br />

che il register fetch che sto<br />

per fare possa non essere<br />

quello corretto, ovvero leggo<br />

un registro sorgente che deve essere ancora prodotto, cioè è destinazione in un’istruzione che è più<br />

avanti nel pipeline. L’istruzione da cui c’è una dipendenza può stare o nella fase di execution o<br />

nella fase di MEM. Quindi potenzialmente io so che in questo tipo di pipeline il problema del data<br />

hazard si può porre tra l’istruzione che è nella fase di decode e le istruzioni che sono nelle fasi di<br />

execution e MEM. Come faccio a scoprire se c’è un problema di data hazard? Basta andare a vedere<br />

se gli operandi sorgenti dell’istruzione I sono operandi destinazione delle istruzioni I–1 e I–2. Se c’è<br />

68


una dipendenza tra I e I–2 devo aspettare un colpo di clock, mentre se la dipendenza è tra I e I–1<br />

devo aspettare due colpi di clock. Supponiamo che la dipendenza sia tra I e I–1, quando la I–1 si<br />

sposta in avanti la I non può spostarsi, ma qualcosa deve avanzare, e questo qualcosa è una not<br />

operation. Quando viene eseguita la fase di write back per l’istruzione I–1 posso fare il register<br />

fetch per la I, ma per fare questo mi occorre che nel registro pipeline IF/ID ci sia l’istruzione I;<br />

allora devo impedire che IR venga scritto a causa delle instruction fetch che possono avvenire<br />

durante quei due cicli di clock, e poi devo impedire che il PC venga incrementato per non perdere le<br />

istuzioni I+1 e I+2. Per fare questo bisogna “prendere in giro” tutto un pezzo di hardware: quando<br />

la logica di controllo andando a controllare se gli operandi sorgente dell’istruzione I dipendono<br />

dagli operandi destinazione di I–1 e di I–2, trova questa dipendenza (e quindi sa che deve introdurre<br />

uno o due stalli), impedisce all’istruzione I e all’istruzione I+1 di propagarsi, e mette 0 al posto di 4<br />

(per l’incremento del PC).<br />

69


23/04/2004<br />

Abbiamo detto che riusciamo a capire se ci può essere un data hazard analizzando la dipendenza tra<br />

l’istruzione che si trova nella fase di decode e le istruzioni che sono negli stage di execution e di<br />

MEM. Poi a fronte della detection di un hazard bisogna provvedere a introdurre degli stalli (può<br />

essere uno o possono essere due). Introdurre uno stallo significa fare in modo che il register fetch<br />

avvenga soltanto quando il write back l’istruzione con cui c’è la dipendenza è avvenuto.<br />

Vediamo come si fa: supponiamo che tra l’istruzione i+1 e l’istruzione i ci sia un data hazard,<br />

quindi ci mettiamo nelle condizioni in cui bisogna introdurre due cicli di stallo<br />

A questo punto nello stadio di fetch bisogna impedire che s’incrementi il PC (il 4 bisogna farlo<br />

diventare 0),e bisogna impedire che venga scritto il campo IR del registro IF/ID; nella fase di<br />

decode si forza una not operation e bisogna impedire che la fase di register fetch venga completata,<br />

cioè che i registri che vengono letti a fronte della decodifica vanno a finire sul pipeline register<br />

ID/EX, e lo stesso vale per l’immediate:<br />

Dopodiché la not operation va nello stadio di execution e si ripete la stessa procedura di prima:<br />

70


a questo punto ho due not operation che si propagano, e quindi sto perdendo due cicli di clock:<br />

A questo punto si può sbloccare tutto e la i+2 può avanzare:<br />

Quello che abbiamo illustrato è un modo per tamponare situazioni di emergenza che potrebbero<br />

portare alla non corretta esecuzione del programma. L’unico rimedio che abbiamo evidenziato è<br />

quello di introdurre dei cicli di stallo, cioè penalizzare le prestazioni.<br />

C’è qualche soluzione alternativa all’introduzione dei cicli di stallo? C’è qualcos’altro che penalizzi<br />

meno le prestazioni, ovvero che introduca meno cicli di stallo?<br />

Una di queste soluzioni, che poi tipicamente è la soluzione che si utilizza, è la seguente: quando<br />

abbiamo la dipendenza tra l’istruzione che si trova nella fase di decode e quella che si trova nello<br />

stadio di execution normalmente bisogna aspettare fintanto che l’istruzione che è più avanti non<br />

arrivi alla fase di write back e scriva il risultato nei registri e a quel punto può avanzare l’istruzione<br />

successiva; ma quando l’istruzione che produce il risultato si trova alla fine della fase di execution<br />

mette il risultato nel campo ALUOutput del registro EX/MEM; quindi perché bisogna aspettare altri<br />

due cicli di clock per averlo? C’è qualche soluzione per cui lo prendo subito e quindi aspetto meno?<br />

Questo viene fatto, cioè se il risultato di un’operazione è già disponibile si riesce a prendere<br />

anticipando la fase di write back, che nonostante ciò avviene correttamente lo stesso:<br />

71


supponiamo di avere la situazione<br />

a sinistra; si vede che c’è una<br />

dipendenza tra la prima istruzione<br />

e tutte le seguenti. Quando la<br />

prima istruzione arriva nella fase<br />

di execution e quindi<br />

l’ALUOutput contiene il valore<br />

corretto di R1, prendo questo<br />

valore e lo forzo all’ingresso<br />

dell’ALU in modo tale che sia<br />

disponibile per l’istruzione<br />

successiva. Questa tecnica si<br />

chiama forwarding, cioè anticipo<br />

la fornitura del risultato<br />

all’istruzione successiva. Lo<br />

stesso discorso si fa per la terza<br />

istruzione con la differenza che<br />

stavolta il valore di R1 si trova nel campo ALUOutput del registro MEM/WB. Facendo in questo<br />

modo non ho bisogno di introdurre alcuno stallo.<br />

C’è un caso in cui non riusciamo ad eliminare completamente gli stalli.<br />

Supponiamo che il processore pipeline è stato dotato di forwarding, e supponiamo che il codice sia<br />

il seguente:<br />

in questo caso il problema è che<br />

mi serve R1 all’ingresso<br />

dell’ALU al quarto ciclo di<br />

clock; ma R1 viene prodotto<br />

dalla load alla fine della sua<br />

fase di MEM, cioè alla fine del<br />

quarto ciclo di clock, quindi a<br />

me serve qualcosa che avverrà<br />

nel futuro. Questo comporta che<br />

non posso fare il forwarding,<br />

quindi devo fare scattare un<br />

altro ciclo; quello che si fa<br />

allora è introdurre una bolla, e<br />

quindi la fase di execution la<br />

faccio avvenire un colpo di<br />

clock dopo:<br />

fatto questo fornisco all’ingresso<br />

dell’ALU il valore dopo averlo<br />

calcolato e quindi alla fine pago un<br />

ciclo di clock. Questo significa che<br />

malgrado sia attivo il forwarding ci<br />

sono dei casi, in particolare quando<br />

ho una load seguita da un’altra<br />

istruzione che usa il risultato della<br />

load, in cui devo introdurre un ciclo<br />

di clock di stallo.<br />

72


Vediamo come si implementa il forwarding:<br />

Sia quando c’è una dipendenza fra due istruzioni adiacenti sia tra due istruzioni distanziate (di due)<br />

noi ci troviamo a fare, quando usiamo il forwarding, una retroazione dal registro ALUOutput verso<br />

l’ingresso dell’ALU; il registro ALUOutput può stare o sul registro pipeline EX/MEM oppure sul<br />

MEM/WB; questo registro può andare o in un ingresso dell’ALU o nell’altro. Per fare questo metto<br />

due multiplexer più grandi rispetto a quelli che c’erano prima in modo tale che posso decidere cosa<br />

fare arrivare all’ingresso dell’ALU.<br />

Quindi ci sono dei casi in cui quando ci sono dei data hazard bisogna introdurre uno stallo; si può<br />

trovare una qualche soluzione che anche in questa eventualità io posso risparmiarmi questo ciclo di<br />

clock di stallo? A livello hardware no. A livello software c’è qualcosa che si può fare? Ovvero il<br />

compilatore può fare qualcosa? Il compilatore è qualcosa che ha progettato qualcuno che sa che<br />

sotto c’è un’architettura pipeline e di conseguenza sa come funziona, e sa che ogni volta che c’è<br />

un’istruzione del tipo LW Rc, c e dopo un’istruzione del tipo ADD Ra, Rb, Rc ci sarà un data<br />

hazard dovuto al fatto che Rc nella prima è un registro destinazione e nella seconda è un registro<br />

sorgente. Siccome il compilatore ha la responsabilità, ma anche il potere di decidere quali istruzioni<br />

del set d’istruzioni utilizzare e in quale sequenza, se ci sono delle alternative nella compilazione che<br />

possono produrre un’ottimizzazione, potrebbe adottare queste alternative. Vediamo quali sono le<br />

alternative che si possono fare.<br />

Supponiamo che il compilatore deve tradurre le seguenti istruzioni C:<br />

a = b + c;<br />

d = e – f;<br />

Il modo più semplice di tradurre questo codice in Assembler, tipo quello del DLX, è il seguente<br />

(ricordiamo che il processore ha un’architettura di tipo load/store e quindi le variabili sono in<br />

memoria):<br />

LW Rb, b<br />

LW Rc, c<br />

ADD Ra, Rb, Rc<br />

SW a, Ra<br />

LW Re, e<br />

LW Rf, f<br />

SUB Rd, Re, Rf<br />

SW d, Rd<br />

73


In questo codice ci sono dei data hazard che non riesco a risolvere col forwarding. Quello sopra è<br />

quello che si chiama slow code, cioè è quello che produce dei cicli di stallo. Vediamo la versione<br />

ottimizzata, fast code:<br />

LW Rb, b<br />

LW Rc, c<br />

LW Re, e al posto di fare ADD Ra, Rb, Rc faccio LW Re, e, cioè ho messo<br />

ADD Ra, Rb, Rc un’istruzione nel mezzo tra le due istruzioni che creano il data<br />

LW Rf, f hazard<br />

SW a, Ra metto questa tra LW Rf, f e SUB Rd, Re, Rf in modo che non si<br />

SUB Rd, Re, Rf crei il data hazard<br />

SW d, Rd<br />

Questo codice è più veloce e impiega due cicli di clock in meno rispetto al primo.<br />

Vediamo delle analisi fatte su campo per capire cosa significa utilizzare queste tecniche di<br />

compilazione per ridurre gli stalli:<br />

In rosso c’è l’ottimizzazione,<br />

in verde non c’è<br />

l’ottimizzazione. Abbiamo<br />

tre benchmark: gcc, spice,<br />

tex. Sulle ascisse abbiamo la<br />

percentuale di load che<br />

stallano il pipeline. Si vede<br />

come nel caso del gcc si<br />

passa dal 54% al 31% di load<br />

che stallano il pipeline. Nel<br />

caso di spice si passa dal<br />

42% al 14% e nel caso di tex<br />

dal 65% al 25%.<br />

Vediamo come si misura la performance in un pipeline. Nel nostro caso il tipo di ragionamento che<br />

utilizziamo è il seguente: se abbiamo una versione sequenziale, per esempio del DLX, in cui si<br />

impiegano da 4 a 5 cicli di clock per eseguire un’istruzione; quando questa versione la si rende<br />

pipeline cosa ci si guadagna? Ragioniamo in termini di speedup:<br />

I due periodi di clock sono normalmente diversi perché il pipeline richiede hardware aggiuntivo<br />

rispetto alla versione sequenziale, e questa aggiunta significa anche probabilmente latenze<br />

maggiori. Ciononostante trascuriamo questa differenza tra i due periodi dei cicli di clock. Il CPIPIPE<br />

è il CPI che mediamente ottengo nella versione pipeline. Questo lo posso scrivere come la somma<br />

di due contributi: caso ideale CPIIDEAL = 1 e CPI non ideale (numero di clock per istruction dovuti<br />

agli stalli: per esempio se avessi 327 stalli su un totale di un milione di cicli di clock questo numero<br />

vale 327/1.000.000):<br />

Il CPIUNP, che vale tra 4 e 5, lo approssimiamo con la profondità del pipeline, ovvero col numero di<br />

stage, che è 5.<br />

Questa formula è stata ricavata per un caso particolare: pipeline bilanciato.<br />

Il pipeline produce un aumento del throughput: incrementa il numero di istruzioni eseguite<br />

nell’unità di tempo e questo comporta una diminuzione del tempo medio di esecuzione delle<br />

(*)<br />

74


istruzioni, e questo significa che un programma gira in meno tempo anche se la singola istruzione<br />

non viene eseguita più velocemente.<br />

Control hazard<br />

Abbiamo detto che il control hazard si verifica quando c’è un branch. In questo caso bisogna<br />

stallare il pipeline:<br />

Facendo così ho perso tre cicli di clock: il primo fetch e i due stalli.<br />

Supponiamo che in un programma ci sono il 30% di branch; questo significa che nel 30% dei casi<br />

devo introdurre tre clock di stallo. Applichiamo la formula del pipeline per misurare lo speedup (*):<br />

Speedup = 5/(1 + 3*0.3) ≅ 2.5, cioè ho una penalizzazione delle prestazioni di circa il 50% a causa<br />

dei branch.<br />

Una prima cosa che si può fare per cercare di minimizzare questa penalizzazione è quella di fare<br />

una modifica all’hardware del DLX: il problema dei tre cicli di stallo nasce dal fatto che io conosco<br />

nella fase di MEM l’esito del salto; se io lo conoscessi prima introdurrei meno stalli. Quando potrei<br />

sapere se il salto è preso oppure no? Intanto devo sapere che è un salto e poi pormi il problema se è<br />

preso oppure no. Quindi non posso che farlo nella fase di decode. Nella fase di decode so che ho un<br />

salto, ma nel frattempo potrei calcolare la condizione e l’indirizzo target; questo lo posso fare<br />

soltanto se metto dell’hardware aggiuntivo nella fase di decode:<br />

75


Quindi sposto il calcolo della condizione nella fase di decode e metto un altro ALU per calcolare<br />

l’indirizzo target. A questo punto se io so che è un branch ho già l’esito, ho l’indirizzo target, e<br />

quindi devo introdurre solo un ciclo di clock di stallo: Speedup = 5/(1 + 1*0.3) ≅ 3.9.<br />

Ci sono diversi modi per cercare di ridurre ulteriormente gli stalli.<br />

Predict not taken<br />

Da questo momento l’architettura che consideriamo è l’ultima vista: faccio la detection dello zero e<br />

il calcolo dell’indirizzo target nel secondo ciclo di clock; nel frattempo il fetch che avevo fatto lo<br />

dovrei ripetere, è questo lo stallo; se questo fetch è stato fatto e scopro nel frattempo che questo<br />

fetch è quello giusto, perché il salto non è preso, perché devo rifare il fetch? È come dire che faccio<br />

funzionare il processore come se lui si aspettasse che il salto non venga preso. Per questo si chiama<br />

“predict not taken”, cioè io predico che il salto non sia preso. Se effettivamente il salto non è preso<br />

non si perde nessun ciclo di clock:<br />

Dovessi scoprire nella fase di decode che il salto è preso si deve far propagare una not operation e si<br />

fa il fetch all’indirizzo target:<br />

Abbiamo visto che se io introducessi sempre lo stallo lo speedup sarebbe: Speedup = 5/(1+0.3)≅3.9.<br />

Supponiamo che ho il 30% di branch e scopro che il 50% è taken e il 50% è untaken. Con la tecnica<br />

del predict not taken posso abbattere ulteriormente questo valore, che mi esprime il numero di cicli<br />

di clock di stallo per instruction; in questo caso quando il salto non è preso non ho dei cicli di clock<br />

di stallo, quindi nel 50% del 30% invece devo introdurre uno stallo; quindi il denominatore diventa:<br />

1 + 0.3 × 0.5 × 1. Questo significa che sto migliorando ulteriormente le performance.<br />

Nel data hazard abbiamo scoperto che se riuscivamo a riorganizzare il codice potevamo ridurre il<br />

numero di stalli. È possibile anche nel caso del control hazard ristrutturare il codice per cercare di<br />

diminuire il numero di cicli di clock di stallo da introdurre? La risposta è sì. La tecnica del Delayed<br />

branch (delay slot) dice questo: quando si fa un branch e si è nella sua fase di decode nel frattempo<br />

si fa un fetch di un’altra istruzione; non si può fare in modo che sia sempre un fetch utile anche se<br />

non è il fetch dell’indirizzo target, se il salto è preso? Questo clock che viene subito dopo il branch<br />

si chiama delay slot. Se il salto viene preso verrà preso due colpi di cicli di clock dopo, ma quel<br />

ciclo ci clock intermedio è stato usato per un’istruzione che doveva comunque essere eseguita; il<br />

risultato è che dal pipeline io vedo uscire sempre un’istruzione e non uno stallo.<br />

Supponiamo che abbiamo un’istruzione di branch, di cui conosco l’esito nella fase di decode, se io<br />

(compilatore) ristrutturo il codice in modo che dopo il branch metto un’istruzione che comunque ha<br />

76


senso fare, sia che il salto sia preso sia che il salto non sia preso, ho risolto il problema.<br />

Consideriamo il caso del salto non preso:<br />

Mentre faccio il decode del branch vado a fare il fetch dell’istruzione successiva che può andare<br />

avanti; se il salto è non preso dovrò fare l’istruzione successiva al branch, cioè l’istruzione i+2, che<br />

è quella che seguiva il branch prima della ristrutturazione del codice.<br />

Se il salto invece viene preso non cambia niente:<br />

L’istruzione che segue l’istruzione che il compilatore ha messo subito dopo il branch sarà il branch<br />

target.<br />

Quindi nei due casi io comunque ho eseguito l’istruzione i+1, cioè ho fatto entrare nella pipe<br />

quell’istruzione, che ha fatto sì che non mi ha fatto introdurre nessun ciclo di stallo.<br />

Vediamo quali sono i casi che si possono verificare.<br />

Supponiamo di avere il seguente segmento di codice:<br />

subito dopo il branch (if R2=0 then) c’è un delay slot che sarebbe il ciclo di<br />

clock successivo in cui bisogna decidere quale istruzione fare entrare nel<br />

pipeline. Il compilatore vede che l’istruzione ADD R1,R2,R3 è indipendente<br />

dall’istruzione successiva dato che genera R1 come operando destinazione.<br />

Allora se questa istruzione la metto subito dopo il branch, cioè significa che<br />

ogni volta che eseguo il branch sicuramente eseguo anche questa, cambia<br />

qualcosa nel programma? Cambierebbe qualcosa se a volte la eseguo e a<br />

volte no, ma viene eseguita sempre. Quindi tutte le<br />

volte che il compilatore trova che l’istruzione che precede il branch non<br />

dipende da quest’ultimo la mette subito dopo e quindi ha risolto il problema.<br />

In poche parole inverte il branch con l’istruzione che la precede, e quindi<br />

quello slot di tempo che potrebbe provocare uno stallo, a questo punto non<br />

provoca alcuno stallo, perché tanto vado ad eseguire un’istruzione che<br />

comunque doveva essere eseguita. Quando ho un codice di questo tipo nel<br />

100% dei casi non ho bisogno di introdurre stalli.<br />

Supponiamo di avere il seguente codice:<br />

stavolta non è possibile fare quello fatto sopra perché l’istruzione che<br />

precede il branch produce un risultato che è sorgente nell’istruzione<br />

successiva, quindi di fatto c’è una dipendenza, di conseguenza non posso<br />

mettere l’istruzione ADD R1,R2,R3 dopo il branch perché non saprei come<br />

fare a vedere se R1 è uguale a zero oppure no. Supponiamo che il<br />

compilatore sappia che è molto probabile che il salto venga preso, allora<br />

77


prende l’istruzione che è all’indirizzo target e la mette subito dopo il branch<br />

ricordando che non deve saltare più alla stessa istruzione, ma all’indirizzo<br />

successivo, perché altrimenti verrebbe eseguita due volte per ogni ciclo.<br />

Quindi il compilatore copia quell’istruzione anche dopo il branch, e non la<br />

sposta perché a quell’istruzione posso arrivare anche da altre vie del<br />

programma. Si pone un problema: se il salto non viene preso? Viene<br />

eseguita una SUB R4,R5,R6 che prima non doveva essere eseguita; questo<br />

può provocare problemi perché la SUB modifica R4, e se questo serve dopo è chiaro che questa<br />

cosa non si può fare, ma il compilatore l’ha fatta e quindi il programma non viene eseguito<br />

correttamente. Ma il compilatore sa se può azzardare questa cosa perché conosce il codice. Se per<br />

caso R4 viene utilizzato in seguito il compilatore non può mettere quell’istruzione e mette al suo<br />

posto una not operation (è come se stesse introducendo uno stallo).<br />

Consideriamo il caso duale, cioè quello in cui scommetto sul fatto che il salto non sia preso:<br />

se io ho indicazione per cui il salto non è preso a questo punto il compilatore<br />

prende un’istruzione successiva al branch, che tanto dovrà essere eseguita<br />

visto che il salto non viene preso, e la sposto come istruzione nel delay slot.<br />

Se il salto non viene preso risparmio un ciclo di<br />

clock di stallo; se il salto viene preso prima di<br />

eseguire l’istruzione target eseguo un’istruzione che<br />

non doveva essere eseguita. Anche in questo caso ci<br />

potrebbero essere problemi; ma anche in questo caso<br />

il compilatore ha il codice e quindi sa se questa cosa si può fare oppure no, e<br />

se non si può fare potrebbe andare a scegliere un’istruzione che è innocua<br />

(se c’è), nel senso che non va a modificare nulla di quello che segue<br />

l’istruzione target se il salto viene preso; altrimenti va a mettere una not operation.<br />

Nell’ipotesi in cui il processore deve introdurre uno stallo come si fa?<br />

A sinistra abbiamo il caso in cui ancora<br />

non è stata fatta l’ottimizzazione,<br />

ovvero in cui bisogna introdurre tre<br />

cicli di clock di stallo. Supponiamo che<br />

il branch sia nella fase di decode, e<br />

quindi l’istruzione i+1 sta facendo il<br />

fetch, quello che devo fare è non fare<br />

avanzare la i+1, ma una not operation, e<br />

quindi a questo punto io devo stallare<br />

per un numero di cicli di clock che è<br />

quello richiesto nel nostro caso;<br />

ovviamente non devo far modificare il<br />

PC finché non sa l’esito del branch.<br />

Vediamo in dettaglio:<br />

78


entra il branch nella fase di decode e si fa il fetch dell’istruzione i+1. A questo punto non faccio<br />

l’incremento del PC, blocco l’accesso in memoria e anche la scrittura dell’IR e faccio entrare una<br />

not operation:<br />

A questo punto la not operation si propaga e nello stadio di fetch entra un’altra not operation:<br />

Si ripete lo stesso di prima per un altro ciclo di clock:<br />

A questo punto le cose sono due: il branch o è preso o non è preso; naturalmente questo lo scopro<br />

nello stadio di MEM.<br />

Consideriamo il caso in cui il branch è preso:<br />

naturalmente vado a finire all’istruzione target<br />

79


Se il salto non è preso:<br />

entrerà nello stadio di fetch l’istruzione i+1<br />

80


29/04/2004<br />

Abbiamo già visto che c’è una grande differenza nel trend di crescita delle performance del<br />

processore rispetto alla crescita delle RAM dinamiche. Di seguito sono riportate le performance del<br />

microprocessore, che crescono del 60% circa ogni anno, e che rispetto all’evoluzione delle<br />

prestazioni della RAM dinamica, che vede crescere in modo significativo la densità ma vede<br />

crescere poco la sua performance, c’è una differenza (CPU-DRAM Gap) di circa il 50% per anno:<br />

Da un punto di vista relativo è come se la memoria fosse sempre più lenta rispetto al processore, e<br />

quindi c’è il rischio che questo gap vanifichi i progressi che si possono ottenere nel campo dei<br />

computer. Abbiamo visto che questo problema è stato affrontato ed è stata trovata una soluzione.<br />

Questa soluzione fa leva su due concetti fondamentali:<br />

1. principio di località, temporale e spaziale: quella temporale dice che se sto referenziando un<br />

item nel prossimo futuro esso sarà referenziato con alta probabilità; quella spaziale dice che se<br />

sto referenziando un item è probabile che gli item che sono nella zona verranno refernziati;<br />

2. misure sperimentali.<br />

Questi due concetti hanno di mostrato che nell’80% degli accessi durante l’esecuzione di un<br />

programma, anche molto grosso, si fa riferimento soltanto al 10%-15% del codice. Questa è una<br />

grande scoperta perché a questo punto prendendo i vantaggi che vengono dall’elettronica che dice<br />

che un hardware più piccolo è anche più veloce, possiamo dire che, se riesco a concentrare in questa<br />

piccola memoria questo 10%-15% di codice, sto diminuendo questo gap di performance tra CPU e<br />

memoria. Tutto questo, località più riscontro di tipo tecnologico sulla memoria, porta a pensare di<br />

organizzare il sistema memoria come una gerarchia. In questa organizzazione della gerarchia le<br />

cose stanno come sono mostrate nella seguente figura:<br />

questa gerarchia è costituita da un insieme di livelli, il primo<br />

dei quali (quello più vicino alla CPU) si chiama memoria<br />

Cache, poi abbiamo la Main Memory, poi abbiamo i Dischi,<br />

e poi eventualmente i Tape. Il concetto sostanzialmente è<br />

quello che se io mi metto su un livello della gerarchia, su<br />

questo è come se avessi una finestra di dimensione limitata<br />

sul livello successivo. La cosa fondamentale è che le<br />

informazioni scambiate tra il processore e la cache sono<br />

istruzioni e dati, cioè significa che il processore quando<br />

81


accede alla memoria, e quindi quando accede in cache, lo fa o per andare a leggere un’istruzione o<br />

per andare a leggere, attraverso un’istruzione di load, o per andare a scrivere, attraverso<br />

un’istruzione di store, un dato (word, byte, ecc…). Per quanto riguarda l’informazione che può<br />

essere scambiata tra cache e main memory, abbiamo che l’unità di informazione è il blocco (ha<br />

come dimensione un’insieme di word). Questo significa che nella cache l’organizzazione è a<br />

blocchi, e la dimensione di ognuno di questi è uguale a quella dei blocchi della main memory.<br />

Quando succede che il processore accede in cache e vuole fare riferimento a una word, se questa<br />

non è presente (miss) succede che tutto il blocco che la contiene deve essere rimpiazzato da un<br />

blocco di main memory che contiene quell’informazione. La comunicazione tra memoria e disco è<br />

a pagine.<br />

Il livello più basso della gerarchia è il più ampio dal punto di vista di capacità di memorizzazione,<br />

ma è anche il più lento, mentre man mano che saliamo nella gerarchia le cose si invertono. Se<br />

consideriamo anche i costi abbiamo che man mano che saliamo nella gerarchia anche i costi<br />

salgono.<br />

Se è vero che l’organizzazione nella cache è a blocchi allora significa anche che per accedere a una<br />

word all’interno di un blocco l’indirizzo, sia per quanto riguarda la main memory sia per quanto<br />

riguarda la cache, è fatto nel seguente modo:<br />

Immaginiamo che la main memory organizzi le informazioni nel seguente modo:<br />

×<br />

ognuno di questi è una word, e ognuno di quelli orizzontali è un<br />

blocco; questo significa che per accedere alla word (×) devo<br />

indirizzare il blocco su cui sta la word e poi dire all’interno del<br />

blocco quale word mi interessa. La cache è anch’essa organizzata<br />

in blocchi della stessa dimensione di quelli della main memory:<br />

quando succede un miss, cioè<br />

quando accedendo ad un blocco<br />

della cache il processore sta<br />

andando ad accedere a questa<br />

word, per esempio, e non la trova, quello che avviene è che il<br />

blocco dove il dato cercato dal processore sta rimpiazzerà questo.<br />

Per gestire in questo modo la memoria l’indirizzo ha una parte che<br />

viene chiamata indirizzo di blocco (block address), e una parte che viene chiamata block offset.<br />

L’indirizzo di blocco identifica il blocco, mentre il block offset identifica quale word all’interno del<br />

blocco sto andando a referenziare. Se il blocco è costituito da quattro word, per esempio, il block<br />

offset è costituito da 2 bit.<br />

Per quanto riguarda i parametri che caratterizzano una gerarchia di memoria abbiamo:<br />

• hit rate: frazione di accessi fatti alla cache che hanno portato ad un accesso positivo;<br />

• miss rate: è il complementare dell’hit rate;<br />

• tempo medio di accesso alla memoria (average memory-access time): se io volessi misurare il<br />

tempo medio di accesso al sistema memoria immaginando che all’interno di questo sistema ci<br />

sia una gerarchia costituita da due livelli (cache e main memory), questo è dato dalla formula<br />

Average memory-access time = Hit time + Miss rate × Miss penalty, dove hit time è il tempo di<br />

accesso alla memoria cache;<br />

• miss penalty: è o il numero di cicli di clock o il tempo speso per andare a reperire il blocco<br />

dalla main memory e portarlo nella cache.<br />

Il miss penalty a sua volta può essere visto come somma di due termini:<br />

• tempo di accesso (access time): è il tempo per accedere ad una word nella main memory;<br />

• tempo di trasferimento (transfer time): è il tempo che si impiega per trasferire questo dato<br />

selezionato sulla cache. Questo tempo dipende dalla banda disponibile tra la main memory e la<br />

×<br />

82


cache, ovvero dall’ampiezza del data bus tra main memory e cache; più ampio è il data bus<br />

minore è il transfer time.<br />

Vediamo cosa succede al tempo medio di accesso applicando la formula:<br />

Questi diagrammi sono tracciati per una determinata dimensione di cache. Abbiamo detto che miss<br />

penalty è costituito dalla somma di due contributi: tempo di accesso che è costante, perché la<br />

selezione di un blocco e all’interno di questo una word con l’offset non dipende dalla dimensione<br />

del blocco; tempo di trasferimento che varia linearmente con la dimensione del blocco, a parità di<br />

data bus. Il miss rate presente l’andamento della figura centrale: quando il block size cresce il miss<br />

rate diminuisce perché se è vero il principio di località spaziale quando io accedo e trovo un dato,<br />

siccome è probabile che accederò ai dati sequenziali a quello, significa che probabilmente starò<br />

all’interno dello stesso blocco, e quindi più ampio è il blocco più località spaziale sto catturando;<br />

c’è un punto che si chiama pollution point in cui si inverte la pendenza: all’aumentare la dimensione<br />

del blocco diminuisce il numero di blocchi presenti in cache e quindi si incomincia a penalizzare la<br />

località temporale, rischiando di fare continuamente swap tra main memory e cache. Se applica la<br />

formula Average memory-access time = Hit time + Miss rate × Miss penalty otteniamo l’ultimo<br />

grafico.<br />

Per spiegare la nostra gerarchia di memoria il progettista quando si appresta ad eseguire il progetto<br />

di una gerarchia di memoria deve rispondere a quattro domande:<br />

1. Dove può essere posto nel livello superiore, nel caso nostro nella memoria cache? Questo si<br />

chiama Block placement, ovvero quando io devo portare un blocco di main memory nella<br />

cache, questo blocco dove lo vado a mettere?<br />

2. Quando io processore voglio andare ad accedere ad un’informazione lo faccio attraverso il suo<br />

indirizzo, che corrisponde ad una word nella main memory; come faccio a vedere se<br />

quest’informazione e presente nella cache dato che ho quell’indirizzo che va a referenziare<br />

l’informazione nella main memory? Ovvero come faccio ad utilizzare l’indirizzo del processore<br />

per andare a vedere se il dato cercato sta nella cache? Questo si chiama Block identification.<br />

3. Quando il processore cerca un’informazione in cache e si verifica un miss, bisogna andare a<br />

recuperare il blocco di main memory dove l’informazione risiede e portarlo in cache. Se io<br />

porto un blocco in cache, immaginando che sia sempre piena, quale dei blocchi butto per far<br />

spazio al blocco caricato dalla main memory? Questo problema si chiama Block replacement.<br />

4. Cosa avviene quando c’è una scrittura? Quando il processore deve scrivere e vuole modificare<br />

un dato (in cache), supponendo che lo trova modifica questo dato, e da questo momento in poi<br />

ho una copia che non è più consistente con quello che c’è in main memory. Questo può essere<br />

un problema? Siccome la risposta è sì, quale strategia di scrittura utilizzo (Write strategy)?<br />

Rispondere a queste quattro domande è sufficiente a progettare, se siamo nell’ottica della<br />

progettazione, o a comprendere, se siamo nell’ottica dello studio, un sistema di gerarchia di<br />

memoria.<br />

83


Guardiamo la seguente figura:<br />

in basso abbiamo la main memory dove ognuna delle barrette rappresenta un blocco di<br />

informazione (stiamo considerando 32 blocchi); sopra abbiamo la cache costituita da 8 blocchi. Se<br />

io voglio accedere al blocco 12 della main memory e posizionarlo in cache, in quale degli 8 blocchi<br />

può andare a finire? Ci sono tre strategie possibili:<br />

1. Direct mapped: si prende l’indirizzo di blocco di main memory, in questo caso 12, si fa<br />

l’operazione in modulo col numero di blocchi che ci sono in cache, in questo caso 8, e si piazza<br />

il blocco nel risultato di quest’operazione ⇒ 12 mod 8 = 4. Facendo così scopriamo che i primi<br />

8 blocchi vengono mappati negli 8 blocchi della cache (blocco 0 della main memory nel blocco<br />

0 della cache, blocco 1 nel blocco 1, ecc…); il blocco 8 verrà mappato nel blocco 0 della cache<br />

(8 mod 8 = 0), e così via. Questo significa che dato un blocco di main memory questo può<br />

andare a finire in uno e uno solo posto in cache.<br />

2. Fully associative: quando ho un blocco di main memory e lo voglio piazzare in cache posso<br />

farlo in uno qualunque dei blocchi della cache.<br />

3. Set associative: si divide la memoria cache in un certo numero di insiemi; ciascun insieme<br />

contiene più blocchi. Immaginiamo che la cache fatta da 8 blocchi la suddividiamo in 4 insiemi<br />

contenenti 2 blocchi. Un blocco di main memory può finire in uno e uno solo degli insiemi della<br />

cache; in questo caso l’operazione di modulo si fa con 4, perché tale è il numero di insiemi.<br />

All’interno dell’insieme la strategia è fully associative, nel senso che il blocco lo posso mettere<br />

dove voglio.<br />

Come si fa quando il processore emette un indirizzo a sapere dove in cache potrebbe stare quello<br />

che sta cercando?<br />

Vediamo di risolvere questo problema<br />

nel caso in cui la strategia di placement<br />

sia il direct mapped. Supponiamo che<br />

quella a destra sia la main memory: è<br />

costituita da 32 blocchi ciascuno<br />

costituito da 4 word. Supponiamo che la<br />

cache sia fatta da 8 blocchi. I blocchi 0,<br />

8, 16 e 24 vanno a finire nel blocco 0<br />

della cache; quindi nel blocco 0 della<br />

cache potrebbero alloggiare 4 blocchi<br />

della main memory in base a questa<br />

84


strategia di placement. Lo stesso discorso si fa per i blocchi 7, 15, 23 e 31 della main memory che<br />

vanno a finire nel blocco 7 della cache. Lo stesso vale per tutti gli altri blocchi: ciascun blocco della<br />

cache può potenzialmente ospitare 1 su 4 possibili blocchi di main memory. Il processore può<br />

accedere ad una word che ha un suo indirizzo; quest’indirizzo abbiamo visto che viene splittato in<br />

due campi: block address e block offset. Come si fa a partire da quest’indirizzo a sapere dove<br />

cercare all’interno della cache se quel dato è presente oppure no? Supponiamo che il dato che sta<br />

cercando il processore sta nel blocco 0 della main memory; in base alla strategia di mapping<br />

sappiamo che dobbiamo andare a cercare nel blocco 0 della cache. Supponiamo che vogliamo<br />

leggere la seconda word, e quindi andiamo a leggere la seconda word del blocco 0 della cache. In<br />

cache c’è il blocco 0 o il blocco 8, oppure gli altri blocchi possibili?<br />

Quindi ho bisogno nella cache di aggiungere dell’informazione che non sia soltanto l’informazione<br />

che dalla main memory ho portato sulla cache, ma devo ricordare quel blocco a quale blocco di<br />

main memory si riferisce. Questa informazione aggiuntiva è quello che viene chiamato tag. Nel<br />

nostro caso siccome ogni blocco della cache può provenire da 4 diverse alternative di main memory<br />

per il campo tag basteranno solo 2 bit. La caratteristica dei 4 blocchi di main memory è che hanno<br />

soltanto i due bit più significativi dell’indirizzo di blocco che si differenziano; questi due bit sono<br />

quelli che costituiranno il tag di quel blocco nella cache. Questo significa che pago un prezzo<br />

perché non è vero che la cache è soltanto la copia di un pezzo di main memory, ma devo aggiungere<br />

dell’informazione che non sarebbe necessaria se usassi solo la main memory.<br />

L’indirizzo del processore, nel caso in cui il suo sistema di memoria è quello che abbiamo visto,<br />

come lo utilizziamo per andare ad identificare l’informazione all’interno della cache?<br />

Supponiamo di avere uno spazio d’indirizzamento che ha 32 blocchi, ciascuno con 4 word e di<br />

conseguenza con 128 word totali. Immaginiamo di avere un processore che in totale possa<br />

indirizzare 128 word. Questo significa che il campo degli indirizzi del processore deve essere di 7<br />

bit:<br />

sappiamo che, se c’è una cache di 8 blocchi, e se abbiamo ogni<br />

blocco di dimensione 4 word, due bit servono per il block offset,<br />

e verranno utilizzati quando andrò in cache per andare a<br />

selezionare la word interessata se trovo il blocco cercato, due bit<br />

mi servono per il tag, e tre bit che servono per identificare il<br />

blocco della cache dove dovrebbe essere il dato. Una volta<br />

selezionato il blocco della cache grazie al campo index vengono<br />

confrontati i due bit di tag (della cache e dell’indirizzo) per<br />

vedere se sono uguali, e se è così vuol dire che ho trovato l’informazione trovata, altrimenti c’è un<br />

miss.<br />

Proviamo a rispondere alla terza domanda: quale blocco sostituire quando c’è un miss?<br />

Tipicamente si fa riferimento a due strategie: random e LRU (less recent used).<br />

Quando c’è un miss e si va in main memory a prendere un blocco non è detto che si può scegliere.<br />

Nel caso direct mapped la politica di sostituzione è imposta: l’unico posto dove il blocco può andare<br />

a finire è quello che si deve liberare, perché non c’è alcuna scelta. Il problema si pone su quelle<br />

strategie di placement che lasciano libertà: set associative o fully associative.<br />

LRU è la politica che dice: in base al concetto di località temporale se c’è un blocco presente in<br />

cache e non lo si usa da un sacco di tempo, più tempo passa e meno probabilmente vi si accederà;<br />

questo significa che laddove c’è libertà se il mio blocco può andare a finire all’interno di un set<br />

verrà scartato il blocco meno recentemente utilizzato.<br />

La politica random dice: se si può scegliere tra più possibilità il blocco da sostituire verrà scelto in<br />

maniera random.<br />

Si può vedere che se si considera la strategia set associative a due vie (cioè con due blocchi<br />

all’interno del set) la LRU presenta un miss rate del 5,18%, mentre se si utilizza una politica<br />

random il miss rate peggiora a 5,69% (per una cache di 16 KB); la stessa cosa si nota anche<br />

85


all’aumentare dell’associatività. La cosa che si può osservare è che man mano che aumenta la<br />

dimensione della cache la differenza dei miss rate tra le due politiche si attenua:<br />

Quindi per cache di dimensioni adeguate le due politiche danno risultati simili, di conseguenza si<br />

sceglie quella più economica, ovvero quella random. Se dovessi implementare la LRU dovrei, in<br />

ogni blocco della cache, non solo avere il campo tag, ma anche un qualcosa che mi segna l’età del<br />

blocco, e quindi un overhead nella ricerca del blocco da sostituire.<br />

L’ultima domanda è quella legata al write.<br />

Il processore deve scrivere (store) e supponiamo che ci sia un hit (trova in cache la word da<br />

modificare); abbiamo detto che facendo questa operazione di scrittura stiamo violando la<br />

consistenza tra le due copie, quella in cache e quella in main memory; questo potrebbe essere un<br />

problema. Per risolvere questo problema la tecnica più semplice a cui si può pensare è: tutte le volte<br />

che si scrive in cache si va a scrivere anche sulla main memory (write through); questo significa<br />

che il processore scrive in cache, e contemporaneamente il cache controller utilizza l’indirizzo<br />

fornito dal processore per andare a scrivere quel dato sulla copia che c’è in main memory.<br />

Ovviamente facendo così tutte le volte che c’è una scrittura penalizzo il processore perché devo<br />

aspettare di finire la scrittura sulla main memory che è più lenta. In questo caso si risolve il<br />

problema attraverso di un write buffer: il processore scrive in cache e contemporaneamente il dato<br />

viene copiato nel write buffer (buffer fatto di un certo insieme di blocchi o di word), fatto questo il<br />

processore può andare avanti, e poi il cache controller si occupa di copiare quello che c’è nel write<br />

buffer sulla main memory, sgravando il processore da questo compito.<br />

La write through, in generale nei sistemi, mi mantiene la consistenza tra le due copie, ma accede<br />

spesso alla main memory, il che significa consumare banda disponibile sulla main memory che oltre<br />

dal processore può essere acceduta anche da altri dispositivi (per esempio il DMA).<br />

L’altra politica di scrittura quando si verifica un hit è quella che viene chiamata write back: quando<br />

si deve effettuare un’operazione di scrittura si scrive solo nella cache, non interessandoci della<br />

consistenza; non aggiornando il dato nella main memory può succedere che se c’è un miss prendo<br />

un blocco della cache e lo butto, e se su quel blocco erano avvenute delle scritture, tutti gli<br />

aggiornamenti non presenti in main memory li ho persi; quindi la write back così semplicemente<br />

non può funzionare. Allora quello che si fa è che tutte le volte che si deve buttare un blocco, prima<br />

di buttarlo si deve andare a copiare sulla main memory. Ovviamente si può migliorare il tutto<br />

pensando che non si deve fare sempre: non è detto che il blocco che butto è stato modificato e<br />

quindi non è necessario copiarlo sulla main memory. Per fare questo è necessario aggiungere<br />

un’informazione sulla cache, ovvero un bit che dice se il blocco è stato modificato oppure no;<br />

questo bit tipicamente si chiama clean or dirty. Questo significa che il cache controller si deve<br />

preoccupare quando c’è un replacement di copiarlo oppure no sulla main memory.<br />

Pro e contro delle due tecniche:<br />

• non succede mai che un miss in lettura, quando utilizzo una politica write through, provoca una<br />

scrittura in main memory, perché la main memory è sempre aggiornata;<br />

• nel caso di write back un miss in lettura può implicare una scrittura sulla main memory;<br />

86


• i vantaggi del write back sono: se su un blocco in cache ho fatto molte scritture quando questo<br />

viene sostituito si riflette in una sola scrittura sulla main memory, cioè consuma pochissima<br />

banda.<br />

Vediamo come funziona il write through:<br />

quando il processore scrive in cache, il dato<br />

viene copiato anche nel write buffer, il<br />

quale essendo un piccolo buffer fa sì che la<br />

scrittura può avvenire alla stessa velocità<br />

della scrittura sulla cache; dopodiché il<br />

write buffer viene scaricato nella main<br />

memory. Naturalmente il write buffer diventa una struttura FIFO, e il problema del progettista del<br />

sistema memoria è di quanto deve essere lungo il write buffer: c’è un servente che svuota il buffer<br />

con la velocità tipica di come una scrittura può avvenire sulla DRAM, e c’è un produttore che<br />

riempie il buffer potenzialmente con la velocità del processore, quindi se il buffer è pieno bisogna<br />

far aspettare il processore per un tempo pari alla scrittura di un intero blocco sulla main memory.<br />

Per fortuna la frequenza delle scritture normalmente in un programma è abbastanza bassa rispetto<br />

alle letture.<br />

Se devo fare un’operazione di write e c’è un miss che cosa dovrebbe succedere normalmente? Si<br />

prende il blocco dalla main memory, lo si porta in cache e poi si scrive. Se si segue questa logica si<br />

dice che si sta utilizzando una politica di write allocate. Si può anche seguire un’altra tecnica:<br />

write not allocate. Questa tecnica fa riferimento ad un’osservazione di tipo pratico: siccome le<br />

scritture hanno una bassa frequenza (si verificano raramente), è vero che se si verifica un miss si<br />

dovrebbe prendere il blocco portalo in cache e scriverlo, ma questo miss si è verificato su una<br />

scrittura, allora forse questo dato che è stato modificato può darsi che non verrà utilizzato per tanto<br />

tempo; questo potrebbe giustificare l’idea che quando si presenta una scrittura si va a modificare<br />

soltanto l’informazione sulla main memory (non si pone il problema di modificarlo in cache perché<br />

non c’è, si è verificato un miss) e non si alloca il blocco sulla cache; in questo caso si scommette sul<br />

fatto che probabilmente quel blocco nel prossimo futuro non sarà acceduto.<br />

Vediamo come un blocco viene identificato in cache:<br />

in alto abbiamo l’indirizzo di<br />

CPU; quando la CPU emette<br />

l’indirizzo non dice quale parte è<br />

il campo tag, quale l’index e quale<br />

l’offset, perché non sa come sarà<br />

fatta la cache, ma è il cache<br />

controller che attribuisce il<br />

significato ai campi dell’indirizzo.<br />

Con l’index il cache controller<br />

seleziona dove dovrebbe stare<br />

l’informazione che sta cercando il<br />

processore. Si vede che sono state<br />

separate la parte della cache che<br />

contiene il tag e la parte della<br />

cache che contiene i dati, però<br />

questo è stato fatto mettendoli in corrispondenza: la parola 0 del chip dei tag contiene i bit per il<br />

blocco 0, ecc. L’offset serve a selezionare la word all’interno del blocco selezionato. Attraverso<br />

l’index viene selezionato oltre al blocco anche il suo tag (ci dice quale blocco di main memory, dei<br />

possibili, è presente nella cache) che deve essere uguale al campo tag dell’indirizzo del processore<br />

per verificarsi un hit; di conseguenza c’è un comparatore, e a seconda se c’è un hit o un miss il dato<br />

87


selezionato che proviene dalla cache passa e va a finire alla CPU. Se le due parti della cache fossero<br />

unite sequenzialerei il confronto del tag e il prelevamento del blocco: prima faccio il confronto e poi<br />

se il blocco è quello giusto vuol dire che devo leggere i dati (indirizzo la word); questo significa che<br />

si peggiorano le performance in termini di tempo di accesso alla cache.<br />

88


06/05/2004<br />

Esempio: fully associative.<br />

Non ci sono regole sul placement, quindi potenzialmente il blocco cercato può stare in qualunque<br />

punto della cache. Come faccio a dire dove può stare una cosa che cerca il processore? Se la<br />

scansione è sequenziale, la fully associative promette una performance migliore della direct mapped<br />

e inoltre il miss rate migliora. Perché? Nella direct mapped non ho alcuna scelta di dove mettere il<br />

blocco e quindi se ho un miss devo buttare il blocco dove andrà a finire quello preso dalla main<br />

memory: ci sono dei conflitti, o ci sta uno oppure un altro. Nella fully associative questo problema<br />

non c’è, potrei decidere di allocare quattro blocchi e potrei metterli tutti e quattro in cache. Come<br />

faccio la fase di ricerca del blocco? Se la faccio sequenziale devo fare tanti accessi. Alternativa: la<br />

faccio in parallelo.<br />

Il campo index si è ridotto, il campo tag si allarga (perché qualunque blocco della main memory<br />

può finire in qualunque punto della cache ⇒ ho bisogno di maggiore informazione per sapere dove<br />

è finito).<br />

Organizzo la cache come parte dati e parte tag:<br />

A livello hardware è difficile farlo; inoltre si spende tempo. Ne segue che la fully associative si può<br />

parallelizzare, però costa in termini economici e di tempo. Ciò nonostante la fully associative non<br />

viene esclusa del tutto. Non si può fare una scelta a priori. Bisogna vedere quale programma deve<br />

girare su quella macchina.<br />

Set associative cache<br />

Poiché l’index individua il set devo procedere in parallelo per vedere quale tra i blocchi del set è<br />

quello che contiene l’informazione che voglio. Suppongo di avere una cache associativa a due vie<br />

(ogni insieme ha due blocchi). Il numero di vie significa quanti blocchi ci sono dentro un insieme, il<br />

campo index mi dice dove cercare in cache e il campo tag mi dice quello che c’è dentro la cache<br />

(quale blocco è). La cache viene divisa in due parti: in una mettiamo i blocchi 0 di tutti i set,<br />

nell’altra i blocchi 1 di tutti i set (un set quindi è in orizzontale).<br />

Ogni blocco di dati ha<br />

associato il corrispondente tag.<br />

Seleziono entrambi i blocchi e<br />

in parallelo accedo anche ai<br />

tag di quei blocchi, li<br />

confronto, quindi li mando in<br />

un comparatore che farà uscire<br />

1 o 0 a seconda che il blocco è<br />

presente oppure no. C’è un<br />

multiplexer (il dato può<br />

arrivare da una delle due vie), che seleziona il blocco che ha dato l’hit; l’hit tramite l’OR ci dice se<br />

il blocco è presente oppure no.<br />

89


2-way Set Associative, address to select word<br />

Qui le due vie sono una sull’altra:<br />

confronto con due comparatori diversi i<br />

tag; se uno dei due confronti è vero,<br />

tramite il multiplexer preleverò il dato<br />

da ricercare e lo porterò in CPU.<br />

Questo multiplexer che non è presente<br />

nella direct mapped introduce un ritardo<br />

e questo può dare problemi e portare<br />

alla necessità di dilatazione del clock<br />

della CPU. Questa dilatazione può far sì<br />

che la direct mapped, che ha un miss<br />

rate più alto, ma non ha questo<br />

multiplexer, potrebbe avere un TCPU più<br />

piccolo di quello della set associativa.<br />

Quindi il miss rate più elevato non<br />

sempre significa avere TCPU più alti.<br />

Il dato è disponibile solo dopo che sono<br />

terminati tutti i confronti. Nel caso del<br />

direct mapped il dato può uscire in<br />

parallelo al compare perché non deve<br />

attraversare il multiplexer ⇒ si anticipa<br />

la fornitura del dato al processore ⇒ la direct mapped è più semplice ⇒ è più veloce della set<br />

associativa.<br />

All’aumentare dell’associatività aumenta la complessità, quindi aumentano dimensione e latenza.<br />

Riepilogando gli svantaggi di una set associativa rispetto ad una direct mapped sono:<br />

• abbiamo bisogno di N comparatori rispetto ad uno;<br />

• maggiori ritardi dovuti al multiplexer;<br />

• il dato arriva dopo l’Hit/Miss decision e la selezione del set; ne segue che la velocità di accesso<br />

al dato da parte del processore è penalizzata (oltretutto per considerare un dato sicuro è<br />

necessario che il processore lo possieda per un certo tempo).<br />

Structural hazard<br />

Nel caso di gerarchia di memoria è preferibile, dal punto di vista del miss rate, una cache unificata o<br />

separata?<br />

Usare una cache unificata per dati e istruzioni può essere non conveniente. Come abbiamo visto una<br />

memoria unificata presenta un hazard strutturale con le load/store. Un modo per risolvere il<br />

problema è dividere la cache in cache dati e cache istruzioni. La CPU sa se l’indirizzo riguarda dati<br />

o istruzioni. La cache separata ci permette di ottimizzare separatamente ogni singola cache:<br />

differenti capacità, dimensioni dei blocchi, associatività.<br />

La seguente tabella mostra i vari miss rate per i vari tipi di cache:<br />

poiché i valori sono riferiti alla cache<br />

unificata bisogna confrontare i dati<br />

nell’instruction cache e data cache di 16k con<br />

quelli della cache unificata di 32k.<br />

90


Per calcolare il miss rate medio della cache separata dobbiamo conoscere la percentuale di accesso<br />

in memoria per i due tipi di cache.<br />

Con cache separate raddoppia l’hardware, replico i segnali di controllo, ecc… I miss sui dati e sulle<br />

istruzioni non avvengono contemporaneamente. Se le unisco avrei un miss rate più basso di quello<br />

dei dati, ma più alto di quello delle istruzioni, ne segue che devo separale, altrimenti tutte le volte<br />

che accedo in memoria perdo un colpo di clock.<br />

Cache performance<br />

Vediamo come valutare l’impatto che ha la gerarchia di memoria sul CPU time. Il nostro obiettivo è<br />

ottimizzare la performance dal punto di vista della CPU.<br />

CPU time = (CPU execution clock cycles + Memory stall clock cycles) × clock cycle time<br />

Memory stall clock cycles = Memory accesses × Miss rate × Miss penalty<br />

(In questa formula il miss penalty deve essere espresso in cicli di clock)<br />

CPU time = Ic × (CPIexecution + Mem accesses per instruction × Miss rate × Miss penalty) × Clock<br />

cycle time<br />

Supponiamo di dimezzare il periodo di clock:<br />

• Ic non varia<br />

• CPIexecution, non varia perché è in termini di numero di cicli di clock e non in termini di<br />

frequenza<br />

• Mem. accesses per instruction, non varia (a parità di programma) perché dipende dal benchmark<br />

che è lo stesso per entrambe le versioni<br />

• Miss rate, non varia perché è la frazione di volte in cui non trova il dato rispetto agli accessi<br />

totali<br />

• Miss penalty, varia perché in questa formula è misurato in cicli di clock.<br />

In particolare il miss penalty raddoppia perché raddoppia il numero di cicli per raggiungere il<br />

ritardo introdotto dalla main memory.<br />

Importantissimo:<br />

CPUtime = Ic × (CPIexecution + Misses per instruction × Miss penalty) × Clock cycle time<br />

Se dimezziamo la frequenza del ciclo di clock: CPIexecution non varia perché dipende dal numero di<br />

cicli di clock, il numero di miss non varia perché dipende dal benchmark, il miss penalty (è espresso<br />

in cicli di clock) raddoppia perché è un numero diviso il periodo, perciò si avrebbe:<br />

new CPU time = Ic× CPIexecution × T/2 + Ic × miss per instruction × 2 miss penalty × T/2<br />

Il secondo addendo rimane invariato, il primo si dimezza ma se il contributo del CPI non era elevato<br />

non è cambiato molto! Se diminuisco il numero di cicli di clock varia il CPI ma non gli altri<br />

elementi. Il CPU time migliora (ma di poco perché il secondo addendo è predominante rispetto al<br />

primo). Il secondo addendo infatti non varia perché il miss penalty raddoppia e il periodo di clock si<br />

dimezza.<br />

91


Esercizio<br />

.data<br />

vett: .space 20<br />

msg_input: .asciiz “\nNum?”<br />

msg_output: .asciiz “\nN: %d”<br />

.align 2<br />

arg_printf .word msg_output<br />

num: .space 4<br />

.text<br />

.global main<br />

main:<br />

addi r2,r0,0<br />

addi r5,r0,5<br />

loop_input:<br />

addi r1,r0,msg_input<br />

jal InputUnsigned<br />

slei r7,r1,7<br />

andi r8,r1,1<br />

or r9,r7,r8<br />

beqz r9,falso<br />

sw vett(r2),r1<br />

addi r2,r2,4<br />

falso:<br />

subi r5,r5,1<br />

bnez r5,loop_input<br />

srli r10,r2,2<br />

addi r2,r0,0<br />

loop_output:<br />

beqz r10,fine<br />

lw r11,vett(r2)<br />

sw num,r11<br />

addi r14,r0,arg_printf<br />

trap 5<br />

subi r10,r10,1<br />

addi r2,r2,4<br />

j loop_output<br />

fine: trap 0<br />

Cicli di clock totali = 356<br />

Ic = 224<br />

FCK = 1 GHz<br />

1) Caso ideale (dati presi dal DLX): TCPU = Ncicli_totali × TCK = 356 × 1 ns =356 ns<br />

CPI = Ncicli_totali / Ic = 356/224 = 1,59<br />

11/05/2004<br />

92


2) Cache unificate: cache da 1k, blocco da 16 byte, associatività 2 e miss penalty pari a 30 cicli di<br />

clock. Col dinero ottengo Nmiss = 450<br />

TCPU = (Ncicli_totali + Nload/store + Nmiss × miss penalty) × TCK = (356+65+450×30) × 1 ns = 1,39 µs<br />

CPI = (Ncicli_totali + Nload/store + Nmiss × miss penalty)/Ic = 13921/224 = 62,1<br />

3) Cache separate: cache dati da 1k, blocco della cache dei dati da 32 byte, associatività 4 della<br />

cache dei dati e miss_penalty_dati pari a 30 cicli di clock; cache delle istruzioni da 2k, blocco<br />

della cache delle istruzioni da 32 byte, associatività 4 della cache delle istruzioni e<br />

miss_penalty_istru pari a 40 cicli di clock. Col dinero ottengo: Nmiss_dati = 17 e Nmiss_istr = 114<br />

TCPU=(Ncicli_totali+Nmiss_dati×miss_penalty_dati+Nmiss_istr×miss_penalty_istr)×TCK=<br />

=(356+17×30+114×40) × 1 ns = 5,43 µs<br />

CPI = (Ncicli_totali + Nload/store + Nmiss × miss penalty)/Ic = 5426/224 = 24,22<br />

93


Esempi su come calcolare lo speedup.<br />

Esempio<br />

Tv<br />

Tn<br />

CT<br />

1 h 4 h<br />

CT Roma<br />

1 h 2 h<br />

Calcoliamo lo SE e la FE :<br />

FE = 4/5<br />

SE = 4/2<br />

Speedup = 1/(1- FE + FE / SE) = 5/3<br />

13/05/2004<br />

Esempio:<br />

ponendoci nel caso visto la scorsa lezione con le cache unificate, calcolare lo Speedup complessivo<br />

qualora la miss penalty della cache unificata diventi 15 cicli.<br />

FE = (450 • 30 • TCK)/[(356 + 65 + 450 • 30) • TCK] = 13500/13921 = 0,97<br />

SE = (450 • 30 • TCK)/(450 • 15 • TCK) = 2<br />

Speedup = 1/(1 - FE + FE / SE) = 1,94<br />

Esempio<br />

Calcolare lo speedup ottenuto qualora si faccia una modifica che abbia come effetto il ridurre a 1/3<br />

il numero di miss nella cache dei dati (caso cache separate dell’esercizio della scorsa lezione).<br />

TCPU<br />

Roma<br />

TCPU<br />

FI<br />

FI<br />

(356 + 65 + 450×30) × TCK<br />

TCPU_ideale hazard strutturali (load/store) Nmiss • miss penalty<br />

(356 + 17 × 30 + 114 × 40) × TCK<br />

TCPU_ideale Nmiss_dati • miss_penalty_dati Nmiss_istr • miss_penalty_istr<br />

FE = (17 • 30 • TCK)/[(356 + 17 • 30 + 114 • 40) • TCK] = 13500/13921 = 0,09<br />

SE = (17 • 30 • TCK)/(17/3 • 30 • TCK) = 3<br />

Speedup = 1/(1 - FE + FE / SE) = 1,05<br />

Utilizzando dinero, che è un cache simulator, abbiamo visto come è possibile andare a vedere come<br />

variano le performance della gerarchia di memoria al variare dei vari parametri della gerarchia<br />

stessa. Questi parametri quali potrebbero essere? Se io ho una cache organizzata con un certo<br />

numero di blocchi, che cosa succede per esempio al miss rate, o all’average memory-access time, se<br />

faccio variare la dimensione del blocco a parità di dimensione di cache? Oppure ho una cache direct<br />

mapped di una certa dimensione, che cosa succede a parità di dimensione se la faccio diventare set<br />

associative a 2 vie, oppure a 4 vie? In poche parole che cosa succede se aumento il livello di<br />

associatività?<br />

Ci sono delle tecniche che posso individuare per migliorare il miss rate, il miss penalty e l’hit time?<br />

Ricordiamo che l’average memory-access time è dato dalla somma dell’hit time e del prodotto di<br />

miss rate e miss penalty. È ovvio che per migliorare l’average memory-access time, quindi per<br />

diminuire il tempo di accesso, posso intervenire cercando di abbassare l’hit time, oppure posso<br />

94


intervenire sul miss rate o sul miss penalty, perché abbassando uno o l’altro il prodotto miss rate per<br />

miss penalty diminuisce.<br />

Proviamo a vedere quali tecniche promettono di ridurre il miss rate.<br />

A questo punto bisogna parlare delle origini dei misses: quando voglio inventare una tecnica per<br />

cercare di migliorare le prestazioni, mi devo chiedere perché si verificano i misses. Sono state<br />

identificate tre cause che possono produrre un miss; queste cause vanno sotto il nome delle 3 C:<br />

• Compulsory: è chiamato anche cold start misses. Quando per la prima volta si va a<br />

referenziare un item, questo potrebbe benissimo non essere in cache; questo succede perché<br />

sfruttiamo il principio di località, e quindi diciamo che se accediamo a qualcosa, nel caso della<br />

località spaziale, sicuramente gli item vicini hanno un’alta probabilità di essere acceduti; ma la<br />

prima volta che si accede a qualcosa e che non era vicino a qualcos’altro probabilmente ancora<br />

in cache non c’è.<br />

• Capacity: i misses dovuti a questa causa sono spiegabili col principio di località; se ho una<br />

cache da 4k avrò un miss rate legato a questa capacità; se utilizzo una cache da 256k piuttosto<br />

che da 4k significa che posso mettervi dentro molti più blocchi di main memory, e quindi la<br />

probabilità di miss diminuisce.<br />

• Conflict: è legato al conflitto che si va a generare su certi tipi di organizzazioni di cache; per la<br />

direct mapped abbiamo visto che ci sono blocchi di main memory che vanno a finire sullo stesso<br />

blocco di cache. Questa competizione genera un conflitto perché nel caso in cui un programma<br />

va ad accedere a blocchi in conflitto tra di loro ogni volta solo uno di questi può stare in cache.<br />

Questo fenomeno si attenua man mano che aumenta l’associatività, perché se c’è un conflitto a<br />

livello di blocchi e non di set, all’interno di un set di una set associativa abbiamo più alternative<br />

di dove piazzare un singolo blocco; questo significa che ho una minore competitività tra i<br />

blocchi e questo significa anche che laddove due blocchi acceduti da un programma dovessero<br />

finire sullo stesso set non avrei problemi di conflitto. Aumentando l’associatività il problema<br />

diventa sempre minore perché il conflitto diminuisce e quindi significa che i misses legati a<br />

questo problema diminuiscono.<br />

Per quanto riguarda queste tre cause abbiamo un diagramma che riporta al variare della dimensione<br />

della cache il miss rate:<br />

riportiamo il miss rate facendo<br />

variare all’interno della cache<br />

l’associatività (partiamo da<br />

una direct mapped (1-way) e<br />

arriviamo ad una set<br />

associativa a 8 vie). La fully<br />

associativa è quella che<br />

elimina ogni tipo di conflitto.<br />

Abbiamo visto anche che per<br />

una fully associativa gestire<br />

l’hardware è complicato. Si è<br />

dimostrato che il<br />

miglioramento è significativo<br />

in termini di conflitto man<br />

mano che si passa da una<br />

direct mapped verso la fully<br />

associativa. Andando oltre le 8 vie si vede che non si ha più nessun tipo di miglioramento, e questo<br />

si vede al solito con l’approccio basato su misure, ovvero si vede che con moltissimi programmi<br />

con una set associativa a 8 vie di fatto non ci sono più quasi completamente conflitti. Questo<br />

significa che una memoria pienamente associativa non ha motivo di esistere, ma al massimo si<br />

arriverà ad una set associativa a 8 vie.<br />

95


Nel grafico si vedono le varie componenti di miss. La linea in rosso rappresenta il compulsory miss:<br />

si vede che al variare della dimensione della cache i misses legati al compulsory restano costanti;<br />

questo succede perché il compulsory non dipende dalla dimensione della cache, ma è legato al fatto<br />

che quando si va a referenziare per la prima volta qualcosa in cache si ha un miss.<br />

Un’altra componente è quella relativa al coflict miss: le strisce di colore diverso fanno vedere cosa<br />

sommo in termini di misses legati ai conflitti alla curva legata alla memoria set associativa a 8 vie;<br />

spostandoci verso la direct mapped il miss rate aumenta. Tutta la striscia compresa tra la direct<br />

mapped e la set associativa a 8 vie (colori: blu, giallo, viola e azzurro) è quella legata ai conflitti.<br />

La striscia in verde è quella legata al capacity miss. Ovviamente tutte le curve assumono un<br />

andamento decrescente all’aumentare della dimensione della cache.<br />

Vediamo una regola pratica:<br />

consideriamo per esempio una cache da 4k organizzata in modo direct mapped; su questa<br />

sperimentiamo un determinato miss rate.<br />

Ma se aumentassimo l’associatività per<br />

caso lo stesso miss rate lo potremmo<br />

sperimentare con una cache più piccola?<br />

La risposta è sì: se consideriamo la set<br />

associativa a 2 vie abbiamo una cache di<br />

dimensione circa 2k. Questo significa<br />

che il miss rate che ottengo con una<br />

cache direct mapped di 4k è pari al miss<br />

rate che ottengo con una cache di 2k<br />

organizzata in modo set associativo a 2<br />

vie.<br />

A questo punto cominciamo a vedere quali tecniche possiamo inventarci per ridurre il miss rate,<br />

sapendo che quest’ultimo è dovuto alle tre cause viste sopra.<br />

Poniamoci delle domande:<br />

• se a parità di dimensione della cache faccio variare la dimensione del blocco, qualcuna delle tre<br />

cause è influenzata?<br />

• se io cambio l’associatività quale delle tre cause è coinvolta?<br />

• se agisco a livello di compilatore quale delle tre cause è influenzata?<br />

Cominciamo con la prima tecnica: variamo la dimensione dei blocchi.<br />

Abbiamo 5 casi, corrispondenti<br />

a 5 dimensioni di cache.<br />

Osserviamo che c’è una fase<br />

iniziale, comune a tutte e 5 le<br />

curve, in cui all’aumentare della<br />

dimensione del blocco<br />

diminuisce il miss rate.<br />

Aumentare la dimensione dei<br />

blocchi provoca il fatto che<br />

dentro ognuno di essi ci entrano<br />

più informazioni; questo<br />

significa che la componente di<br />

miss che probabilmente<br />

miglioro è il compulsary: se io<br />

ho un cold start miss e<br />

trasferisco sulla cache un blocco<br />

grande è probabile che anche gli altri item che non sono stati referenziati li trovo in cache proprio in<br />

96


virtù del principio di località, e quindi per questi item non avrò un cold start miss. Tutto questo è<br />

vero fino ad un certo punto, perché tutto va relazionato alla dimensione della cache: se continuo a<br />

far crescere la dimensione del blocco il risultato è che all’interno della cache avrò pochissimi<br />

blocchi per cui comincia il problema del conflict miss.<br />

Siamo sicuri che riducendo il miss rate gli altri due componenti, hit time e miss penalty, non<br />

peggiorino? La cosa che peggiora sicuramente è il miss penalty: fare un blocco più ampio significa<br />

che quando c’è un miss aumenta la quantità di informazione che si deve trasferire dalla main<br />

memory alla cache, e quindi aumenta il miss penalty.<br />

La seconda tecnica per diminuire il miss rate è quella di aumentare l’associatività. Se aumento<br />

l’associatività diminuiscono i conflict miss. Però all’aumentare dell’associatività peggiora l’hit<br />

time: in una set associativa ci deve essere un multiplexer tra cache e CPU, quindi mi trovo una<br />

latenza che sperimento nella maggior parte delle volte (tutte le volte che c’è un hit quel multiplexer<br />

deve essere attraversato).<br />

Osserviamo la seguente tabella: vediamo come varia l’average memory-access time (A.M.A.T.) al<br />

variare della dimensione della cache e al variare dell’associatività. Per una cache da 1k<br />

all’aumentare dell’associatività il<br />

tempo medio di accesso alla cache<br />

diminuisce perché diminuisce il<br />

conflict miss. Tutto questo è vero<br />

finché non arriviamo ad una cache<br />

da 8k dove si vede che passando da<br />

una direct mapped ad una set<br />

associativa a 2 vie peggiora<br />

l’A.M.A.T. perché peggiora l’hit<br />

time (questo è legato al periodo di<br />

clock del processore che si allunga<br />

passando da direct mapped a set<br />

associativa a 2 vie, a 4 vie, ecc…).<br />

si vede che a partire da una cache<br />

da 16k aumentare l’associatività<br />

non porta alcun beneficio.<br />

La seguente tecnica è una tecnica che segna un punto di svolta rispetto alle tecniche precedenti,<br />

perché fa migliorare il miss rate senza intervenire né sull’hit time né sul miss penalty.<br />

Questa tecnica si chiama Victim cache (cache vittima). Ho la mia cache (tag e dati); quando ho un<br />

miss devo sostituire un blocco il che implica<br />

prendere questo blocco e portarlo in main memory e<br />

quello giusto dalla main memory lo porto in cache.<br />

Qualcuno ha pensato: il blocco che tolgo piuttosto<br />

che portarlo in main memory lo lascio vicino alla<br />

cache, perché se era in cache vuol dire che era stato<br />

acceduto, e se lo si toglie può essere causa di miss.<br />

Allora si organizza una piccola memoria fully<br />

associativa da associare alla cache in modo tale che<br />

quando si scarta un blocco dalla cache lo piazzo in<br />

questa cache fully associativa piuttosto che nella<br />

main memory. Questo significa che adesso quando<br />

si ha un miss piuttosto che andare in main memory a<br />

cercare il blocco prima si va a vedere se si trova in<br />

questa cache fatta da pochi blocchi.<br />

Jouppi, che è quello che un po’ ha inventato questa<br />

memoria vittima, ha dimostrato che con una<br />

97


memoria associativa con soltanto 4 entry, cioè con solo 4 blocchi, organizzata in maniera fully<br />

associativa, si rimuoveva dal 20% al 95% dei conflitti di una cache direct mapped da 4KB. Questa<br />

tecnica è stata utilizzata concretamente in macchine reali.<br />

La quarta tecnica è quella che si chiama pseudo-associativa. Abbiamo già visto che in una<br />

memoria set associativa, per esempio a 2 vie, abbiamo bisogno di un multiplexer e di fare in<br />

parallelo i due confronti dei tag; tutto questo fa si che il tempo di accesso a quella memoria sia più<br />

lungo che un accesso ad una memoria direct mapped; d’altra parte è anche vero che la set<br />

associativa a 2 vie ha meno conflitti. Quindi a me piacerebbe avere il tempo di accesso di una direct<br />

mapped e i conflitti di una set associativa. Organizzo una memoria associativa a 2 vie, ma il cache<br />

controller la gestisce come se fosse direct mapped. Quando si va ad individuare il set attraverso<br />

l’index, ma nel set ci sono due blocchi (se è associativa a 2 vie) e di conseguenza viene fatto un<br />

confronto dei tag in parallelo; allora diamo una sorta di default: si va a cercare sempre il primo dei<br />

due blocchi, così è come se si facesse sempre un accesso diretto. Quello che può succedere è che se<br />

ci va bene risparmiamo tempo, mentre se ci va male dobbiamo andare a controllare l’altro blocco.<br />

Sicuramente beneficiamo di una riduzione dei miss legati ai conflitti, sicuramente il tempo medio<br />

legato di accesso è più basso, però c’è un problema: se ci va bene impieghiamo un tempo, mentre se<br />

ci va male impieghiamo un tempo un po’ più lungo.<br />

Questa tecnica ci tornerà utile piuttosto che come cache di primo livello, ma come cache di secondo<br />

livello, dove il processore non accede direttamente.<br />

Un’altra tecnica utilizzata per la riduzione del miss rate è quella che si chiama hardware<br />

prefetching. Se c’è un miss significa che si deve andare nella main memory per prendere un blocco<br />

e portarlo in cache; siccome c’è sempre il principio di località forse anche il blocco successivo di<br />

main memory potrebbe servire; naturalmente non lo prendo per andare a rimpiazzare un blocco che<br />

già c’è in cache, però faccio un prefetching: il blocco su cui c’è stato un miss lo alloco in cache,<br />

mentre il blocco successivo lo vado a mettere in uno stream buffer, che è un buffer piccolo e veloce<br />

che sta vicino alla cache, così è pronto per essere acceduto. Allora quello che si fa è che se c’è un<br />

miss prima di andare in main memory si va a vedere se nello stream buffer c’è qualcosa che ci<br />

interessa. Jouppi ha dimostrato che un data stream buffer si riusciva a catturare il 25% di miss da<br />

una cache di 4KB; con 16 streams fino al 72% dei miss. Il prefetching va a diminuire la banda della<br />

main memory.<br />

L’ultima tecnica è quella che riguarda il compilatore. Abbiamo già visto per esempio nel caso del<br />

pipeline come il compilatore ci può aiutare a ridurre il data hazard ristrutturando il codice. Per<br />

quanto riguarda la località si può fare qualcosa col compilatore? Consideriamo il caso dei dati: un<br />

array se si potesse portare tutto in cache non avrei problemi; ma se l’array è di una certa dimensione<br />

non si riesce a portarlo tutto in cache; ma se io accedo a righe successive e l’array nella main<br />

memory è memorizzato per colonne caricando le colonne commetterei una sciocchezza perché gli<br />

elementi contigui non sono le colonne. Questo significa che c’è un’incompatibilità tra come è<br />

memorizzato l’array e come sarebbe meglio memorizzarlo per cercare la località. Allora il compito<br />

del compilatore è ristrutturare il codice in modo tale che invece che fare l’accesso per righe<br />

successive lo si fa per colonne.<br />

Nella figura accanto è riportato cosa si ottiene<br />

quando il compilatore adotta alcune tecniche<br />

per ottimizzare la località. Si vede come il<br />

perfomance improvement man mano che si<br />

utilizzano diverse tecniche può essere di 1,5-<br />

2,5. E questo è legato solo al compilatore.<br />

98


18/05/2004<br />

Abbiamo visto che l’average memory-access time è dato dalla somma di due contributi: hit time e<br />

prodotto tra miss rate e miss penalty. È chiaro che ridurre l’hit time, il miss rate, e il miss penalty<br />

sono tutti e tre obiettivi nobilissimi, ma non sempre si riesce a ridurne uno senza andare a<br />

influenzarne un altro.<br />

Passiamo alle tecniche per la riduzione del miss penalty.<br />

La prima tecnica è quella che parla del read priority over write on miss. Supponiamo che<br />

utilizziamo una cache con politica di scrittura write through, ovvero quando si fa una scrittura in<br />

cache si scrive anche in main memory, e per evitare di penalizzare il processore a causa della<br />

lentezza di quest’ultima si interpone tra cache e main memory un write buffer; ci sono dei casi in<br />

cui le cose si complicano: c’è un miss in lettura, supponiamo, e quello che dovrebbe accadere è che<br />

il cache controller va in main memory individua il blocco cercato e lo va a piazzare in cache; ma<br />

siamo sicuri che il blocco che va a prendere in main memory e porta in cache è aggiornato?<br />

Potrebbe essere non aggiornato perché magari l’aggiornamento sta ancora nel write buffer e non c’è<br />

stato ancora il tempo di andarlo a copiare sulla main memory. A questo punto se si volesse usare<br />

una politica di tipo conservativo dovrebbe succedere che tutte le volte che c’è un read miss prima di<br />

andare in main memory e prendere il blocco da portare in cache si aspetta che si svuoti tutto il write<br />

buffer; se il write buffer è a quattro posizioni significa che ogni volta che c’è un miss in lettura, che<br />

sono i miss più frequenti dato che le letture sono più frequenti delle scritture, sperimento un miss<br />

penalty che è molto più lungo di quello che sarebbe richiesto se andassi direttamente in main<br />

memory. Per abbassare il miss penalty piuttosto che aspettare di svuotare il write buffer si potrebbe<br />

andare a vedere se c’è conflitto tra il blocco che devo portare in cache dalla main memory e i<br />

blocchi che sono presenti nel write buffer; conflitto significa che il blocco che sto cercando è<br />

presente all’interno del write buffer. La tecnica nominata su ci dice di dare la priorità alle letture<br />

sulle scritture: se c’è quel tipo di conflitto è inevitabile che si deve aspettare di svuotare il write<br />

buffer, mentre se il conflitto non c’è prima di svuotare il write buffer vado ad asservire il read miss,<br />

cioè vado in main memory prendo il blocco e lo porto in cache.<br />

Adesso supponiamo di avere una cache con politica di scrittura write back; quando c’è un miss in<br />

lettura bisogna andare a caricare un blocco dalla main memory e scartarne uno dalla cache; il blocco<br />

scartato dalla cache ha un bit (clean or dirty) attraverso il quale si sa se copiare questo blocco in<br />

main memory per aggiornarlo oppure no. In questo caso se c’è un read miss si scarica tutto il blocco<br />

dalla cache su un write buffer e si fa avanzare il read miss, cioè si dà la priorità alla lettura sulla<br />

scrittura.<br />

La seconda tecnica si chiama subblock placement. Una componente del miss penalty è legata al<br />

tempo di trasferimento delle informazioni del blocco dalla main memory alla cache, e questo<br />

significa che se ho un bus da 32 bit e ho un blocco di 4 word da 32 bit devo fare quattro<br />

trasferimenti. Allora si potrebbe pensare di fare blocchi di dimensione più piccola, però questo<br />

significa che a parità di dimensione di cache aumenta il numero di tag; questo potrebbe non essere<br />

un problema se la cache fosse esterna al processore, ma visto che normalmente c’è un pezzo di<br />

cache integrate nel processore sprecare spazio per i tag potrebbe essere troppo oneroso. Per cui si<br />

cerca di coniugare queste due esigenze (da un lato avere blocchi di piccola dimensione per<br />

diminuire il miss penalty e dall’altro evitare che ci siano troppi bit per i tag) e per questo è nata la<br />

tecnica del subblock placement: ogni blocco della cache (direct mapped) viene diviso in<br />

sottoblocchi, per esempio nel nostro caso in 4<br />

sottoblocchi, e a tutto il blocco viene associato<br />

un unico tag. Facciamo un esempio in lettura:<br />

se accedo ad un’informazione, in parallelo<br />

confronto il tag e accedo alla word; per ognuna<br />

delle word c’è un bit associato, che viene<br />

chiamato bit di validità; se questo bit è 1 e il<br />

confronto dei tag è corretto vuol dire che la<br />

99


word è quella cercata e quindi non si verifica un miss; se il bit di validità è 0 vuol dire che la word<br />

non è valida e quindi c’è un miss; in quest’ultimo caso si accede alla main memory si trasferisce<br />

soltanto il sottoblocco interessato (tipicamente ha la dimensione di una word) e quindi il transfer<br />

time tra main memory a cache è limitato soltanto ad un sottoblocco e di conseguenza sto abbattendo<br />

il miss penalty. Nel caso in cui il confronto dei tag non dà esito positivo vuol dire che c’è un miss;<br />

in questo caso si va in main memory si trasferisce la word cercata e contemporaneamente si cambia<br />

il tag mettendo quello corretto, e si mettono a 0 tutti gli altri bit di validità che eventualmente sono<br />

messi a 1.<br />

La terza tecnica è la seguente: quando ho un miss, in generale, prendo un blocco dalla main<br />

memory lo porto in cache e poi il processore può accedere alla word cercata; per esempio se<br />

abbiamo blocchi di 8 word e il processore sta accedendo alla word 5, quando c’è il miss il<br />

processore deve aspettare che il blocco venga trasferito tutto in cache; in alternativa si potrebbe<br />

pensare: il blocco viene trasferito word dopo word, o a gruppi di word a seconda del parallelismo<br />

tra main memory e cache, e non appena la word cercata è stata copiata in cache il processore vi può<br />

accedere e il cache controller può continuare a copiare il resto del blocco. Questo comporta un<br />

abbassamento del miss penalty. Questa tecnica è quella che viene chiamata Early Restart.<br />

Qualcuno ha detto un’altra cosa ancora: se c’è un miss su una word, perché non si fa in modo che la<br />

prima word del blocco che si deve trasferire non è proprio quella che sta cercando il processore?<br />

Facendo così si trasferisce la prima word, il processore la legge e poi il cache controller continua a<br />

trasferire l’intero blocco dalla main memory. Fare questo significa complicare l’hardware del cache<br />

controller perché deve essere in grado di fare non sempre la stessa operazione, ma l’algoritmo di<br />

trasferimento dipende dalla word cercata. C’è un problema: data la word al processore il cache<br />

controller deve poi continuare a trasferire il resto del blocco nella cache, però questo lo fa alla<br />

velocità della main memory, e quindi se è vero che esiste il principio di località spaziale è probabile<br />

che la prossima word a cui accederà il processore sarà la word vicina a quella per cui c’è stato il<br />

miss, ma questa word non è ancora in cache; quindi probabilmente da un lato diminuisce il miss<br />

penalty, ma dall’altro sto aumentando il miss rate.<br />

Un’altra tecnica è quella di organizzare la cache a più livelli. Il progettista normalmente si trova<br />

davanti ad un dilemma: avere cache molto veloci (di conseguenza piccole), ma grandi per avere un<br />

basso miss rate.<br />

Dalla figura accanto si vede come<br />

all’aumentare della dimensione della cache<br />

aumenti significativamente il tempo di accesso<br />

alla cache.<br />

Quindi dal punto di vista della velocità sarei<br />

portato a scegliere cache piccole perché sono<br />

più veloci, ma cache piccole significa elevato<br />

miss rate. Tra l’altro cache piccole significa che<br />

le posso anche integrare all’interno del<br />

processore, e questo implica che sono ancora<br />

più veloci, perché la maggior parte del tempo<br />

su un accesso in memoria si perde nella latenza<br />

che riguarda la comunicazione esterna tra<br />

processore e memoria.<br />

Qualcuno ha pensato di potere raggiungere entrambi gli obiettivi: sia cache veloci che cache grandi.<br />

Come? Organizzando la cache a due livelli: metto una piccola cache, che è quella che si interfaccia<br />

direttamente al processore (o esterna o interna al processore, e in quest’ultimo caso organizzata in<br />

modo direct mapped, che è la più veloce in assoluto), e tra questa piccola cache e la main memory<br />

interpongo un altro livello di cache. Quest’ultima cattura molti dei miss generati dalla piccola cache<br />

e quindi il miss penalty che pago è quello del trasferimento del blocco dal secondo livello di cache<br />

100


al primo, che essendo una RAM statica mi fa sperimentare un miss penalty più basso di quello che<br />

si sperimenta trasferendo un blocco dalla main memory, che è una RAM dinamica.<br />

Siamo sicuri che questa combinazione tra queste due cache effettivamente abbia un average<br />

memory-access time più basso che una singola cache magari più grande e meno veloce? Siamo<br />

sicuri che il miss rate complessivo sia più basso del miss rate che sperimenterei in una cache a<br />

livello unico?<br />

Facciamo dei conti: (L1 = livello 1, che è quello più vicino al processore; L2 = livello 2)<br />

AMAT = Hit TimeL1 + Miss RateL1 × Miss PenaltyL1<br />

Miss PenaltyL1 = Hit TimeL2 + Miss RateL2 × Miss PenaltyL2<br />

Sostituendo otteniamo:<br />

AMAT = Hit TimeL1 + Miss RateL1 × (Hit TimeL2 + Miss RateL2 × Miss PenaltyL2)<br />

In questo caso si definiscono un local miss rate e un global miss rate:<br />

• il local miss rate è la frazione di accessi che producono un miss diviso il numero di accessi a<br />

quello stesso livello di cache. In poche parole il miss rate di livello 2, che sarebbe il local miss<br />

rate per la cache di livello 2, è dato dal numero di accessi al livello 2 che generano un miss<br />

diviso il numero di accessi totali al livello 2. Gli accessi totali al livello 2 non sono tutti gli<br />

accessi del processore, ma sono solo quegli accessi del processore che hanno generato un miss<br />

sul livello 1. Il local miss rate nel livello 1 si definisce sempre allo stesso modo, però questa<br />

volta il numero di accessi a questo livello è il numero totale di accessi che genera il processore;<br />

• il global miss rate per un certo livello di cache è il rapporto tra il numero di miss che si<br />

sperimentano in quel livello diviso il numero totale di accessi in memoria generati dal<br />

processore. Nel caso della cache di livello 1 il local miss rate e il global miss rate coincidono;<br />

nel caso della cache di livello 2 questi due sono completamente diversi, e in particolare il global<br />

miss rate è dato dal prodotto Miss RateL1 × Miss RateL2.<br />

Se io vado a considerare il local miss rate della cache di livello 2, mi aspetto che questo sia elevato,<br />

perché siccome è il rapporto tra il numero di miss sul livello 2 e il numero di accessi sulla cache di<br />

livello 2, questi ultimi vengono scremati dalla cache di livello 1, quindi anche se ci sono pochi miss,<br />

questi diviso un basso numero di accessi mi dà un miss rate che può essere elevato.<br />

Siamo sicuri che miss rate nella cache a due livelli sia confrontabile con quello nella cache a un solo<br />

livello? A noi potrebbe andare bene che sia uguale, perché migliorando il miss penalty ci sto già<br />

guadagnando.<br />

Nel seguente grafico viene riportato il local miss rate del livello 2, che è intorno al 70% e poi va a<br />

scendere, e poi è riportato il confronto tra il global miss rate del livello 2 e il miss rate della cache a<br />

un unico livello della stessa dimensione:<br />

si assume che la cache di livello 1 sia di 32k, e si fa variare<br />

la dimensione della cache di livello 2. Si vede che il local<br />

miss rate fino a quando la dimensione della cache di<br />

secondo livello è minore di 32k è elevatissimo;<br />

all’aumentare della dimensione della cache di secondo<br />

livello il local miss rate diminuisce, però mantenendosi<br />

sempre a livelli di 15%-18%.<br />

Guardando il global miss rate si vede che, fino a quando la<br />

cache di secondo livello è più piccola o uguale alla cache<br />

di primo livello, questo è maggiore del miss rate della<br />

cache ad un solo livello, il che significa che ci stiamo<br />

perdendo. Aumentando la dimensione della cache di<br />

secondo livello il global miss rate diventa praticamente<br />

uguale al miss rate del caso di cache ad un solo livello. Da<br />

256k in su le due curve coincidono.<br />

101


Questi grafici dimostrano che la scommessa di tentare di fare una cache a due livelli si può vincere,<br />

perché in realtà se la cache di secondo livello si fa sufficientemente grande i miss rate sono<br />

confrontabili, e quello che ci guadagno è il miss penalty più basso, e anche il fatto che potendo<br />

integrare il primo livello all’interno del processore posso avere un clock cycle time più veloce.<br />

Il fatto di aver organizzato la cache a due livelli mi dà una maggiore libertà sulle tecniche di<br />

ottimizzazione per ridurre il miss rate che io posso implementare sulla cache di secondo livello.<br />

L’aumento dell’associatività sappiamo che riduce il miss rate, ma fa peggiorare l’hit time. Se io<br />

faccio una cache con un’elevata associatività e questa è la prima cache che vede il processore , il<br />

fatto che peggiora l’hit time significa far andare più lento il processore. Se io però ho un secondo<br />

livello di cache posso organizzare il primo livello come direct mapped, e per il secondo livello<br />

posso incrementare l’associatività per diminuire il miss rate, o le altre tecniche di cui abbiamo<br />

parlato la scorsa lezione, senza impattare negativamente sul processore. Quindi molte delle tecniche<br />

che abbiamo studiato che potrebbero non andare bene per una cache ad un unico livello,<br />

incominciano a trovare applicazione per una cache a due livelli.<br />

Vediamo alcune cose sulla riduzione dell’hit time.<br />

La prima cosa che consente di ridurre l’hit time è quella di fare cache semplici (direct mapped) e<br />

piccole.<br />

Adesso vediamo una tecnica che si propone l’hit time in scrittura. Quando accedo in cache per una<br />

lettura posso fare in parallelo il confronto del tag e l’accesso al dato, mentre se ho l’operazione di<br />

scrittura questo parallelismo non è possibile, perché se io confronto il tag e contemporaneamente<br />

scrivo, se il confronto del tag mi ha dà esito negativo modificherei qualcosa che non avrei dovuto.<br />

Questo vuol dire che mentre un hit per un’operazione di lettura potrebbe richiedere un ciclo di<br />

clock, un write dovrebbe chiederne due. Qualcuno si è inventato un pipeline write: quando si deve<br />

scrivere si organizzano le scritture in pipeline. Come? Supponiamo di avere un dato da scrivere in<br />

cache; utilizziamo un ciclo di clock per vedere se c’è un hit oppure no; in questo ciclo di clock oltre<br />

a fare quest’operazione di ricerca del tag verrà messo il dato da scrivere insieme all’indirizzo che<br />

serve per scrivere in cache in un buffer che si chiama<br />

Delayed Write Buffer. Quando scriverò quello che c’è nel<br />

write buffer in cache? Non appena c’è la prossima scrittura<br />

confronto il tag e in parallelo svuoto il write buffer in<br />

cache (nella parte dati; questa scrittura non è relativa a<br />

questo tag, ma a quello precedente) e il dato da scrivere in<br />

questa scrittura si copia nel delayed write buffer. Lo stesso<br />

si fa per le scritture successive.<br />

Se io accedo in cache in lettura e il dato che mi serve è<br />

quello che si trova ancora nel delayed write buffer leggerei<br />

un dato inconsistente. Quindi non solo devo organizzare<br />

questo pipelining, ma devo anche curarmi tutte le volte che<br />

c’è un miss il lettura di andare a vedere se quello che sto cercando è nel write buffer che ancora non<br />

è stato scaricato. Nella figura si vede che tag e dati sono separati; c’è il delayed write buffer dove si<br />

va a scrivere solo se c’è un hit; c’è un multiplexer all’ingresso della cache perché vi si può accedere<br />

o normalmente o attraverso il delayed write buffer.<br />

L’organizzazione a sottoblocchi della cache si presta a implementare un tecnica per migliorare l’hit<br />

time in scrittura. Confronto il tag e in parallelo scrivo; si possono verificare tre casi:<br />

• Se il bit di validità era 0 significa che ho scritto su qualcosa che non era valido; se il tag era<br />

quello giusto avrei dovuto scrivere proprio in quel punto, e quindi l’unica cosa da fare è mettere<br />

il bit di validità a 1.<br />

• Se il bit di validità era 1 e il tag era quello giusto non devo neppure modificare il bit di validità.<br />

• Il tag non è quello giusto; quello che faccio è scrivere il tag del dato che ho scritto in cache e<br />

metto a 0 tutti i bit di validità che sono a 1.<br />

Il risultato è che riesco a fare una scrittura ogni ciclo di clock.<br />

102


Guardiamo un quadro sinottico utile a rivedere tutto quello che abbiamo fatto per quanto riguarda<br />

le tecniche di ottimizzazione. Abbiamo quattro colonne: miss rate, miss penalty, hit time,<br />

complessità: + (migliora), – (peggiora)<br />

Main memory performance<br />

Nella formula dell’AMAT compare il miss penalty che dipende dall’access time alla main memory<br />

e dal transfer time tra main memory e cache. Posso usare qualche trucco per organizzare la main<br />

memory in modo tale che il miss penalty diminuisca? Si possono fare alcune cose.<br />

Ci sono tre soluzioni:<br />

La prima soluzione (a) è quella banale: abbiamo il processore, un bus della dimensione della word<br />

della CPU, poi c’è la cache che è organizzata a blocchi e ha parallelismo di una word (significa che<br />

con un accesso esce una word, e un blocco significa che occupa più word consecutive), poi un bus<br />

sempre di dimensione di una word e infine la main memory organizzata a blocchi come la cache.<br />

Con questa organizzazione cosa succede quando c’è un miss? Tutto il blocco della cache va<br />

sostituito e quindi si devono fare tanti accessi quante sono le word del blocco (dato che il bus è<br />

grande quanto una word). Quindi sperimento un miss penalty legato a tutti questi tempi di accesso e<br />

tempi di trasferimento.<br />

Un’organizzazione che mi riduca questo miss penalty è la (b): organizzo la cache e la main memory<br />

in modo da aumentare il loro parallelismo, cioè tutta una locazione di main memory e di cache<br />

coincide con un intero blocco, e poi si fa il bus di dimensione pari a tutto il blocco (se è fatto da 4<br />

word da 32 bit il bus deve essere da 128 bit). In questo caso quando c’è un miss faccio un accesso<br />

solo alla main memory; quindi abbasso il miss penalty perché il tempo di accesso lo pago solo una<br />

volta e lo stesso per il tempo di trasferimento. Il problema di questa organizzazione è la<br />

103


ealizzazione del bus. Pago un altro prezzo: quando il processore accede alla cache lo fa per leggere<br />

o per scrivere una word; se la cache adesso ha un parallelismo maggiore di una word quando vi<br />

accedo escono tutte le word della locazione, quindi devo selezionare la word che mi interessa<br />

attraverso un multiplexer. Facendo così peggioro l’hit time che è il caso più frequente, e quindi il<br />

miglioramento del miss penalty potrebbe essere vanificato.<br />

La terza tecnica (c) è quella del memory interleaving: piuttosto che organizzare un bus grande<br />

quanto quello della seconda tecnica, lo facciamo della dimensione di una word, e il parallelismo<br />

della cache anch’esso di una word; facendo così togliamo il multiplexer. Organizzo la main<br />

memory in banchi fisicamente separati; ogni banco ha un parallelismo di una word (ogni blocco ha<br />

una word su ogni banco). Quando si deve selezionare un blocco si deve mandare l’indirizzo di<br />

questo blocco a tutti i banchi della main memory, e questo lo faccio in parallelo; questo significa<br />

che con un solo accesso ottengo quattro word (se ho 4 banchi); quindi il tempo di accesso lo pago<br />

solo una volta, mentre il tempo di trasferimento lo pago 4 volte perché il bus è di una word.<br />

Esempio:<br />

supponiamo che per indirizzare la memoria ci voglia 1 ciclo di clock, 6 cicli per accedere (cioè il<br />

tempo di accesso è di 6 cicli di clock), e 1 ciclo per trasferire il dato; supponiamo che il blocco della<br />

cache sia formata da 4 word. Vediamo nei tre casi cosa succede:<br />

a) miss penalty = 4 × (1 + 6 + 1) = 32 cicli di clock<br />

b) miss penalty = 1 + 6 + 1 = 8 cicli di clock<br />

c) miss penalty = 1 + 6 + 4 × 1 = 11 cicli di clock.<br />

104


25/04/2004<br />

Riduzione del miss rate<br />

L’obiettivo è ridurre il tempo medio di accesso alla memoria che, considerando una gerarchia di<br />

memoria con un solo livello (CPU, cache e main memory), possiamo esprimere con la seguente<br />

formula: AMAT = Hit time + Miss rate × Miss penalty.<br />

Abbiamo visto che una tecnica di riduzione del miss rate è quella di fare i blocchi più grandi.<br />

Un’altra tecnica è quella di utilizzare cache con un più alto grado di associatività. Lo svantaggio di<br />

utilizzare questa tecnica è quello che aumenta l’hit time. Supponiamo di avere il processore che<br />

emette un indirizzo che viene partizionato in un campo offset (spiazzamento della parola all’interno<br />

del blocco), un campo index (che indirizza il set della cache in cui è contenuto il blocco che<br />

contiene la word che stiamo cercando) e un campo tag (permette di stabilire se sul blocco che<br />

abbiamo individuato è effettivamente mappata la parola che stiamo cercando). Consideriamo una<br />

tag index ofs<br />

addr<br />

µP<br />

cache direct mapped. Di solito realmente<br />

abbiamo due banchi suddivisi: banco dei<br />

1 W tag e il banco che contiene i dati. Con<br />

index indirizziamo entrambi i banchi che<br />

tags Block<br />

emettono in uscita tag e blocco; il tag lo<br />

confrontiamo con un comparatore col tag<br />

che viene fuori dall’indirizzo e questo<br />

. .<br />

confronto ci dice se c’è un hit o meno. Il<br />

blocco è composto da N word, in<br />

generale, invece il processore non richiede<br />

blocchi ma word, quindi c’è un<br />

MUX multiplexer che estrae dal blocco la parola<br />

tag Block<br />

che il processore richiede, e questo lo fa<br />

attraverso il campo ofs.<br />

Hit<br />

?<br />

N<br />

Aumentiamo il grado di associatività (per esempio a due vie) di questa cache: ho un altro banco per<br />

addr<br />

tag index ofs µP<br />

?<br />

?<br />

tags block<br />

. .<br />

tags block<br />

MUX<br />

Block<br />

. . MUX<br />

1 W<br />

N<br />

i tag e un altro banco per i<br />

blocchi. Tutti i blocchi 0 di<br />

tutti i set sono mappati nel<br />

primo banco dei dati e tutti<br />

i blocchi 1 sono mappati<br />

nell’altro banco; lo stesso<br />

vale per i tag. Con l’indice<br />

indirizzo parallelamente<br />

sia il banco 0 sia il banco<br />

1, e ognuno dei banchi<br />

risponderà con i tag e i<br />

blocchi. Parallelamente<br />

confronto i tag: uno dei<br />

due restituirà un hit oppure<br />

entrambi un miss; ho una<br />

logica che in base a come<br />

rispondono i due<br />

comparatori mi dà il<br />

105


segnale di selezione del multiplexer che mi fa passare il blocco 0 o il blocco 1. A questo punto ho il<br />

blocco che contiene la word che il processore vuole leggere, e poi un multiplexer mi seleziona la<br />

word interessata. Il multiplexer in più rispetto alla direct mapped fa sì che l’hit time aumenti. Più<br />

aumenta il numero di vie più ingressi ha il multiplexer e di conseguenza più lento è. (N.B. Anche se<br />

ho più comparatori non aumenta l’hit time perché questi lavorano in parallelo).<br />

Un’altra tecnica per ridurre il miss rate (senza effetti collaterali) è quella che utilizza le cache<br />

vittime. Un’altra tecnica che abbiamo visto è quella che fa uso delle cache pseudo-associative.<br />

Esistono altre tecniche per la riduzione del miss rate:<br />

• il prefetching delle istruzioni e/o dei dati (hardware prefetching);<br />

• il prefetching controllato dal compilatore;<br />

(entrambe hanno bisogno di un supporto hardware)<br />

• le ottimizzazioni di compilazione, che non richiedono modifiche hardware:<br />

♦ Merging Arrays;<br />

♦ Loop Interchange;<br />

♦ Loop Fusion;<br />

♦ Blocking.<br />

Hardware Prefetching<br />

Accanto alla cache abbiamo un altro buffer di memoria che può ospitare anche un solo blocco e<br />

funziona nel seguente modo: quando c’è un miss devo caricare un blocco dalla memoria alla cache;<br />

invece che caricare un solo blocco, carico quel blocco, ma ne carico anche un altro che non<br />

memorizzo in cache ma in quel buffer; questo lo faccio per sfruttare la località spaziale.<br />

Vediamo i vantaggi che si possono avere.<br />

Per l’Alpha 21064 con 8 KB di instruction cache, per un benchmark si è sperimentato un miss rate<br />

= 1.10%. Supponiamo di utilizzare un hardware di prefetching. Il miss rate in questo buffer di<br />

prefetch supponiamo sia del 25% (di solito questi sono i valori per un buffer di prefetch che<br />

contiene solo una word). Supponiamo che l’hit time sia di 2 cicli di clock. Supponiamo anche che<br />

quando ho un miss, ma trovo il dato nel buffer di prefetch, sperimento un ciclo di clock.<br />

Consideriamo che il miss penalty sia di 50 cicli di clock.<br />

Calcoliamo l’AMAT:<br />

AMAT = Hit time + (se lo trovo in cache)<br />

Miss rate × Prefetch hit rate × 1 + (se abbiamo un miss però abbiamo un hit sul buffer di prefetch)<br />

Miss rate × (1 – Prefetch hit rate) × Miss penalty= (se un miss anche nel buffer di prefetch)<br />

= 2.415<br />

Se guardiamo il sistema da un punto di vista di più alto non sappiamo che il sistema diciamo che:<br />

AMAT = Hit time + Miss rate × Miss penalty.<br />

Vogliamo paragonare il miss rate di una macchina senza prefetching con una con prefetching; se<br />

sostituiamo i numeri otteniamo:<br />

Effective Miss rate = (AMAT – Hit time)/Miss penalty =0.83%; questo è il miss rate osservato<br />

dall’utente finale a cui non interessa se è un miss dovuto al fatto che non è stato trovato in cache o<br />

nel buffer di prefetch. Quindi abbiamo ottenuto una notevole riduzione del miss rate (da 1,1% a<br />

0.83%). Questo valore è quello che si sarebbe ottenuto se si fosse utilizzata una cache di 16 KB<br />

senza buffer di prefetch (utilizzando sempre lo stesso benchmark).<br />

Compiler-Controlled Prefetching<br />

Un’altra tecnica è quella di arricchire l’instruction set di un’istruzione nuova, che chiamiamo<br />

istruzione di prefetch, in cui noi forziamo il cache controller a prendere un blocco dalla memoria,<br />

anche se non ci sono stati miss, e metterlo in cache. Supponiamo che il nostro instruction set<br />

disponga di istruzioni di questo tipo, che consentono quindi il controllo software dei dati in cache;<br />

supponiamo anche che questa operazione possa essere fatta senza bloccare la cache: mentre<br />

106


abbiamo il dato che si sta spostando dalla memoria alla cache, quest’ultima, che è dotata di più<br />

porte, può continuare a fornire il processore di istruzioni e di dati.<br />

Consideriamo un cache di 8 KB direct mapped, con blocchi di 16 byte. Questo di seguito è un<br />

frammento di codice C: abbiamo due matrici a e b di double (8 byte)<br />

double a[3][100], b[101][3];<br />

...<br />

for(i=0;i


prefetch(b[j+7][0]);<br />

prefetch(a[0][j+7]);<br />

a[0][j]= b[j][0]*b[j+1][0];<br />

}<br />

for(i=1;i


Il numero di cicli di clock nel caso di prefetch è il seguente:<br />

Quindi abbiamo che il codice con le istruzioni di prefetch è 14650/3400 = 4,3 volte più veloce.<br />

Entrambe le tecniche che abbiamo visto sono ottimizzazioni del miss rate che determinano una<br />

modifica dell’hardware o dell’instruction set. Vediamo adesso una tecnica di ottimizzazione del<br />

miss rate che modifica l’hardware, ma modifica soltanto il software. Queste ottimizzazioni possono<br />

agire sia sulla parte del codice sia sulla parte dei dati del programma. Nella parte di codice il linker<br />

può ordinare le funzioni del nostro programma in modo tale che non creino conflitti; per esempio se<br />

ho un ciclo for che chiama due funzioni, il linker può pensare di fare in modo che queste due<br />

funzioni siano allocate in locazioni di memoria in modo tale che vengono mappate nella cache in<br />

zone che non creino tra loro conflitti. Ovviamente il compilatore deve essere conscio dell’hardware<br />

che c’è sotto.<br />

Merging Arrays<br />

Questa tecnica migliora la località spaziale. Molto spesso abbiamo degli array a cui accediamo nello<br />

stesso momento, con gli stessi indici, e hanno la stessa dimensione. Consideriamo il seguente<br />

programma:<br />

int val[SIZE];<br />

int key[SIZE];<br />

for(i=0;i


for(i=0;i


molto probabilmente lo avremo in cache perché l’abbiamo utilizzato nell’istruzione precedente, e lo<br />

stesso vale per c[i][j].<br />

Blocking<br />

Con questa tecnica viene modificato l’algoritmo per ridurre il numero di miss. Questa tecnica<br />

migliora la località temporale.<br />

Se abbiamo un algoritmo che opera su una struttura dati grossa, l’algoritmo viene diviso in tanti<br />

piccoli algoritmi che operano su zone piccole della struttura dati. Per esempio il seguente codice fa<br />

il prodotto di due matrici:<br />

for (i=0; i

Hooray! Your file is uploaded and ready to be published.

Saved successfully!

Ooh no, something went wrong!