Autori: Albano Teodoro, Luigi Biasi, Giuseppe Narracci
Titolo Originale: Calcolatori Elettronici, 1a edizione 2012
Copyright © 2012 Tutti i diritti sono riservati a norma di legge e a norma delle convenzioni internazionali. Nessuna parte di questo libro può essere riprodotta con sistemi elettronici, meccanici o altri, senza l’autorizzazione degli autori.
i
“Nessun calcolatore 9000 ha mai commesso un errore o alterato un’informazione. Noi siamo, senza possibili eccezioni di sorta, a prova di errore, e incapaci di sbagliare.“ (CALCOLATORE HAL 9000) 2001: Odissea nello spazio
ii
iii
Sommario CAPITOLO 1 LA VALUTAZIONE DELLE PRESTAZIONI DI UN PROCESSORE.............................................................................. 1 1.
Filosofia Risc E Cisc ................................................................................................................................ 2
2.
La Legge Di Amdahl ............................................................................................................................... 3
CAPITOLO 2 CARATTERISTICHE DELLE ISTRUZIONI DI UN CALCOLATORE ............................................................................. 7 1.
Ciclo Istruzione ...................................................................................................................................... 7
2.
Categorie Di Formati Istruzione............................................................................................................ 8
3.
Struttura Dell'istruzione ....................................................................................................................... 8
CAPITOLO 3 CLASSIFICAZIONE DEI PROCESSORI ................................................................................................................... 9 1.
Classificazione per istruzione ............................................................................................................... 9
2.
Classificazione per parola ..................................................................................................................... 9
3.
Classificazione per la gestione della memoria interna ...................................................................... 10
4.
Classificazione per modi di indirizzamento ........................................................................................ 16
CAPITOLO 4 MODI DI INDIRIZZAMENTO ............................................................................................................................. 17 1.
Indirizzamento Diretto........................................................................................................................ 17
2.
Indirizzamento con Immediato .......................................................................................................... 17
3.
Indirizzamento con Scostamento (displaycement) ........................................................................... 18
4.
Indirizzamento con registro auto-incrementato o auto-decrementato ........................................... 19
5.
Indirizzamento per le operazioni di salto........................................................................................... 22
6.
Operazioni Nell’insieme Di Istruzioni ................................................................................................. 26
CAPITOLO 5 ISTRUZIONI DEL PROCESSORE MIPS ................................................................................................................ 29 1.
Istruzioni di tipo J ................................................................................................................................ 29
2.
Istruzioni di tipo R ............................................................................................................................... 30
3.
Istruzioni di tipo I ................................................................................................................................ 30
4.
Le istruzioni del calcolatore MIPS ...................................................................................................... 31 4.1.
Istruzioni di trasferimento dati .................................................................................................. 31
4.2.
Istruzioni per operazioni logico-aritmetiche .............................................................................. 33
4.3.
Istruzioni di controllo .................................................................................................................. 36
Esercizi ......................................................................................................................................................... 39 iv
CAPITOLO 6 ARCHITETTURA CALCOLATORE PER LE OPERAZIONI CON DATI INTERI........................................................... 41 1.
Fasi Di Una Istruzione ......................................................................................................................... 41
2.
Architettura Calcolatore Per La Gestione Di Dati Interi .................................................................... 42
3.
2.1.
Istruzione Di Tipo R ..................................................................................................................... 45
2.2.
Istruzione di tipo I ....................................................................................................................... 45
Meccanismo Di Funzionamento Interno Dell'ALU: ............................................................................ 50
CAPITOLO 7 LA PIPELINE ...................................................................................................................................................... 53 1.
Introduzione alla Pipeline ................................................................................................................... 53
2.
Architettura Pipeline........................................................................................................................... 57 2.1.
3.
La Tipica “Vita” Di Un’istruzione In Pipeline .............................................................................. 60
Microcodice o Microistruzioni ............................................................................................................ 61 3.1.
IF (ISTRUCTION FETCH) ............................................................................................................... 62
3.2.
ID (ISTRUCTION DECODE) ........................................................................................................... 63
3.3.
EX (EXECUTE) ............................................................................................................................... 64
3.4.
MEM ............................................................................................................................................ 65
3.5.
WB ............................................................................................................................................... 65
CAPITOLO 8 PRESTAZIONI PROCESSORE PIPELINE .............................................................................................................. 67 1.
Miglioramento prestazioni: super pipeline........................................................................................ 67
2.
Miglioramento prestazioni: parallelismo ........................................................................................... 68
CAPITOLO 9 CONFLITTI NELLA PIPELINE .............................................................................................................................. 71 1.
Conflitti di Dato ................................................................................................................................... 72
2.
Conflitti sulle Diramazioni .................................................................................................................. 79
Esercizi ......................................................................................................................................................... 87 CAPITOLO 10 IL COSTO DI UN CIRCUITO INTEGRATO ........................................................................................................... 95 1.
La progettazione ................................................................................................................................. 95
2.
La produzione ...................................................................................................................................... 96
CAPITOLO 11 IL PROCESSORE FLOATING POINT 1.
Operazioni Floating Point ................................................................................................................... 99
v
2.
1.1.
Addizione................................................................................................................................... 100
1.2.
Moltiplicazione.......................................................................................................................... 101
Architettura Processore Floating Point ............................................................................................ 101
CAPITOLO 12 GERARCHIA DI MEMORIA .............................................................................................................................. 109 1.
Panoramica sulle memorie ............................................................................................................... 109
2.
Principi generali ................................................................................................................................ 111
CAPITOLO 13 MEMORIA CACHE 1.
Cenni storici sulle memorie cache .................................................................................................... 115
2.
Strategie di allocazione dei blocchi .................................................................................................. 115 2.1.
Memoria Completamente Associativa ..................................................................................... 116
2.2.
Indirizzamento Diretto.............................................................................................................. 116
2.3.
Set Associativa .......................................................................................................................... 116
3.
Strategia di ricerca e identificazione del blocco .............................................................................. 116
4.
Sostituzione di un blocco .................................................................................................................. 117
5.
Scrittura e lettura di un blocco ......................................................................................................... 118
CAPITOLO 14 PRESTAZIONI DELLA CACHE ........................................................................................................................... 123 1.
Analisi delle prestazioni della cache ................................................................................................ 123
2.
Cache dal punto di vista tecnologico................................................................................................ 125
3.
2.1.
Memoria statica SRAM ............................................................................................................. 125
2.2.
Memoria dinamica DRAM ........................................................................................................ 125
Dimensionamento della memoria dal punto di vista del tempo .................................................... 125
CAPITOLO 15 STRUTTURA ELETTRONICA DELLA CACHE...................................................................................................... 127 1.
Analisi elettronica della cache .......................................................................................................... 128
2.
Struttura delle memorie ................................................................................................................... 132
Esercizi ....................................................................................................................................................... 133 CAPITOLO 16 DIVERSE ARCHITETTURE DI MEMORIE .......................................................................................................... 135 1.
Aumentare la banda aumentando i dati letti o scritti: banchi paralleli ......................................... 136
2.
Aumentare la banda diminuendo il tempo: banchi interlacciati .................................................... 137
CAPITOLO 17 vi
POLITICHE DI GESTIONE DELLE MEMORIE .................................................................................................... 141 1.
Interazione con i dispositivi di I/O ................................................................................................... 141
2.
Interazione con più processori ......................................................................................................... 144
CAPITOLO 18 TECNICHE DI COMPILAZIONE EFFICIENTI ...................................................................................................... 147 1.
Srotolamento del loop ...................................................................................................................... 147
2.
Pipeline da programma .................................................................................................................... 149
CAPITOLO 19 PARALLELISMO A LIVELLO DI ISTRUZIONI ..................................................................................................... 153 1.
Processore VLIW ............................................................................................................................... 153
2.
Processore superscalare ................................................................................................................... 157
CAPITOLO 20 PROCESSORE VETTORIALE ............................................................................................................................. 159 1.
Architettura processore vettoriale cray-1........................................................................................ 160
2.
Modello di programmazione vettoriale ........................................................................................... 162
3.
Vantaggi del processore vettoriale .................................................................................................. 163
4.
Processori a registri vettoriali vs processori memory-memory ...................................................... 166
5.
Vettorializzazione del codice ............................................................................................................ 167
6.
Vector Stripmining ............................................................................................................................ 168
7.
Parallelismo delle istruzioni vettoriali ............................................................................................. 170
8.
Alcune tipiche operazioni con i vettori ............................................................................................ 173
9.
Riduzione a scalare dei vettori ......................................................................................................... 174
10.
Set istruzioni del processore VMIPS ............................................................................................. 175
CAPITOLO 21 TASSONOMIA DI FLYNN................................................................................................................................. 185 1.
Classificazione dei processore secondo Flynn ................................................................................. 185
2.
Concetti base..................................................................................................................................... 185
CAPITOLO 22 INFRASTRUTTURE DI COMUNICAZIONE ........................................................................................................ 187 1.
Bus ..................................................................................................................................................... 187
2.
Rete punto-punto ............................................................................................................................. 187
3.
Rete ad anello ................................................................................................................................... 188
4.
Rete a centro stella ........................................................................................................................... 189
5.
Reti ibride .......................................................................................................................................... 189 vii
6.
Ipercubo ............................................................................................................................................ 189 6.1.
Trasferimento dei dati all’interno dell’ipercubo ..................................................................... 190
CAPITOLO 23 PREDIZIONE DINAMICA ................................................................................................................................. 195 1.
Predittore a un bit............................................................................................................................. 197
2.
Predittore a due bit........................................................................................................................... 197
CAPITOLO 24 ALGORITMO DI TOMASULO .......................................................................................................................... 201 1.
Buffer di Riordino.............................................................................................................................. 201
2.
Fasi dell’istruzione ............................................................................................................................ 203
3.
Esecuzione fuori ordine .................................................................................................................... 206
viii
ix
CAPITOLO 1
LA VALUTAZIONE DELLE PRESTAZIONI DI UN PROCESSORE Le prestazioni di un processore sono state storicamente valutate da una serie di parametri che nel tempo sono stati sostituiti da altri. Per esempio un modo è stato il numero di colpi di clock per unità di tempo, cioè la frequenza di clock. Prendiamo come esempio un processore da 60 MHz: il clock nel processore, emette un impulso ogni 60milionesimo di secondo. In un secondo ci sono quindi 60 milioni di colpi di clock. Magari questo processore viene considerato migliore di un altro che ne emette 45 milioni al secondo. Questa distinzione però non ha senso se non indichiamo cosa avviene in un colpo di clock. Il colpo di clock è come il colpo di tamburo che il capo vogatore dava sulle galee romane. Gli schiavi remavano e a ogni colpo di tamburo dovevano dare un certo numero di vogate. Se la vogata è legata alla battuta, una vogata per battuta, e’ chiaro che con 50 battute al minuto, ci sono 50 vogate al minuto. Ma si può benissimo pensare di legare una vogata ogni 5 battute, ogni 5 battute bisogna dare quindi una vogata. Per cui lasciando da parte il singolo colpo di clock, ci si può concentrare sul CONTO ISTRUZIONI. Mi interessa sapere, in 1 secondo quante istruzioni processa un processore? E anche con il contro istruzioni non avrà molto senso se non mettiamo dei “paletti”. Questo viene ricavato da un altro parametro che si chiama CLOCK PER INSTRUCTIONS (CPI). Se io so i colpi di clock e il clock per instruction allora ho il conto istruzioni. Ad esempio sapendo che un processore con una frequenza pari ad 1 GHz e che esegue un istruzione per colpo di clock(CPI = 1), ottengo che in 1 secondo eseguirà 1 miliardo di istruzioni. Un altro processore da 1 GHz che invece ha bisogno di 1,3 clock per istruzione, cioè ha bisogno di 1,3 colpi di clock per eseguire una istruzione, allora in 1 secondo il processore eseguirò 1 miliardo/1,3 istruzioni. Sapere che un processore esegue N istruzioni/sec e un altro ne esegue M istruzioni/sec ci aiuta a definire quale tra questi due processori è migliore? Solitamente saremmo portati a dire che quello che ne esegue di più dovrebbe essere migliore. Ma in realtà questo non ci aiuta molto o meglio ci sarebbe di aiuto solo nell’ipotesi in cui entrambi i processori abbiamo lo stesso SET DI ISTRUZIONI (INSTRUCTION SET). Ora vediamo con accuratezza di cosa si tratta. Quando un processore effettua una operazione, esegue e traduce tale compito in una sequenza di istruzioni macchina. Ad esempio quando bisogna eseguire un operazione come A*B*C e scrivere il risultato in memoria all’allocazione D il processore attraverso una serie di istruzioni del SET DI ISTRUZIONI realizza questo compito: MULT3
A, B, C, D
(1)
Con questa istruzione del processore vengono presi i valori dagli indirizzi di A, di B e di C, vengono moltiplicati e il risultato salvato nell’indirizzo D. Il processore prende il dato all’indirizzo di ognuno , fa il prodotto tra questi e scrive il risultato in D, con una sola istruzione. Un secondo processore diverso dal primo per eseguire la stessa operazione magari dovrà utilizzare più istruzioni del suo SET DI ISTRUZIONI(diverso dall’altro processore), e quindi avrà bisogno di fare: -
A (istruzione per prelevare A dalla memoria) B (istruzione per prelevare B dalla memoria) C (istruzione per prelevare C dalla memoria) D1 = A*B ( istruzione per fare il prodotto tra A e B e salvarlo in D1) 1
-
D=D1*C ( istruzione per fare il prodotto tra D1 e C e salvarlo in D) D (istruzione per scrivere D in memoria)
Sapendo che entrambi i processori eseguono 1 istruzione/sec mi da un idea per confrontare i due processori. Il primo processore che compie l’operazione con una sola istruzione necessiterà di 1 secondo a differenza del secondo processore, che ha bisogno di 6 istruzioni e quindi di 6 secondi. E’ chiaro che in queste ipotesi il primo processore è migliore.
L’ultimo processore analizzato non ha nel suo SET DI ISTRUZIONI ,istruzioni così potenti come MULT3 dell’altro processore . Questo processore ha bisogno di 6 istruzioni per eseguire quell’operazione. Allora il CONTO ISTRUZIONI è più significativo del colpo di clock che è un parametro che descrive veramente poco e ci indica solamente la frequenza del treno di impulsi “treno di impulsi”. Allora può avere più senso sapere effettivamente quante sono le istruzioni che vengono eseguite in un istante di tempo, e comparare in questo modo due macchine a patto che esse abbiano lo stesso SET DI ISTRUZIONI. Quello che realmente interessa all’utente è il tempo che deve aspettare affinché un problema venga risolto. Ora il problema nella valutazione comparativa dei processori è che un problema risolto da un sistema di calcolo in un unità di tempo , mi porta a dare una valutazione estremamente orientata a quel problema (PROBLEM ORIENTED) perché nessuno mi garantisce che cambiando il problema la macchina A resti più veloce della macchina B. Nella risoluzione di un algoritmo vengono messi in evidenza aspetti che sono differenti dalla risoluzione di un altro algoritmo. Quindi fare una valutazione comparativa tra due processori, e quindi sapere quali aspetti andare a migliorare su un progetto, diventa un compito non banale.
1. Filosofia Risc E Cisc Si presentano due filosofie di architettura di un processore per quanto riguarda il SET Di ISTRUZIONI: RISC e CISC. Un set di istruzioni RISC (Reduced Instruction Set Computing, calcolatore con insieme ridotto di istruzioni) è un tipo di architettura di microprocessore che si concentra sull'elaborazione rapida ed efficiente di una serie relativamente piccola di istruzioni che comprende la maggior parte delle istruzioni che un calcolatore codifica ed esegue. L'architettura RISC ottimizza le istruzioni di modo che possano venire seguite molto rapidamente, in genere in un solo ciclo di clock. I chip RISC eseguono così le istruzioni semplici più velocemente dei microprocessori CISC (Complex Instruction Set Computing) progettati per gestire una serie più vasta di istruzioni. Sono tuttavia più lenti dei chip CISC quando si tratta di seguire istruzioni complesse che, per poter essere eseguite da microprocessori RISC, devono essere suddivise in molte istruzioni macchina. Allora scegliere il tipo di filosofia assumerà un senso quando si utilizzerà un processore che prevede che tutte le istruzioni siano molto simili tra di loro in modo da semplificare una parte non trascurabile di un processore: l’UNITA’ DI CONTROLLO. Progettare un’unità di controllo che piloti ogni transistor del processore per l’esecuzione di ogni singola istruzione ha una certa complessità. Questa complessità può 2
essere ridotta se le istruzioni sono molto simili tra di loro(RISC). Al contrario pilotare ogni transistor del processore per eseguire tante diverse istruzioni(CISC), comporta un unità di controllo un po’ più complicata. Venuto meno il problema della memoria costruita con nuclei di ferrite, da tanti anni i calcolatori CISC non esistono più.
Tornando al discorso sulla valutazione delle prestazioni, quello che interessa è vedere il tempo in cui un problema viene svolto. Come abbiamo già detto, se una macchina risolve un problema in un tempo minore di un'altra macchina non vuole che con problemi diversi ha sempre caratteristiche di performance che la portano a prevalere sull’altra macchina. Esistono dei programmi che hanno lo scopo di valutare le prestazioni di un sistema. Questi programmi si chiamano BENCHMARK (banco di prova). Per mettere in evidenza vari aspetti si crea un paniere di benchmark in cui c’è un programma che testa come quel sistema gestisce gli accessi in memoria, un altro che testa come quel sistema fa i calcoli in virgola mobile, un altro ancora che testa come quel sistema gestisce l’accesso a grandi quantità di dati e così via. Quindi in base a questo paniere di benchmark, si riescono a valutare le prestazione di una macchina. In fase di progettazione quando si ottengono delle prestazioni dal sistema e si individuano dei punti critici, si pone il problema se ha senso o meno migliorare il sistema riguardo quel punto critico. Tornando all’esempio di (1), ammettiamo che il progettista del secondo processore (RISC) non abbia problemi a inserire istruzioni di tipo CISC, ad esempio come MULT3. Questo ampliamento del set di istruzioni avrà un certo costo, bisogna fare in modo che l’unità di controllo sappia gestire anche quella istruzione, e supponiamo di aver quantificato questo costo, es. 2 transistor. Bisogna però tenere in considerazione il beneficio che si trae rispetto al costo. Quindi scoprire che questa istruzione dopo averla inserita mi consente di eseguire questo obiettivo in un secondo piuttosto che in 1,2 secondi quindi con un aumento delle mie prestazioni del 20% può essere utile a fronte del fatto che ho dovuto inserire 2 transistor su un unità di controllo che ne prevedeva 1000 con un costo dello 0,2 %. Questo però deve va in relazione con il numero di volte in cui verrà utilizzata nella vita di questo calcolatore l’istruzione , ad esempio MULT3. Perché se questa istruzione viene utilizzata spesso allora effettivamente si è guadagnato, ma se quel calcolatore lavora con questa istruzione una volta su mille, questo 20% che mi ho guadagnato non è stato guadagnato in assoluto ma una volta su mille e quindi è diventato 0,02%.
2. La Legge Di Amdahl In questo discorso assume molta importanza la LEGGE DI AMDAHL. Usando la legge di Amdahl si può calcolare il guadagno in termini di prestazioni che si può ottenere migliorando una parte di un calcolatore. Questa legge afferma che il miglioramento di prestazioni che può essere ottenuto usando qualche modalità di esecuzione più veloce è limitato dalla percentuale di tempo in cui può essere utilizzata tale modalità. La legge di Amdahl definisce l’aumento di velocità (speedup) che si può ottenere usando una particolare caratteristica. Cos’è l’aumento di velocità? Immaginiamo di poter apportare un miglioramento a un calcolatore in modo che le sue prestazioni migliorino. L’aumento di velocità è il rapporto
3
Oppure equivalentemente Speedup =( Tempo di esecuzione complessivo senza usare il miglioramento) /( Tempo di esecuzione complessivo usando il miglioramento).
Il valore di Speedup ci dice quanto più velocemente verrà eseguito un programma usando il calcolatore migliorato, rispetto al calcolatore originale. La legge di Amdhal fornisce un mezzo rapido per misurare lo Speedup conseguente a un determinato miglioramento, che dipende da due fattori:
1. La frazione del tempo di esecuzione sul calcolatore originale che può trarre vantaggio dal miglioramento. Per esempio, se il miglioramento può essere usato in 20 dei 60 secondi totali richiesti per l'esecuzione di un programma, la frazione di cui parliamo è 20 /60. Questo valore, che chiameremo frazione migliorata, è sempre inferiore al più uguale a 1. 2. Il miglioramento di prestazioni che si ottiene nel modo di esecuzione migliorato, cioè quanto verrebbe seguito più velocemente il programma se il modo di esecuzione migliorato potesse essere utilizzato per l'intero programma. Questo valore è uguale al tempo di esecuzione nella modalità originaria diviso il tempo di esecuzione nella modalità migliorata. Se nella modalità migliorata occorrono, per esempio, due secondi per eseguire una parte del programma che richiede invece 5 secondi nella modalità originaria, il miglioramento è 5/2. Chiamiamo speedup migliorato questo valore, che è sempre maggiore di 1.
Il tempo di esecuzione con il calcolatore originario a cui viene apportato il miglioramento sarà pari al tempo impiegato dalla porzione non soggetta a miglioramento sommato al tempo della porzione che si avvantaggia del miglioramento:
4
Lo Speedup complessivo è il rapporto tra i tempi di esecuzione:
5
6
CAPITOLO 2
CARATTERISTICHE DELLE ISTRUZIONI DI UN CALCOLATORE I linguaggi con cui noi riusciamo a dialogare con il processore sono stratificati. Esistono una serie di livelli stratificati. Nello strato più interno, quello più vicino al processore, il linguaggio è incomprensibile da un essere umano(linguaggio macchina), invece man mano che ci si avvicina agli strati più esterni,il linguaggio diventa sempre più comprensibile per l'essere umano(linguaggi di alto livello). I linguaggi ad alto livello sono stati introdotti per codificare gli algoritmi in un modo più semplice e intuibile da parte dell'essere umano. Ciò che viene realmente eseguito da una CPU sono le istruzioni del programma tradotte in un linguaggio più intuibile da parte del processore costituito da stringhe di 1 e 0(linguaggio macchina). Il linguaggio "appena superiore" al linguaggio macchina è il linguaggio ASSEMBLER. Questo linguaggio è una traduzione 1:1 di ogni istruzione del linguaggio macchina con una sintassi più comprensibile per l’uomo. I livelli d'istruzione che operano sulla CPU fondamentalmente sono di 3 tipi: -
-
Istruzioni aritmetico-logiche(somma, and logico, ecc..) Istruzioni per accedere alla memoria(istruzioni per scrivere e leggere dalla memoria) Lettura(LOAD) Scrittura(STORE) Istruzioni di controllo (o di diramazione) Salti incondizionati(JUMP) Salti condizionati(BRANCH)
I JUMP ad esempio possono essere confrontati con le chiamate di funzioni di qualsiasi linguaggio ad alto livello, visto che, in questo caso, avviene un salto incondizionato da una istruzione ad un altra. Alla fine si fa ritornare l'esecuzione all'istruzione successiva a quella chiamante.
1. Ciclo Istruzione L'istruzione è una sequenza di bit che il processore legge caricando dalla memoria. Una volta lette le istruzioni verranno eseguite sempre una dopo l'altra. L’esecuzione di una istruzione avviene grazie ad un ciclo, chiamato CICLO D'ISTRUZIONE. Il Ciclo di Istruzione è composto da: -
FETCH dell’istruzione: e' la fase di prelievo dell'istruzione. Questa fase avviene grazie al PROGRAM COUNTER che contiene l'indirizzo dell'istruzione da eseguire. Questa viene prelevata e poi caricata nel processore pronta per essere decodificata ed eseguita.
-
DECODE dell’istruzione: è' la fase di riconoscimento dell'istruzione che avviene grazie all'unità di controllo che legge l’istruzione e riconosce quale operazione bisogna eseguire. Ovviamente queste
7
-
operazioni sono state definite secondo rigide convenzioni. Ogni famiglia di processori ha delle proprie convenzioni. EXECUTE dell'istruzione: è la fase di esecuzione dell'istruzione.
Queste fasi sono sequenziali e sono arbitrate dal clock.
2. Categorie Di Formati Istruzione Le istruzioni si distinguono per il numero di bit di cui sono composte, inoltre possono esserci due categorie di formato istruzione: -
Formato a lunghezza fissa (numero di bit fisso) Formato a lunghezza variabile (numero di bit variabile)
Il formato a lunghezza variabile è prevista maggiormente nelle macchine di tipo CISC. Le macchine di questo tipo quindi hanno bisogno di un' unità di controllo molto complessa per poter leggere ogni tipo di lunghezza delle istruzioni. Invece le macchine di tipo RISC che hanno l'obbiettivo di semplificare l'unità di controllo, prevedono un formato d'istruzione a lunghezza fissa.
3. Struttura Dell'istruzione Un istruzione è costituita essenzialmente dal codice operativo (OPCODE): tra i bit che compongono l'istruzione ce ne saranno un certo numero che servono a specificare l'operazione dell'istruzione corrente. Il codice operativo è formato da log2n bit, arrotondato per eccesso, dove n è il numero delle istruzioni possibili. Ad esempio se ho bisogno di implementare un processore che necessita di 100 tipi di istruzioni, servirebbero per eccesso 7 bit per codificare il codice operativo di ogni istruzione, che può definire fino a 128 istruzioni (27). In genere i 28 tipi di istruzione rimanenti non vengono implementate proprio per non incorrere in complicanze dell'unità di controllo. Però queste istruzioni rimanenti, in futuro, potrebbero essere utilizzate per delle nuove frontiere tecnologiche, in modo tale da poter effettuare un semplice upgrade invece di riprogettare tutta l'unità di controllo da zero. Oltre ai bit del codice operativo ci sono inoltre delle sequenze di bit che identificano gli operandi su cui l’istruzione effettua le operazioni.
8
CAPITOLO 3
CLASSIFICAZIONE DEI PROCESSORI Si possono caratterizzare i processori in base a diversi aspetti. 1. Classificazione per istruzione Un aspetto è la tipologia di lunghezza fissa o variabile delle istruzioni affrontata nel capitolo precedente: le istruzioni a lunghezza fissa tipiche di una architettura con filosofia RISC e istruzioni con lunghezza variabile tipiche di una architettura con filosofia CISC. Inoltre le istruzioni fondamentalmente si dividono in tre famiglie: o
istruzioni di accesso a memoria
o
istruzioni che modificano il flusso del programma
o
istruzioni che codificano operazioni logico-aritmetico
2. Classificazione per parola Un altro aspetto con cui è possibile caratterizzare i processori è attraverso il tipo di dati che essi trattano. Ci sono una serie di dati che si differenziano sia per il formato(numero bit) e sia per il tipo di dati che questo formato va a interpretare. Ad esempio i dati di tipo numerico si codificano con o senza la virgola(interi o reali) e questo presuppone una codifica in binario puro con o senza segno o con una codifica che tiene conto di potenze di due sia positive e sia negative in modo da poter compattare i numeri in diversi formati come anche la virgola fissa o mobile. Standard più efficienti prevedono l’uso della virgola mobile( vedi floating point). Bisogna precisare quindi se il processore elabora dati in floating point o in virgola fissa. Anni fa non tutti i processori elaboravano dati floating point poiché non prevedevano al loro interno hardware sufficiente per effettuare queste operazioni, e quando era necessario fare calcoli in virgola mobile questi venivano eseguiti da un software. Se c’era bisogno di fare molti di questi calcoli si comprava il coprocessore matematico che implementava al suo interno in hardware questo tipo di calcoli. Ora è possibile integrare nello stesso chip degli hardware per effettuare calcoli matematici in floating point, infatti la maggior parte dei processori del giorno d’oggi gestiscono dati in floating point, contengono al loro interno un set istruzioni che permette la gestione di questi. Per anni questo non è stato possibile poiché i processori, avendo un hardware limitato, avevano a disposizione quindi un set di istruzioni che prevedeva la gestione dei soli numeri interi. Una volta scelta la possibilità di utilizzare dati piuttosto che altri(floating point piuttosto che numeri interi) bisogno stabilire la grandezza di questi dati, di quanti bit devo essere questi dati. I processori dunque si differenziano per i tipi di dati che possono gestire e la loro dimensione, dati da 4 byte intero, 8 byte intero, 16 byte intero oppure 32bit floating point(singola precisione) o 64bit floating point e così via. Un ulteriore aspetto che differenzia i processori è rappresentato anche della profondità della parola. L’unità è il bit che viene elevato a byte(1byte = 8bit) con 1 byte posso esprimere informazioni in un range tra 0 e 255(28). Se l’informazione che voglio descrivere è rappresentabile con un singolo byte è sufficiente la 9
parola di un byte, ad esempio le lettere dell’alfabeto, 26 lettere entrano in un byte(256 lettere) posso ancora codificare le lettere maiuscole e minuscole, numeri, simboli di punteggiatura. Con un byte si possono benissimo rappresentare caratteri alfanumerici e attraverso una codifica(in questo caso la codifica ASCII) a associa ad un numero tra 0 e 255 un simbolo. Quel byte invece non è più sufficiente per indicare quanti sono gli spettatori al concerto di Vasco Rossi. Non è più sufficiente perché non riesco a esprimere con un byte questa informazione. Bisogna quindi definire una quantità che verrà usata come riferimento standard dell’architettura: la parola (WORD). La parola diviene l’unità. Un architettura definisce una parola con un certo numero di byte che tratta come nucleo del dato più frequentemente adoperato. Le parole solitamente sono di 4byte, tutto il traffico nel processore avviene usando dati di 4 byte. I dati sono nel formato definito dalla WORD.
3. Classificazione per la gestione della memoria interna Possiamo differenziare i processori anche in base a come i dati all’interno di questo vengono gestiti. La macchina secondo Von Neumann è strutturata con una cpu, una memoria centrale, periferiche di input/output e i canali di comunicazione tra questi. Si può immaginare ad un modello in cui il processore quando ha bisogno di un dato in una istruzione lo va a reperire ogni volta direttamente dalla memoria centrale e il risultato lo scrive sempre in memoria. Ma si può pensare ad un processore che abbia al suo interno un’area di memoria in cui i dati quando servono vengono letti dalla memoria centrale scritti in questa area e ogni volta che servono vengono letti da questa e non dalla memoria centrale, purché al termine di tutto questi dati vengano scritti in memoria centrale. Sono stati sviluppati tre modelli per la gestione per questa memoria intima del processore.
10
i
1. Una soluzione semplice era il processore ad accumulatore, che prevedeva al suo interno una sola area di memoria, l’accumulatore, utile a contenere un solo dato.
Nell’accumulatore veniva caricato un operando dalla memoria e quando il processore doveva effettuare delle operazioni, che di solito necessitano di due operandi sorgente, ad esempio la 11
somma (SUM), l’istruzione intendeva fare la somma di due operandi, uno letto dalla memoria e l’altro è quello che risiede nell’accumulatore. Il risultato andava scritto sempre nell’accumulatore. Le istruzioni nella loro strutturazione sono semplici in quanto sono costituiti dal codice operativo e un indirizzo di memoria. SUM
2730
L’operando nell’accumulatore veniva sommato all’operando che si trovava nell’indirizzo di memoria 2730, e il risultato veniva scritto nell’accumulatore. Dopo l’operazione quello che c’era prima nell’accumulatore veniva perso, c’era il risultato della somma. Per scrivere il risultato nella memoria centrale ad un certo indirizzo: STORE
3256
Il valore nell’accumulatore veniva scritto in memoria all’indirizzo 3256. Esercizio: Supponiamo di avere degli indirizzi di memoria A B C D e di voler mettere nell’indirizzo di memoria E la somma di quello che c’è nell’indirizzo A e nell’indirizzo B: E=A+B; e invece in F: F = A*C+D
Istruzione LOAD A SUM B STORE E LOAD A MULT C SUM D STORE F
Valori nell’ accumulatore A A+B A+B A A*C A*C+D A*C+D
2. Un'altra tipologia di processore era quella a stack, processori a stack. In questi non c’è una sola area di memoria, ma una struttura di memoria con più locazioni a cui non si può accedere in maniera casuale, ma si può aver accesso solo a chi è in testa allo stack(come una pila LIFO Last Input First Output), l’ultimo dato inserito è il primo ad essere prelevato. C’è un meccanismo che prevede uno STACK POINTER (SP) che mi indica l’area dello stack a cui posso accedere, e che contiene quindi il dato in questione. Nelle operazioni aritmetico logiche non bisogna specificare nell’istruzione l’indirizzo degli operandi, perché questi sono i primi due che si trovano al top dello stack e sono questi che verranno utilizzati nelle operazioni. Le operazioni di tipo LOAD vengono chiamate in questo contesto push, mentre quelle di STORE pop
12
Bisogna però stabilire delle convenzioni sullo stack pointer: lo stack pointer punta alla prima vuota o all’ultima piena. Lo stack pointer può puntare alla prima area dello stack vuota o all’ultima piena. Ipotizzando che SP punti alla prima vuota.
LOAD (push): M[indirizzo] ->S[SP] SP+1->SP Letto il dato dalla memoria(indirizzo) viene memorizzo nello stack, nell’area di questo puntata dallo stack pointer(questa area dello stack è vuota visto che SP indica proprio l’area vuota per la convenzione adottata). Bisogna però incrementare SP per farlo puntare nuovamente alla prima vuota. STORE (pop): SP-1->SP S[SP]->M[indirizzo] SP punta alla prima vuota(garbage, valore dell’ultimo dato scritto e non ha nessun valore utile), bisogna farlo puntare all’ultima piena e poi scrivere ciò che è puntato dallo stack pointer in memoria(nell’indirizzo di memoria). Utilizzando invece la convenzione in cui SP punta all’ultima piena.
13
LOAD (push): SP+1->SP M[indirizzo] -> S[SP] Bisogna far puntare prima SP alla prima vuota(altrimenti perdo il dato che avevo in precedenza), e caricare poi il dato dalla memoria. STORE (pop): S[SP]->M[indirizzo] SP-1->SP L’ultimo pieno (quello puntato da SP) è proprio il dato da salvare in memoria. Però adesso bisogna decrementare lo stack pointer. E’ essenziale stabilire la convenzione. Nella strutturazione pipeline(che vedremo successivamente nel corso) si prevede che prima si eseguono dei calcoli e poi si accede a memoria. Potrebbe essere utile questa scelta per stabilire quale convenzione usare per lo stack pointer, però le due convenzioni non vanno comunque bene(perché nella LOAD di una convenzione si eseguono prima le operazioni e poi si accede a memoria ma nella STORE è il contrario, e viceversa con l’altra convenzione), allora si può ipotizzare di fare una ibridazione e scegliere di eseguire le load secondo una convenzione e le store secondo un'altra, ma non va bene comunque. Bisogna scegliere una di queste due convenzioni in base a quale operazione faccio più frequentemente load o store. Intuitivamente si è portati a dire che le load e le store sono effettuate lo stesso numero di volte, però le load sono più numerose, perché si sale lungo lo stack con le load e si scende con le store ma anche con le operazioni che prevedono la memorizzazione di un operando nella memoria. 3. Un'altra famiglia di processori è quella dei processori a registri, che utilizzano un certo numero di locazioni (come lo stack) a cui però si può accedere a piacere, si può accedere a qualsiasi locazione senza vincoli. Queste celle assumono tutte la stessa “dignità”. C’è un area di memoria costituita da dei registri che si indirizzano autonomamente. Non si è legati alla cima della pila. Per questa comodità si paga sia un hardware più complesso per gestire questo sistema, e soprattutto si paga un discorso che obbliga a inserire nell’istruzione la specifica degli indirizzi degli operandi.
14
Nel caso del processore ad accumulatore un operando era sempre l’accumulatore quindi nell’istruzione si indicava un solo indirizzo per l’operando da prelevare dalla memoria; nel processore a stack gli operandi erano sempre i primi due dello stack e nelle istruzioni non si specificava nessun indirizzo. Nei processori a registri invece è necessario specificare il primo operando(che si trova in un determinato registro), il secondo operando(che si trova in un altro registro), il terzo operando(che si trova in un’altro registro ancora). Quindi l’istruzione oltre al codice operativo prevede tre campi per gli operandi, e questi campi contengono un numero di bit pari al log2(num. registri). Si può sempre pensare di dare una ulteriore flessibilità e oltre a prelevare gli operandi dai registri si vuole dare la possibilità di prendere operandi dalla memoria, ad esempio si vuole effettuare la somma di due dati in cui uno si trova in un registro, mentre il secondo si trova in una certa locazione della memoria centrale(indirizzo di memoria). Oppure la locazione dell’area di memoria di un operando è scritta addirittura in un registro. In questi casi il formato si complica perché il numero dei bit dei campi per la specifica degli operandi deve poter contenere anche gli indirizzi di memoria, bisogna prevedere la gestione di questi meccanismi.
La maggior parte dei processore di oggi sono della tipologia a registri. Tipicamente le macchine a registro si suddividono in LOAD/STORE piuttosto che no. Una macchina a registro si dice che è una macchina LOAD/STORE nel momento in cui le uniche istruzioni di questa macchina che fanno riferimento alla memoria sono solo le istruzioni LOAD e STORE, significa che nell’esecuzione di una istruzione aritmetica, la somma per esempio, il processore non può accedere alla memoria, gli operandi devono essere stati già tutti preventivamente caricati nei registri. Non è possibile con questa tipologia di macchina prendere un operando dalla memoria direttamente con la istruzione aritmetico logica, SUM per esempio, passando l’indirizzo della locazione di memoria, ma tutti gli operandi sono stati già tutti preventivamente caricati nei registri, e il risultato di questa operazione deve essere ancora scritto in un registro, non può essere scritto in una locazione di memoria. Nelle macchine di tipo LOAD/STORE le uniche operazioni che permettono di caricare o scrivere da/in memoria sono solo e soltanto le operazioni di LOAD e di STORE. Osserviamo nella figura che quando c’è da usare l’ALU la memoria è scollegata
15
4. Classificazione per modi di indirizzamento Un altro modo di caratterizzare i processori è il modo con cui questi gestiscono l’accesso a memoria, cioè il modo con cui l’indirizzo, dove andare a leggere o scrivere, può essere fornito al processore. Il modo più semplice è scrivere un indirizzo nell’istruzione stessa(modalità con indirizzo immediato); un altro modo è quello con indirizzo diretto dove l’indirizzo dell’ area di memoria è scritto in un registro. L’indirizzo dell’area di memoria va prelevato da un registro.
16
CAPITOLO 4
MODI DI INDIRIZZAMENTO Nel capitolo precedente abbiamo distinto le macchine a registri in due categorie: 1. L’architettura Load-Store che consente di fare operazioni di tipo aritmetico logico solo fra operandi che sono nei registri, quindi non si può fare la somma fra un dato che è in un registro e un dato che è in memoria, o ancor peggio la somma di due dati che sono in memoria. Bisogna solo fare operazioni aritmetico logiche che riguardano due operandi che sono già presenti nei registri. In sostanza si accederà alla memoria solo attraverso operazioni tipiche della memoria. Quindi questo tipo di macchina sarà una macchina a registri LOAD-STORE per significare che la memoria sarà usata solo con istruzioni di tipo LOAD STORE. Quindi per fare un operazione aritmetica fra due dati che sono in memoria prima bisogna eseguire la LOAD di questi due dati , per esempio prendere il primo dato e inserirlo nel registro R1 , leggere un secondo dato e inserirlo nel registro R2 e solo ora è possibile fare le operazioni fra R1 ed R2; il risultato andrà in un altro registro che può anche essere uno dei due che si è appena usato, ma non si può scrivere il dato in memoria se non attraverso l’operazione di STORE 2. L’architettura Registro-Memoria invece prevede di usare l’ALU per fare calcoli ma nell’ambito della stessa istruzione anche di accedere a memoria. Questo tipo di struttura però prevederà un codice operativo che dovrà contenere sia la specifica di un registro (per l’operando) sia la specifica di un registro del risultato e sia un indirizzo. Quindi siccome per indirizzare un registro tipicamente bastano pochi bit, con 32 registri è sufficiente un insieme di 5 bit. Con 5 bit infatti si possono codificare numeri compresi tra 0 e 31; scrivendo il numero 7 si intende il registro R7. Per specificare un indirizzo non bastano 5 bit, attraverso 5 bit si indirizza solo una porzione della memoria. Tipicamente per specificare un indirizzo si necessitano di più bit, almeno che per specificare un indirizzo non si utilizza un artefatto: scrivere l’indirizzo di memoria in un registro. Quindi nell’istruzione si andrà solamente a specificare l’indirizzo del registro, che contiene l’indirizzo di memoria, attraverso i 5 bit.
1. Indirizzamento Diretto La modalità di indirizzamento appena mostrata si chiama INDIRIZZAMENTO DIRETTO. C’è un registro che direttamente contiene l’indirizzo dell’area di memoria. Nell’istruzione si andrà a specificare solamente l’indirizzo del registro che contiene l’indirizzo di memoria a cui accedere. 2. Indirizzamento con Immediato È possibile scrivere l’indirizzo direttamente nell’istruzione stessa. Quindi l’istruzione dovrà contenere oltre anche varie altre informazioni fra cui una di queste è l’indirizzo.
17
3. Indirizzamento con Scostamento (displaycement) L’indirizzo con displaycement viene calcolato sommando al contenuto di un registro un numero (INDIRIZZO BASE) codificato nell’istruzione stessa. Nell’istruzione verrà specificato quindi un registro per esempio R9 e un immediato(indirizzo base), e l’indirizzo di memoria verrà calcolato sommando questo indirizzo base che e il contenuto di R9. Ci si può chiedere, ma non era meglio inserire tutto l’immediato di per sé? Si però se questa istruzione va eseguita all’interno di un loop e ad ogni ciclo si ha la necessità di accedere a un dato diverso bisogna avere un indirizzo diverso. Con l’indirizzamento con scostamento l’istruzione contiene una un immediato e un registro da sommare così che nel loop ogni volta verrà cambiato il valore dell’immediato Ci sono anche altre modalità che sono usate nell’ambito dei programmi in maniera diversa. Cioè quando un programma usa una modalità di indirizzamento si può vedere se questa modalità è frequente perché in tal caso si può pensare di potenziare l’esecuzione di un istruzione che usa quel tipo di modalità di indirizzamento.
Questo diagramma prende in considerazione 5 modalità diverse di indirizzamento: 1. Indiretto in memoria 2. Scalato 3. Indiretto con registro 4. Immediato 5. Scostamento e per ciascuna di queste usando 3 programmi standard che vengono considerati come benchmark, si può vedere, quando si esegue una compilazione, come funziona l’uso di tali indirizzamenti. Dopodiché se si vuole provvedere a come potenziare l’architettura di un sistema che dovrebbe essere usato prevalentemente per fare calcolo scientifico si studia il comportamento di quello che fa il sistema quando usa un programma di tipo scientifico come SPICE, e si scopre che oltre la metà degli accessi in memoria avvengono attraverso uno SCOSTAMENTO (displaycement). Questo è il tipico modo con cui si accede ai dati dei vettori. Quindi capiamo bene come in SPICE questo è il modo più frequente perché quando si fa una 18
simulazione di una rete elettrica si risolvono sistemi che fanno uso di calcolo vettoriale e per questo per accedere a elementi di un vettore si ha bisogno di accedere con indirizzamenti codificati in questo modo. Per cui se si vuole migliorare qualcosa di questo processore, relativamente agli accessi in memoria converrà migliorare il modo con cui il processore accede a memoria dopo aver calcolato l’indirizzo in maniera di spiazzamento. Se invece si vuole realizzare un sistema che fa tipicamente editoria, (TeX) ci si accorge che quando il compilatore accede a memoria utilizza indirizzi immediati cioè nella esecuzione si fa riferimento a un indirizzo codificato in maniera diretta nell’istruzione stessa. Invece l’indirizzamento che prende il nome di REGISTRO INDIRETTO è un indirizzamento secondo il quale il registro contiene un indirizzo che non è l’indirizzo del dato di destinazione ma è ancora un altro indirizzo e quindi si va due volte in memoria. 4. Indirizzamento con registro auto-incrementato o auto-decrementato In questo diagramma non è presente un altro modo abbastanza frequente di accedere alla memoria che è quello con auto-incremento o auto-decremento del registro, in cui l’istruzione di accesso a memoria ingloba in sé anche l’operazione di aggiungere o togliere una quantità al registro usato per conoscere l’indirizzo. Ad esempio in R8 c’è un certo indirizzo, si può avere o meno l’immediato da sommare al registro per avere l’indirizzo di memoria; si effettua la somma nel caso sia necessario fra l’immediato e il registro, e si ottiene l’indirizzo di memoria. Oltre a questo il registro viene aumentato o diminuito di 4 o comunque di una quantità che rappresenta il numero di byte letti dalla memoria in maniera tale che questo registro in automatico adesso punterà al prossimo dato. C’è la operazione con indirizzamento auto incrementante o auto decrementante prefisso a significare che prima verrà incrementato o decrementato il registro e poi verrà usato per accedere in memoria. Questo meccanismo è utile per la gestione di uno stack, non si intende lo stack interno al processore, la macchina è sempre a registri, ma si intende uno stack come un’ area di memoria che il processore gestisce. Valgono le due convenzioni su SP che punta alla prima vuota o all’ultima piena. Ad esempio vediamo la gestione dello stack. D è la dimensione della cella dello stack, ed R6 è il registro su cui avvengono le operazioni. Consideriamo il caso in cui SP punti all’ultima piena (1). POP (1): M[SP] R6 SP – D SP
PUSH (1): SP + D SP R6 M[SP]
19
Consideriamo il caso in cui SP punti all’ultima vuota (2). POP (2): SP – D SP M[SP] R6
PUSH (2): R6 M[SP] SP + D SP
Supponiamo che R31 è il registro che contiene lo stack pointer che è un indirizzo di memoria. Posso fare uso dell’indirizzamento con auto-incremento o auto-decremento, si scrive un “+”( o un “-“) a destra o a sinistra del registro per indicare rispettivamente un incremento( o decremento) postfisso o prefisso. Le istruzioni scritte prima diventano: POP (1): LOAD
R6
R31-
Scrivo in R6 il dato letto dalla memoria puntata da R31. Il segno “–“ indica che dopo aver scritto nel registro R6, R31 viene decrementato: R31 = R31 – D PUSH (1) STORE
R6
+R31
R1 viene prima incrementato, e poi viene scritto nell’area di memoria puntata il valore di R6. Con l’altra convenzione POP (2): LOAD
R6
-R31
R6
R31+
PUSH (2): STORE
E’ importante precisare che quando si accede a uno stack con le operazioni di PUSH e POP, bisogna prima verificare che lo stack non sia pieno, altrimenti si scrive su una zona di memoria che magari è stata riservata per contenere altri dati; e che non sia vuoto. Ipotizziamo che R30 e R29 sono due registri che contengono rispettivamente R30 il TOP (ultima locazione accessibile) ed R29 il BOTTOM (la prima cella accessibile).
20
Con la convenzione dell’ultima piena, per effettuare una push bisogna verificare che: SP < TOP Invece per effettuare una pop bisogna verificare che: SP >= BOTTOM Con la convenzione dell’ultima vuota invece, per effettuare una push deve essere verificato che: SP <= TOP Invece per effettuare una pop: SP > BOTTOM Tipicamente l’indirizzamento con spiazzamento (più usato nel calcolo scientifico) ha questa struttura: Il codice operativo ad indicare un operazione di LOAD o STORE, e un registro destinazione o sorgente: nel caso di una LOAD il registro è di destinazione, nel registro verrà scritto il risultato; altrimenti nel caso di una STORE il registro è sorgente, contiene un dato che verrà scritto in memoria. Infine ci c’è un altro registro che contiene lo spiazzamento e un indirizzo per l’immediato, questi ultimi sommati vanno a formare l’indirizzo di memoria. Questo immediato è da prendere come indirizzo a cui sommare il contenuto del registro di spiazzamento per ottenere l’indirizzo finito. E’ normale che questo immediato ha un numero di bit che è minore del numero di bit dell’istruzione. Se considero un processore con N istruzioni, sono necessari almeno Log2N bit per il codice operativo. Ad esempio 6 bit per il codice operativo. Considerando inoltre un processore con 32 registri, sono necessari 5 bit per specificare un registro. Nella struttura fornita prima dell’istruzione in cui compaiono oltre al codice operativo due registri e considerando ancora che le istruzioni sono di 32 bit rimangono per l’immediato solamente 16 bit. Tipicamente uno spazio di indirizzamento nella memoria, ha la dimensione del BUS del processore. Quindi se il processore traffica con dati da 32 bit anche gli indirizzi saranno di 32 bit perché all’interno del processore gli indirizzi che si possono gestire (per esempio somma tra immediato e registro di spiazzamento) sono indirizzi scritti in un registro. Quindi non si è vincolati ad avere una memoria solo di 232 byte = 4 gigabyte, significa però che in un momento storico della vita di quel programma ci sarà una memoria di 4 gigabyte. Molto probabilmente la memoria sarà effettivamente di diversi terabyte, però si sta facendo riferimento a uno spazio di indirizzamento che è una fetta(4 gigabyte) di questa memoria. Per cui quando si accede a memoria, questo indirizzo sarà un indirizzo che andrà a consultare un SUBSET della memoria di 4 gigabyte. Allora questo immediato di 16 bit (ad esempio) ipotizzando 32 bit per questa istruzione, va utilizzato come se fosse di 32. Quindi questi immediati vengono tipicamente espansi a 32 bit. A partire da questo numero di 16 bit verrà creato un numero di 32 bit facendo un espansione in segno, perché questo numero può essere positivo o negativo ad indicare uno spostamento in avanti o indietro rispetto al registro di spiazzamento. Se è positivo o negativo l’espansione in segno prevederà la replica dei bit più significativi. Dato un numero di N bit e lo si vuole espandere a N+K bit, si andranno ad aggiungere K bit alla sua sinistra.
21
Esempio: consideriamo il numero 18 e lo si vuole scrivere con 4 cifre. Bisognerà scrivere 0018, poiché questo è un numero positivo si andranno ad aggiungere gli zeri a sinistra. Se il numero invece è binario con segno in complemento a 2: i numeri negativi avranno il primo bit uguale a 1. Per scrivere -7 usando 5 bit binari, si può scrivere -7 in complemento a 2. 00111 (7) in 5 bit 11000
C.A. 1
11001
C.A. 2 (-7)
Se si vogliono usare 8 bit per rappresentare -7: 00000111 (7) in 8 bit 11111000 11111001
C.A.1 C.A. 2 (-7)
Per leggere questi risultati dovrò verificare il primo bit se esso è uguale a zero allora il numero è in binario puro e lo si può leggere normalmente. Se invece il primo bit è uguale a 1 non si può legge il numero in binario e bisognerà fare un operazione che è di nuovo in complemento a 2. Si scriver il segno meno e si effettua il complemento a 2 sul numero: 11111001 “-“ 00000110 C.A. 1 00000111
C.A. 2 (7)
Il risultato è 7, con il segno negativo poiché il primo bit era 1: -7. Bisogna chiedersi però se questi 16 bit (per esempio) sono sufficienti piuttosto che no a indirizzare la memoria. In realtà con 16 bit, rispetto un valore scritto in un registro, ci si può spostare 215 locazioni prima o 215 locazioni dopo. Per cui se si sta spaziando su una memoria a partire dal valore puntato dal registro di spiazzamento, sommando questo immediato positivo o negativo ci si può muovere su 215 locazioni sopra o 215 locazioni sotto rispetto a questo registro. Questi 16 bit sono sufficienti o sono pochi?. Se per esempio questi sono pochi ci sono dei problemi, perché bisognerebbe pensare a un istruzione che sia più lunga di 32 bit. Allora prima di capire se questi bit sono pochi o molti, si può fare uno studio statistico e vedere quando si effettua un accesso a memoria questo displaycement , di solito, quanto è grande? Di solito lo quanto è lungo lo spostamento richiesto? E lo stesso, a maggior ragione, quando si dovranno eseguire delle istruzioni di salto di tipo JUMP e di tipo BRANCH i quali prevedranno un codice operativo diverso.
5. Indirizzamento per le operazioni di salto Un istruzione JUMP effettua un salto ad un nuovo indirizzo di memoria senza nessuna condizione, non si dovrà codificare altro. Si avrà a disposizione oltre al codice operativo una serie di bit per scrivere il valore di quanto saltare.
22
Un istruzione BRANCH invece necessita di una serie di bit oltre al codice operativo per codificare i termini dell’operazione logica da fare. Ad esempio se si vuole codificare un istruzione, un branch se R2 > R6, bisognerà mettere da parte tanti bit per scrivere R2 e tanti per scrivere R6. Quindi nei branch, solitamente i bit che restano per specificare di quanto saltare sono di meno a differenza del JUMP dove si ha a disposizione praticamente tutta l’istruzione a meno del codice operativo per codificare la lunghezza del salto. Nei branch, invece, parte dell’istruzione , oltre al codice operativo, deve essere “sacrificata” per codificare su quali registri si sta facendo il test se saltare o meno. E quindi anche qui avrebbe senso studiare quanto sono lunghi solitamente i salti di un istruzione JUMP o di una istruzione BRANCH quando si usano software come SPICE, GCC o altri. Se per esempio ci si accorge di avere salti di 266 locazioni bisogna trovare una soluzione! Una potrebbe essere quella di utilizzare un istruzione di 71 bit, 5 per il codice operativo e 66 per fare il salto, però poi questi 71 bit sono uno spreco quando le istruzioni rientrano in un numero più ridotto. Per cui uno studio senz’altro utile è quello che viene in mente nel verificare questi numeri di bit di spiazzamento. Un istruzione CALL effettua un salto incondizionato come la Jump, però a differenza della Jump che salta semplicemente le istruzioni, la Call effettua un salto ma con un meccanismo di salvataggio della posizione da dove si è effettuato il salto. Viene memorizzato l’indirizzo da dove viene eseguito il salto. Questo è il meccanismo per gestire le procedure, le subroutine. Quando si esegue una Call, viene chiamata una procedura, vengono eseguite le istruzioni di questa, e quando termina l’esecuzione del programma ritorna all’istruzione successiva alla Call. Può capitare che in un codice si possono avere più chiamate a quella subroutine, bisogna prevedere una istruzione di Call che funziona come una Jump, ma non si limita al semplice salto, deve salvare l’indirizzo successivo dell’istruzione di Call eseguita prima e che dovrà essere gestito al termine della procedura dall’istruzione return. L’ultima istruzione di una procedura è la return che anch’essa effettua un salto incondizionato, salta all’indirizzo che salvato quando è stata eseguita la Call. Bisogna scrivere in un certo registro speciale l’indirizzo successivo all’istruzione di Call.
Il problema di questo meccanismo nasce quando anche nella subroutine avviene un'altra chiamata ad un'altra subroutine o quella subroutine stessa(ricorsione). Nella subroutine c’è una nuova Call ad un'altra procedura che va a modificare nel registro l’indirizzo di ritorno, quindi ora nel registro viene scritta la nuova posizione dove ritornare dopo questa Call ma si perde la posizione precedente. Bisogna quindi, per ovviare a questo problema, vincolare la subroutine a non fare altre chiamate a subroutine. Un metodo migliore per gestire le Call/return è scrivere i diversi indirizzi di ritorno in un area di memoria, quest’area di memoria, lo stack, deve essere organizzata con tipologia LIFO. Quando viene chiamata una subroutine nello stack si va a memorizzare l’indirizzo di ritorno, quando nella subroutine avviene un'altra chiamata ad un'altra procedura viene salvato l’indirizzo di ritorno nella cima dello stack, e così via se ci sono
23
altre chiamate. Nelle operazioni di return, viene effettuato il salto all’indirizzo in cima allo stack(l’ultimo inserito) e così via nelle return delle subroutine. Possiamo avere anche 1000 registri in un processore, ma di questi se ne visualizzano 32 o 64 e gli altri servono per gestire un meccanismo che viene chiamato Finestra di Registri. Per individuare un singolo registro, tra 1000, avrei bisogno di 10 bit, e un istruzione che fa uso di 3 registri, avrebbe bisogno di soli 30 bit per specificare 1000 registri. Non se ne utilizzano mai 1000 registri, di solito il valore di un registro viene sovraccaricato dopo una operazione. Accedere a 1000 registri è più lento, bisogna connettere per esempio un dato registro all’ALU(con un selettore multiplexer). Il cammino critico, il percorso che deve fare un segnale in un circuito, dalla sorgente alla destinazione in un colpo di clock, se bisogna attraversare un numero di gate di un selettore che ha molti ingressi è molto più lungo e si impiega più tempo rispetto ad uno che ne ha di meno. Si possono spezzettare i cammini critici in più tappe e avere un clock che può andare più velocemente, ma la latenza è quella di attraversare un sistema: “cercare una cosa tra poche cose è più veloce che cercarla tra molte”. La Jump come la Call o come la return sono salti incondizionati. I branch sono invece salti condizionati che vengono effettuati quando sono verificate delle condizioni. Condizioni logiche su cui vengono impostati in BRANCH. Queste condizioni da codificare sono istruzioni che possono essere calcolate molto velocemente. Bisogna capire subito se si deve saltare una istruzione o meno. Le istruzioni di salto vanno gestite in maniera molto più veloce rispetto all’esecuzione di un calcolo. Normalmente le istruzioni devono essere eseguite uno dopo l’altra, al termine di una viene eseguita la successiva. Però si può pensare ad un meccanismo in cui non si è necessariamente vincolati a eseguire una istruzione dopo aver terminato la precedente(parallelizzazione delle operazioni, pipeline). Istruzioni di questo genere possono essere iniziate non necessariamente al termine dell’istruzione precedente. Quando viene decodificata l’istruzione di calcolo, questa può durare del tempo intanto viene eseguita la successiva e nella peggiore delle ipotesi si può avere una dipendenza di questa istruzione con l’operazione precedente. Nell’istruzione di salto questo calcolo sulla condizione deve essere fatto velocemente, perché finché non si è deciso se saltare o meno non possono essere eseguite le istruzione successive o quella nella posizione da saltare. Bisogna verificare le condizioni nel modo più velocemente possibile.
Set di istruzioni che codificano condizioni logiche: EQUAL TO NOT EQUAL TO LESS THAN LESS THAN OR EQUAL TO GREATER THAN GREATER THAN OR EQUAL TO
== != < <= > >=
Una politica potrebbe essere quella di non implementare Greather Than perché questa operazione è effettuata da Less Than Or Equal To. Si possono implementare diverse situazioni per semplificare le istruzioni.
24
Osservando la figura pag. 507, è stata tracciata la frequenza dove certi bit di spiazzamento sono richiesti. Per esempio 5 bit di spiazzamento nel 10 % dei casi e via dicendo. Il grafico arriva fino a 16 perché i programmi alla fine considerano le istruzioni di salto realmente implementate solo con 16 bit. Questo non significa che si possono fare dei salti da una locazione a N locazioni più in là dove log2N è superiore a 16. Una volta affidato al campo immediato un numero di bit , 16 come abbiamo detto, come si risolve il problema di saltare invece che di 215 locazioni in avanti (16 significa 215 locazioni più avanti e 215 locazioni indietro) ma di 228 locazioni più in là ad esempio? Servirebbero altri 13 bit che non possono essere inseriti perché non si posso fare istruzioni più lunghe. Per risolvere questo problema possiamo pensare all’atletica! Se si vogliono saltare più di 8 metri e mezzo bisogna fare un salto triplo! Quindi qualora serve realmente spostarsi di 228 locazioni più in là, bisogna fare un salto di 215 locazioni, e inserire nell’istruzione 215 un'altra istruzione di salto fino alla locazione di destinazione. Quest’ultima istruzione di salto non è condizionata. Perché la condizione è stata verificata, se era necessaria, nella prima istruzione di salto(quella di 215). Essendo una jump quest’ultima istruzione di salto potrebbe avere uno spiazzamento per indicare il salto ad esempio di 26 bit. Quindi è sufficiente nell’istruzione di salto che vuole saltare di 228 locazioni più in là, fare un salto di 215 e scrivere in quest’ultima locazione un istruzione di salto anche incondizionato che ci proietta in un altro punto dell’esecuzione. Nasce un secondo problema con questa soluzione, a seconda istruzione di salto che inserita (dopo le 215 locazioni) viene raggiunta non solo dal primo salto ma potrebbe essere raggiunta anche con il normale corso delle istruzioni (passo passo). Prima o poi verrà raggiunta l’istruzione questa istruzione di salto che effettuerà un salto più avanti senza volerlo. Si dovrà inserire un’ altro jump, esattamente prima dell’istruzione di salto in questione , che salti quest’ultima e quindi evitandola e che ci riporti esattamente all’istruzione successiva in modo da permettere la regolare esecuzione delle istruzioni.
Questo è un altro studio che invece prende in considerazione le istruzioni, le divide in due categorie: le LOAD e le operazioni della ALU. Verifica più o meno com’è l’andamento relativamente a un programma che fa uso sia di numeri floating point che ad aritmetica intera perché quando studieremo il processore ampliando l’orizzonte alla parte del processore che opera sui dati floating point ci accorgeremo che il processore può essere diviso in due moduli: -
Uno che opera sulle istruzioni che fanno riferimento a dati interi
-
Uno che opera sulle istruzioni che fanno riferimento a dati floating point
Le parti di logica che fanno i calcoli sono diverse, l’hardware che permette di eseguire la somma di due interi non è la stessa che permette la somma di due floating point. La somma floating point prevede un confronto fra le mantisse, l’allineamento ecc. ecc. . Per cui l’hardware è fisicamente distinto allora prendendo in esame le istruzioni che hanno a che fare con le operazioni floating point e le istruzioni che hanno a che fare con i dati ad aritmetica fissa vediamo questo tipo di statistica di un benchmark che fa uso
25
di floating point. Se si osserva la figura, e ci si focalizza sulle operazioni che hanno a che fare con i dati ad aritmetica intera, un quarto sono operazioni aritmetico logico. Quando si effettua una LOAD, il 23% di queste usano un dato immediato che è per esempio l’ indirizzo. Nelle operazioni ALU, il 25% usano un dato immediato, quindi l’operazione che prevede due operandi: un operando è un registro, l’altro non è in un registro ma è in un immediato. Il compilatore quando ha tradotto quell’istruzione già sapeva qual’era un operando, non doveva prenderlo da un registro. Ad esempio tipicamente in un contatore di un loop ,i++, non ha senso mettere 1 in un registro, ad esempio R6, mettere “i” in un altro registro, ad esempio R7, e ogni volta fare R7+R6 così occupo un registro R6. Sapendo invece che a R7 va sommato sempre 1, il compilatore nello scrivere quell’istruzione scriverà un’istruzione di somma con immediato, quindi scriverà nell’istruzione stessa il numero 1 come operando immediato ed R6. Quindi scriverà un’istruzione che è fatta da un codice operativo che non significa somma ma significa somma con immediato. E poi ci sarà per esempio R6, e poi l’immediato 1, a significare che R6 va sommato al numero 1 e il risultato va sempre in R6.
6. Operazioni Nell’insieme Di Istruzioni Abbiamo accennato che i dati sono anch’essi nel loro formato una caratteristica che diversifica il processore. Quindi per esempio abbiamo dati che a partire dal singolo byte possono essere raggruppati in categorie che comprendono le word da 4 byte e le doppie word da 8 byte. Esiste anche una via di mezzo le half word da 16 byte. Questi dati vengono effettivamente usati. Osservando la figura pag. 510, per quanto riguarda i dati floating point, la gran parte delle operazioni riguarda dati floating point di tipo doppia word per una certa applicazione. Questo studio serve a definire se è importante considerare un ALU a 64 bit perché per esempio se si fa in modo di trattare i dati da 64 bit come dati da 32 bit si può pensare ad una ALU da 32 bit(prima fa il calcolo su 32 della parola e poi sugli altri 32 bit). Se invece si deve fare la somma di due dati da 64 bit ma l’ALU è da 32 bit si può decidere che tutte le volte bisogna fare somme di dati da 64 bit si divide il dato in due parti da 32 e 32, e si esegue la somma di una parte e poi dell’altra e precisamente si farà la somma ovviamente prima delle due parti meno significative. Questa somma eventualmente genererà un riporto che si aggiungerà alla somma delle due parti più significative. Avendo un ALU di 32 bit è necessario eseguire in pratica due calcoli. Questa soluzione è ottima fintantoché mi accorgo che dati da 64 bit con cui fare i calcoli non sono molti. Purtroppo si nota dalla figura che nel 60 % dei casi bisogna fare calcoli con dati da 64 bit, allora ci si pone la domanda se ha senso progettare un ALU da 64 bit per fare in un colpo solo quello che si effettuava in due colpi. Si ha senso. Le stesse considerazioni fatte per l’ALU si possono fare per i registri: devono essere di 32 bit o 64 bit? Se si pensa a una aritmetica(ALU) di 32 bit che all’occorrenza fa anche calcoli da 64 bit, i registri saranno di 32 bit e quando si ha bisogno di dati da 64 bit verranno utilizzati due registri contigui. Ma questa coppia di registri può essere R7 e R17 per esempio? Teoricamente si, però comporta dei problemi; Nell’istruzione bisognerà specificare R7 e R17, che contengono le due parti di un dato, poi si avrà bisogno di specificare altri due registri che contengono le parti del secondo dato, esempio R5 ed R19, ed infine si bisognerà specificare i due registri per contenere le due parti del risultato, R8 e R21 ad esempio. Sono necessari 6 slot per indicare gli operandi. Se i registri sono contigui invece basterà specificare R7, perché il secondo pezzo sarà R8; basterà specificare R9, perché la seconda parte si trova in R10; ed infine per il risultato basterà specificare R2 perché la seconda parte sarà R3. 26
Un altro problema che si verifica utilizzando registri non contigui nasce quando bisogna connettere alla parte che fa i calcoli, i due registri. L’unità di controllo diviene molto complessa. Quindi a fronte di una massima flessibilità, che porterebbe a desiderare la libertà di mettere quei 64 bit in due registri a piacere, le cose si complicano sia dal punto di vista delle connessioni verso le unità di calcolo e soprattutto nella strutturazione di un istruzione che dovrà prevedere 6 specifiche.
27
28
CAPITOLO 5
ISTRUZIONI DEL PROCESSORE MIPS Il processore di riferimento è il processore MIPS. È sviluppato con filosofia RISC, ha un numero ridotto di istruzioni ma che hanno tutte una struttura molto simile tra di loro. Inoltre è un processore di tipo LoadStore, le uniche operazioni che possono accedere a memoria sono le istruzioni LOAD e STORE. Il processore MIPS è costituito da 32 registri interi da 64bit di utilizzo generale (GPR, general-purpos register), denominati R0, R1,…, R31. Ci sono inoltre registri per numeri in virgola mobile (FPR, floating-point register), denominati F0,F1,…,F31. E infine c’è anche un piccolo numero di registri “speciali”. I tipi di dati presenti sono: byte (8bit), half word (16bit), word (32bit) e double word (64bit). Esistono solo due modalità di indirizzamento, immediato e con scostamento. Le istruzioni di questo processore sono di 32 bit e presentano tutte un codice operativo costituito da 6 bit. Le istruzioni sono fondamentalmente di tre tipi: tipo R, tipo I e tipo J. 1.
Istruzioni di tipo J
Le istruzioni di tipo J prevedono una struttura semplice, oltre ai 6 bit del codice operativo, i rimanenti 26 bit codificano un immediato, l’indirizzo dove saltare. L’architettura prevede indirizzi di 32 bit, quei 26 bit non sono un indirizzo assoluto, ma sono un offset ovvero uno spiazzamento che va sommato al Program Counter (PC). Oltre ai comuni registri di un processore, ne esistono degli altri, chiamati registri speciali o dedicati perché non contengono dati generici, ma dati che hanno un determinato significato. Uno di questi è il PROGRAM COUNTER (PC). Il PC è un registro che contiene l’indirizzo dell’istruzione da eseguire. Quando viene eseguita una istruzione serve sapere l’indirizzo di questa per prelevarla e il processore conosce questo indirizzo attraverso il PC. Quando termina una istruzione e se ne deve prelevare un'altra, il PC contiene l’istruzione successiva a quella eseguita grazie a un aggiornamento che viene fatto durante l’esecuzione di una istruzione. Se PC=16byte, viene eseguita l’istruzione al byte 16, e considerando istruzioni istruzione di 4 byte (32bit), al termine di questa istruzione, viene incrementato il PC, PC=PC+4=16+4 = 20byte, verrà eseguita l’istruzione al byte 20. Durante l’esecuzione di una istruzione oltre a fare quello che l’istruzione richiede bisogna aggiornare il Program Counter. Necessariamente ogni volta che verrà eseguita una istruzione oltre a essere compiuto quello che l’istruzione richiede bisogna aggiornare il PC. Se le istruzioni hanno tutte la stessa lunghezza, lunghezza fissa, l’operazione è facilitata. Un conto è aggiornare il PC senza sapere dove è l’istruzione successiva, perché quella in corso ora è ad esempio di 8 byte, e quindi bisogna aggiornare di 8byte questa volta il PC, mentre l’istruzione successiva magari è di 15 byte e quindi bisognerà aggiornare di 12 byte il PC. Ritornando all’istruzione di salto, questa scrive un nuovo valore nel PC, scrive il valore del nuovo indirizzo dove saltare nel PC. L’istruzione non può contenere tutti i 32 bit dell’indirizzo dove saltare perché se ne hanno a disposizione al massimo 26 di bit nel caso di una jump. I 26 bit contengono uno spiazzamento di quanto bisogna spostarsi dall’istruzione attuale, dal PC. Ad esempio se l’istruzione corrente si trova all’indirizzo 90 e bisogna saltare all’istruzione 150, bisognerà scrivere nei 26 bit il valore 60, scopriremo che bisognerà scrivere 56 perché si darà per scontato che il PC si è giù incrementato di 4 byte. I salti che 29
vengono effettuati sono tutti multipli di 4 gli indirizzi si spostano di 4 in 4, visto che le istruzioni sono di 4 byte. I multipli di 4 in binario terminano con 2 zeri, gli ultimi due bit valgono zero. Si può pensare che quei 26 bit contengono l’indirizzo privato dei due zeri finali(risparmio 2 bit): 12 = 1100 => 11 = 3, scrivendo 3 (11) ci si sta riferendo a 12(1100). Si hanno a disposizione quindi 28bit. Inoltre se il valore è positivo il salto avviene in avanti, altrimenti indietro.
2. Istruzioni di tipo R Le istruzioni di tipo R sono istruzioni che prevedono l’uso di 3 operandi di tipo registro, due operandi sono di input e 1 operando è di output: due sorgenti e una destinazione. Ricordiamo che questa è una macchina Load-Store in cui le uniche operazioni che accedono a memoria sono le Load e le Store, mentre le operazioni aritmetiche non accedono a memoria. L’architettura ha un certo numero di registri, ma la visibilità è solo su 32 registri, gli altri servono per la Finestra di Registro. Sono necessari allora 5 bit per codificare un registro, poiché l’istruzione specifica 3 registri, servono 15 bit per i registri, in sostanza 6 + 3*5 = 21, gli altri 11 bit sono utilizzati per altre informazioni, se i dati sono con segno senza segno.
3. Istruzioni di tipo I Il tipo I prevede una struttura in cui un operando è un immediato e gli altri due sono registri: 6 + 2*5 = 16bit i restanti 16bit sono utilizzati per l’immediato. Uno scenario è quello in cui degli operandi sono noti. Essendo la macchina Load-Store non si può prelevare un operando dalla memoria attraverso un operazione aritmetico logica, quindi gli operandi o sono tutti nei registri o qualcuno è un termine noto. Solo un operando però deve essere un termine un noto(due operandi termini noti non ha senso). Deve esistere quindi un solo immediato come sorgente non come destinazione(altrimenti si sta risolvendo una equazione!). Il codice operativo della somma con immediato è diverso dall’istruzione somma senza immediato. E’ il codice operativo che indica se l’istruzione è di tipo R o di tipo I o J. Prelevata l’istruzione questa va decodificata, capire di che tipo è l’istruzione di cui ci si sta occupando, se è di tipo R allora gestisce i bit diversamente se l’istruzione è di tipo I o J.
30
4. Le istruzioni del calcolatore MIPS Tutte le istruzioni si differenziano per tipologia in una serie di 4 famiglie:
Trasferimento dati
Operazioni aritmetico logiche
Istruzioni di controllo (salto)
Istruzioni di calcolo in virgola mobile
Le operazioni di calcolo in virgola mobile sono distinte dalle operazioni logico aritmetico perché di fatto interessano parti diverse del processore, perché sia i dati che l’hardware per fare i calcoli sono diversi. Le istruzioni logico aritmetico usano registri di tipo R e la parte del processore che esegue il calcolo è l’ALU, le istruzioni in floating point usano registri di tipo F e la parte del processore che effettua il calcolo non è l’ALU ma altre unità. Più che il codice operativo che è un insieme di numeri, 1 e 0, utilizziamo una traduzione mnemonica, insieme di lettere più facili da ricordare. 4.1. Istruzioni di trasferimento dati Nel trasferimento dati si intende il trasferimento dei dati dalla memoria ai registri e viceversa, oppure dai registri ad altri registri. Il modo per accedere a memoria è attraverso l’uso dell’immediato da 16bit da sommare ad un registro (istruzione di tipo I). Per accedere a memoria l’indirizzo viene specificato sempre nella modalità in cui l’istruzione di tipo I considera i 16bit di immediato da sommare a uno dei due registri. La prima lettere delle istruzioni è una L o una S: istruzioni Load o Store: lettera significato L Load S Store L trasferimento dalla memoria al registro, S trasferimento dal registro alla memoria. La seconda lettere precisa la dimensione del dato. lettera B H W D
significato Byte Half Word Word Double Word
Ci sono alcune istruzione che hanno come ultima lettera una U (unsigned). La presenza o meno della lettera U indica che il dato è rappresentato con segno o senza segno(U). Se il dato in questione è un byte e non viene specificata la lettera U, il dato è un byte signed altrimenti con la lettera U rappresenta un dato di un 31
byte unsigned. E’ importante specificare questo perché il registro è di 32 bit, facendo la Load di un byte in cui specifico un certo registro LB R6 R7 100 Si ottiene l’indirizzo di memoria sommando R7 a 100, e viene prelevato un byte dalla memoria puntata da questo indirizzo e lo si memorizza in R6: M[R7+100]->R6 Normalmente viene prelevata una parola, poiché l’istruzione è una LOAD di 1 byte(LB), ci si focalizza sul byte di questa parola che viene messo in R6. Ora che in R6 ci sono gli 8 bit, i restanti bit verranno riempiti con degli zeri se il numero è insigne, altrimenti con il bit più significativo, poiché il numero sarebbe in complemento a 2 in questo caso. Se viene prelevato un dato in Double Word , 32bit, questo problema dell’unsigned non esiste, visto che questo comprende tutti i bit. Questo problema dell’espansione dei bit esiste solo nelle istruzioni LOAD, mentre nelle Store si andrà a scrivere in memoria solo il byte, i restanti bit non vengono toccati. L.S, L.D, S.S, S.D sono istruzioni di LOAD e di STORE che fanno uso di registri floating point. Il punto, “.” indica una LOAD o una STRE in un registro che contiene dati floating point, non sono registri R ma registri F. La lettera S o una D indica la precisione, la dimensione, rispettivamente Single Precision(32bit) o Double Precision (64 bit). MFC0, MTC0 (Move From, Move To). La move from copia da un registro GPR(General Purpouse Register) a un registro speciale (registri particolari per esempio il PC, uno dedicato allo stack pointer, dedicato a un controllo di memoria ecc., cioè registri che non vengono usati per immagazzinare i dati durante i calcoli delle operazioni che fanno parte di un programma). Mentre l’istruzione Move To copia da un registro speciale a un registro GPR. MOV.S MOV.D Copiano dati in Singola precisione o Doppia precisione da un registro a un altro cioè creano un duplicato del dato. A cosa potrebbe servire un duplicato del dato? Può presentarsi il caso in cui il dato viene aggiornato in una certa maniera per un ramo dell’algoritmo e in un'altra maniera in un'altra situazione dell’algoritmo. Con un'unica operazione di tipo LOAD all’interno del processore, si accede una sola volta alla memoria e una volta caricato, con l’operazione MOV lo si replica. MFC1, MTC1 Come MFC0, MTC0 fanno copia da registri speciali a registri GPR La move from copia da un registro GPR(General Purpouse Register) a un registro speciale, solamente che queste usano dati di virgola mobile. Quindi trasferiscono dati da registri in virgola mobile a registri in virgola fissa o viceversa. Bisogna fare attenzione poiché in questo passaggio di dati non avviene nessuna conversione! Non esistono istruzioni per trasferire un numero in virgola mobile a registri speciali, per il semplice fatto che il contenuto di questi ultimi , normalmente, ha un significato binario. Quindi, per esempio, dati che sono in registri floating point non hanno niente a che vedere con un indirizzo. Però qualora fosse indispensabile trasferire il contenuto in un registro floating point in un registro speciale, lo si fa utilizzando l’istruzione di tipo MTC1,
32
per copiare il contenuto dal registro floating-point in un registro intero GPR, e poi attraverso un istruzione di trasferimento MTC0 si trasferisce il contenuto dal registro intero GPR a un registro speciale.
4.2. Istruzioni per operazioni logico-aritmetiche DADD, DADDI, DADDU, DADDIU . L’istruzione DADD effettua la somma. La prima lettera D indica che si tratta di un dato DOUBLE WORD. DADD significa che l’operazione è una somma. Eventualmente ci sono due altre lettere: I e U, o entrambe. La lettera U come abbiamo già detto che il dato è insigne, significa che gli la somma avviene tra operandi unsigned, dati unsigned, la lettera I indica che la somma ha come secondo operando un immediato invece che un registro. Nell’istruzione DADD sono specificati tre registri, due sorgenti e uno di destinazione, istruzione di tipo R. Nell’istruzione DADDI invece, sono specificati due registri, uno di destinazione e uno sorgente, e un immediato considerato sempre come sorgente, istruzione di tipo I. Le istruzioni con la lettera I si chiamano istruzioni di tipo immediato. Come mai tutte queste istruzioni operano con dati di tipo Double? Quando si è caricato il dato in un registro, attraverso l’operazione di LOAD, il dato che come abbiamo detto può essere prelevato come byte, half word, word o double word, nel momento in cui è stato scritto nel registro è stato espanso a 64bit, è stato tradotto in un double word. Ma ci si potrebbe chiedere: non si impiega meno tempo a sommare due dati di tipo byte? Fare un operazioni con dati che sono byte comporta meno tempo rispetto a operazioni con dati double, ma non servirà risparmiare questo tempo perché non comporterà dei vantaggi quando penseremo alla gestione PIPELINE, la quale destinerà a ogni fase dell’istruzione, fetch decode execute, una fetta di tempo. E se si destino a fare questa parte dell’operazione, un tempo sufficiente a fare la somma di un byte, ci si trova nei guai quando si dovrà fare la somma di un double che richiederà più tempo. Ci accorgeremo che non solo si dovrà dimensionare al tempo di calcolo dell’operazione più lenta ma si dovrà fare in modo che le varie porzioni di tempo assegnate a ciascuna fase dell’operazione dovranno essere uguali. Per cui non si può pensare di dare a questa parte di esecuzione un tempo diverso dall’altra parte perché quando la prima istruzione starà facendo un’operazione ci sarà un'altra istruzione che starà facendo la decodifica, quindi queste due operazioni dovranno avvenire simultaneamente. Si capirà meglio il concetto quando parleremo della tecnica della pipeline. Ci si può ancora chiedere come mai durante la somma di due dati double , bisogna specificare signed o insigned? Quando si effettuano delle operazioni logico-aritmetiche con dati insigned, in binario puro quindi, queste possono generare un overflow. Il numero di bit necessari per esprimere il risultato dell’operazione è maggiore dei bit a disposizione per contenerlo, ad esempio viene generato un bit al 65esimo posto, e il risultato ottenuto nei 64 bit è errato. Supponiamo di avere un dato a 4 bit: 1 0 1 1 + 1 1 0 0 = 1|0 1 1 1
33
Il processore restituisce come risultato 0111 segnalando un overflow, ad indicare che il risultato ottenuto è errato. Facendo un operazione con due numeri signed, quindi in c.a 2, la generazione di un overflow potrebbe essere normale e non da considerare, perché quel bit, o quei bit perché potrebbero essere più di uno, sono il bit di segno che si propagano. Quindi il meccanismo di controllo all’uscita dell’ALU, è diverso se i dati sono unsigned o signed, per questo motivo bisogna specificare se si sta operando con bit con o senza segno. Se questi bit fossero stati signed, nell’esempio precedente 1 0 1 1 +
(-5) +
Per leggere il numero in c.a 2, bisogna verificare
1 1 0 0 =
(-4) =
se il primo bit è 1, segnare il meno, poi fare il
1|0 1 1 1
-9
c.a 1 e poi sommare 1.
Il bit che è andato oltre è un bit di segno, ad indicare che il numero non è un numero positivo ma negativo. DSUB, DSUBU Effettua la sottrazione. Non prevede l’immediato perché fare una sottrazione con immediato, equivale ad una somma con l’immediato negativo. In questo modo non solo si risparmia un codice operativo ma anche tutta l’unità di controllo necessaria a controllare il processore nell’esecuzione di un istruzione, si risparmia sulla parte hardware. DMUL, DMULU, DDIV,DDIVU Effettua una moltiplicazione o una divisione con dati signed o unsiged. MADD ( o MACC in altri set, moltiplicazione ad accumulazione) E’ un operazione che nella stessa istruzione effettua una moltiplicazione e una somma. C’è un piccolo problema nella MADD. Si prevedono due operandi per fare il prodotto, uno per fare la somma e infine ci vuole un quarto operando per scrivere il risultato. Ma le istruzioni prevedono al massimo tre operandi. Quindi per fare la MADD , un operando ha doppia valenza , sia come risultato che come operando. Quindi chi riceve il dato per fare la somma è quello che poi riceve anche il contributo del risultato. Essa fa una cosa del tipo: X += Y * Z che sarebbe X= X + (Y*Z) Dove X, Y, Z sono i tre registri specificato nell’istruzione. Questa operazione MADD è molto frequente: c’è un valore che si accresce dei prodotti di altri due fattori, come nel prodotto scalare tra due vettori:
Questo può essere fatto, con un loop di istruzioni di tipo MADD.
34
AND, ANDI Operazione di AND logico sia fra due registri che fra un registro e un immediato. Se di una AND uno dei due operandi è noto al compilatore, non ha senso sprecare un registro per metterci questo operando. L’operando viene inserito nell’istruzione stessa come immediato. Questo operando immediato non potrà essere un numero che richiederà più di 16 bit, perché le operazioni con immediato hanno un immediato di 16 bit. Ad esempio, non è possibile eseguire un operazione di AND con un dato da 32 o 64 bit e un immediato da 50 bit. Bisogna in qualche modo scrivere questo immediato in un registro e poi fare l’AND fra i due registri. Gli AND con immediato sono utile per fare le classiche operazioni di MASCHERAMENTO. Di un dato a 64 bit, per esempio, si ha la necessità di “vedere”, analizzare solo alcuni bit. Per esempio se si vuole vedere se un numero è pari o dispari: di questi 64 bit interessa guardare il bit meno significativo. Banalmente, si utilizza una “maschera”: un immediato composto da tutti 0 e il bit meno significativo invece vale 1, e si effettua l’operazione di AND tra il registro che interessa analizzare e questo immediato. 0 0 0 0 0 0 … 0 1 Esempio di maschera, immediato da 16 bit con tutti i bit settati a zero eccetto il bit meno significativo.
L’operazione di AND tra il registro e questo immediato risulterà vero o un falso e quindi un valore 1 o un valore 0 a seconda se il numero è rispettivamente dispari o pari. OR, ORI, XOR, XORI Mentre con l’AND abbiamo visto che si fanno le operazioni di mascheramento, con l’OR si fa un operazione di imposizione di sovrascrittura. Se si vuole forzare una certa parola a contenere per esempio nel 7° bit il valore 1, si effettua l’OR di quella parola con l’immediato composto da tutti 0 e al settimo bit il valore 1. LUI( Load Upper Immediate). Durante l’operazione di load di una word, si copia il contenuto di memoria nei 32 bit meno significativi del registro da 64 bit e i restanti vengono espansi in segno. Con l’istruzione LUI c’è la possibilità di caricare un immediato, da 16 bit, scritto nell’istruzione stessa, nella parte superiore del registro, nei bit del registro che vanno da 32 a 47. L’immediato viene espanso in segno, in modo che da occupare con la replica del bit di segno i restanti bit da 48 a 63, mentre i bit meno significativi quelli che vanno da 0 a 30 sono posti a zero. 63 62 … … 48 47 46 … 32 31 30 … 1 1 1 1 0 1 0 0 0 espansione in segno
… 1 0 0 0 0
immediato
DSLL, DSRL, DSRA,DSLLV( Double Shift Left, Double Shift Right) fa scorrere il registro verso sinistra o verso destra. La lettera A o L indica lo shift Arithmetic o Logic. Lo shift logico verso destra o verso sinistra consiste rispettivamente nel moltiplicare o dividere per potenze di due a seconda del numero di posizioni di cui si sta shiftando, facendo entrare quindi degli zeri da sinistra o da destra. Lo shift aritmetico verso sinistra si comporta come lo shift logico verso sinistra, vengono fatti entrare degli zeri da destra. Mentre nello shift aritmetico verso destra viene fatto entrare da sinistra il bit più significativo, il bit di segno. Quindi c’è una differenza tra i due shift solo durante lo shift verso destra. Per
35
questo motivo Shift Left ha solo DSLL e non DSLA, perché non ha senso distinguere fra shift a sinistra logic e arithmetic. La differenza fra Logic e Arithmetic appare solo in caso di shift right. Queste operazioni di Shift avvengono con un operando immediato. Esistono operazioni di shift con operandi che sono registri, DSLLV. La lettera V indica variabile e cioè che il numero delle posizioni da scorrere è in un registro. SLT, SLTI, SLTU, SLTIU (Set Less Than) Imposta uguale a 1 un particolare registro di condizione se un registro è minore di un altro registro. Ad esempio: SLT
R1
R2
R3
R3 viene settato a 1 se R1 è minore di R2 SLTI R1
R2
5
Se R1 è minore dell’immediato , R2 viene settato uguale a 1. La eventuale U di unsigned fa il confronto tenendo presente che i dati scritti sia nell’immediato che nel registro sono dati unsigned.
4.3. Istruzioni di controllo Come abbiamo già detto ci sono due gruppi di istruzioni di controllo: -
Quelle che saltano in maniera CONDIZIONATA
-
Quelle che saltano in maniera INCONDIZIONATA
Ogni produttore ha un modo diverso di chiamare queste istruzioni, però una letteratura è quella di classificare come BRANCH le istruzioni di salto condizionato e JUMP le istruzioni di salto incondizionato. E’ opportuno considerare delle condizioni abbastanza semplici da valutare, per cui le condizioni in caso di branch saranno limitate. I test che si fanno per verificare se saltare o meno sono quelli relativi all’uguaglianza o alla diversità. Quindi non si potrà avere un branch se R1 >0 o se R1 > R2; si potrà avere un salto soltanto se R1== R2 o se R1!=R2 . Perché non si possono fare istruzioni di branch con immediato? L’istruzione di tipo branch, è di tipo immediato: cioè codificherà due registri e l’immediato il quale sarà necessario per indicare dove saltare. Poiché l’immediato serve a codificare dove saltare non può essere utilizzato per verificare una condizione. BEQ, BNE (Branch Equal) Confronta due registri se sono uguali o meno ed effettua il salto. BEQZ, BNEZe effettua il contronto tra un registro e un immediato, l’immediato 0. Non c’è la possibilità di scegliere un altro immediato. Zero non va scritto nel campo immediato perché è l’unico immediato consentito. Siccome il più delle volte quando ci sono da fare salti, il confronto è con l’immediato 0, allora si è introdotto un codice operativo per fare il salto relativamente all’immediato 0. Nell’immediato bisogna inserire il numero che sommato all’attuale valore di PC che è stato già aggiornato con +4,, produrrà l’indirizzo dove saltare. Ricordiamoci che l’immediato del branch, non è l’indirizzo assoluto: è un valore che va sommato al PC pere ottenere l’indirizzo dove saltare, tenendo conto che il PC è già stato incrementato di 4. Quindi per saltare di due istruzioni più avanti dovrò sommare il numero 4 invece che il numero 8. 36
Salta se R1=R2? BEQ
R1 R2 indirizzo
Salta se R1!=R2? BNE
R1 R2 indirizzo
Salta se R1>R2? SLT R3 R1 R2
se R1>R2 allora R3 = 1 altrimenti R3 =0
BNEZ R3 indirizzo BC1T, BC1F le istruzioni di salto che fanno il controllo sul bit di confronto relativamente alle operazioni floating point MOVN, MOVZ Copiano i dati da un registro all’altro, sempre di tipo R, qual’ora l’operando di test è negativo o è zero. Copia R1 in R2 se R3 è uguale a zero MOVZ R1
R2
R3
Copia R1 in R2 se R3 è minore di zero MOVZ R1
R2
R3
Le istruzioni di salto incondizionato: J, JR (jump e jump con registro) Jump è un istruzione con immediato a 26 bit, mentre jump con registro è un istruzione di tipo R dove il registro contiene l’indirizzo dove saltare. Mentre i 26 bit di immediato si sommano al PC, Jump di tipo R (JR) specifica un registro che specifica l’indirizzo dove saltare. JAL, JALR oltre a fare esattamente cosa fa la Jump si preoccupano di salvare, prima di saltare, il contenuto del PC. Queste istruzioni prendono il contenuto del PC, quando questo e’ gia’ stato incrementato , e lo salvano nel registro R31. Questo è un meccanismo per gestire il ritorno da una routine. Però attenzione il PC viene memorizzato in un registro non in uno stack, di conseguenza se nella procedura avviene una chiamata a un’altra routine viene perso il valore del PC e sostituito con quello nuovo. TRAP Istruzioni particolari che servono a trasferire il controllo a procedure del sistema operativo. Succede che durante l’esecuzione del programma(applicazione utente) , il programma stesso preveda che in un certo momento occorre trasferire il controllo a una particolare procedura del sistema operativo. ERET (Exception Return) Ci sono una serie di casi in cui il programma viene interrotto. Fondamentalmente queste casistiche vengono raggruppate nelle: 1) ECCEZIONI sono situazioni che si verificano in maniera SINCRONA, cioè quel programma, tutte le volte che verrà eseguito con quei dati genererà sempre quelle eccezioni. Esempio: la divisione di un numero per zero. 37
2) INTERRUPT o INTERRUZIONI sono come le eccezioni ma si rivelano in maniera imprevista, perché non dipendono dalla struttura e dalla natura tipica del problema e dei suoi dati ma dipendono anche da fenomeni esterni all’esecuzione stessa del programma.Esempio: diamo il comando di stampa alla stampante e viene a mancare la carta, o quando un sistema operativo non trova il file. Questa istruzione consente di tornare nel punto in cui si era prima di essere andati ad eseguire l’eccezione.
38
Esercizi Esercizio n.1: Supponendo che all'indirizzo K ci sia la prima istruzione del programma, saltare all'indirizzo K+1000 nel caso in cui R2 sia maggiore o uguale a R3.
Svolgimento: 1. K: 2. K+4:
SLT R1, R2, R3 BEQZ R1, 248
Commento: La prima riga, serve a confrontare i due registri, e setta R1=0 se R2>=R3, la seconda riga, fa un branch nel caso in cui R1=0. ATTENZIONE: 248, indica lo scostamento del program counter(ricorda che il PC non può essere modificato direttamente con il registro, può essere solo incrementato), lo scostamento però va considerato tenendo a mente che ogni istruzione scosta il PC di 4. Quindi, quel 248 deriva dal fatto che ci troviamo all'istruzione 8. 1000-8=992 → 992/4=248. Questo, solo nel caso in cui io considero lo spiazzamento a MENO DEI DUE BIT MENO SIGNIFICATIVI. Se usassi una notazione normale, dovrei mettere 992.
Esercizio n.2: Scrivere una chiamata ad una subroutine supponendo che l'indirizzo della subroutine sia a 10000. Scrivere la subroutine in modo che faccia questo: Copia il valore presente in M[100] e lo scrive in M[200].
Svolgimento:
1. DADDI 2. JALR
R5, R0, 10000 R5
Subroutine: 1. LD 2. SD 3. JR
R1, 100(R0) R1, 200(R0) R31
Commento: Innanzitutto, ERET non è un'istruzione valida in questo caso perchè fa riferimento esclusivamente a routine del sistema operativo. La prima riga, serve a scrivere in R5 l'indirizzo 10000(sarebbe come scrivere in R5 la somma tra R0, che si trova a 0, e l'immediato 10000). La seconda riga, permette di fare un salto incondizionato all'indirizzo R5 che grazie alla prima riga è 10000 e memorizza in R31 il valore dell’istruzione che si trova subito dopo JALR. Una volta alla subroutine il programma fa prima una load, poi una store, e infine con JR R31, ritorna all'istruzione immediatamente successiva alla JALR. Ricorda che il registro dell'istruzione successiva alla JALR viene messo di default in R31. (R0) serve a 39
specificare l'indirizzo in cui fare la load/store. Sarebbe come scrivere: Fai la Load dall’indirizzo di memoria 100(dove 100 indica lo scostamento da R0, che di default è uguale a 0) e fai la Store dello stato dato R1 a 200(dove anche in questo caso 200 indica lo scostamento da R0).
Esercizio n.3: Fare la divisione tra R1 ed R2 in modo che se R2=0, il programma lasci la gestione dell'eccezione al sistema operativo. Svolgimento:
1. 2. 3. 4.
BEQZ DDIV J TRAP
R2, 2 R3, R2, R1 1
40
CAPITOLO 6
ARCHITETTURA CALCOLATORE PER LE OPERAZIONI CON DATI INTERI 1. Fasi Di Una Istruzione Le fasi dell'istruzione sono delle operazioni che vengono svolte durante l'esecuzione di ogni istruzione. Un'istruzione per essere eseguita deve innanzitutto essere letta dalla memoria e caricata nel processore. Questa prima operazione viene chiamata Fetch(prelievo), oppure Instruction Fetch (IF). Una volta prelevata occorre che venga decoficare, questa seconda fase prende il nome di Instrucion Decode (ID). Terminata la decodifica dell'istruzione il processore è in grado di capire cosa fare, a questo punto entra in attività la fase di Execute(esecuzione). Questo nome non deve trarre in inganno, per esecuzione si intende tutto il complesso delle fasi dell'istruzione; mentre per FASE di Execute, si intende la fase che impegna l'ALU del processore se operiamo con dati interi, oppure che impegna l'unità di calcolo Floating Point, se stiamo operando con numeri a virgola mobile. Quando l’istruzione ha bisogno di accedere alla memoria, si avrà la Fase di accesso a memoria che prende il nome di MEM(sia scrittura che lettura dalla memoria). Ricordiamo però che la stessa istruzione non può sia leggere che scrivere in memoria, deve fare solo una delle due. Infine c’èla fase di scrittura in un registro, che prende il nome di Write Back (WB). Dall'execute in poi, sono fasi che esistono o meno a seconda dell'istruzione. Tutte le istruzioni vengono eseguite in maniera identica per quanto riguarda la fase di Fetch e di Decode. Nella fase di decodifica, mentre l'istruzione si trova nell'Instruction Register (IR), il processore svolge anche un'altra mansione, per accelerare i tempi, si porta avanti con il lavoro e effettua la “Precarica” degli operandi, o meglio prepara i registri che dovrebbero essere usati per fare i calcoli della fase di Execute. Attenzione la ALU la usiamo anche se l'istruzione è di tipo I(con immediato), o anche durante una LOAD ecc. praticamente la ALU si usa quasi sempre. Supponiamo ad esempio di voler eseguire un'operazione che richiede l'uso dei registri R1 ed R2, mentre viene effettuata la decodifica, si porta in ingresso all'ALU già R1 ed R2 e anche l’immediato esteso in segno, poi una volta terminata la decodifica vengono utilizzati i dati che realmente servono nell'ALU e viene eseguita l'operazione decodificata. Tutto questo serve a velocizzare i tempi di esecuzione perchè ancora prima che la decodifica termini, si hanno già tutti i dati pronti per entrare nell'ALU e quindi si riuscirà ad accederci in maniera più veloce, infatti non si avrà bisogno di caricarli dopo la decodifica. Questa operazione di precarica potrebbe anche essere stata un'operazione inutile, come nel caso di una Jump poiché non si utilizzano i registri, ma non è un problema, l'importante è che questo tipo di operazione non faccia danni. Questa azione viene fatta in maniera speculativa, o meglio, nel caso in cui l'istruzione richiedesse entrambi i registri, allora si è risparmiato tempo; mentre nel caso in cui non si è risparmiato tempo, comunque non ci sono stati danni. Per esempio, invece di caricare i registri, potrei pensare di preparare un immediato; per “preparare un 41
immediato” si intende prendere i 16 bit di quest'ultimo ed espanderli in segno. Durante l'esecuzione non va tralasciato il PC, perchè contenendo l'indirizzo dell'istruzione che devo eseguire, alla fine dell'istruzione va sempre incrementato, a prescindere dall'istruzione. L'incremento può essere fatto in qualsiasi momento dell'esecuzione dell'istruzione, purchè avvenga sempre nell'ambito dell'istruzione stessa. Però è preferibile anticipare l'incremento del PC perchè è possibile eseguire l'istruzione successiva, prima che l'istruzione corrente sia finita(concetto di pipeline). Quindi, poiché la prima fase dell'istruzione è la Fetch, è necessario incrementare subito il PC perchè ci permette di anticipare l'esecuzione dell'istruzione successiva. L'incremento del PC quindi può essere fatto durante la fase di Fetch, e non necessita di alcun calcolo durante la fase di Execute. In conclusione si può pensare di : leggere il PC , caricare l’istruzione relativa al PC, incrementare il PC, PC+4, perché ora il suo valore non serve, e proseguire già con la fetch di un'altra istruzione relativa al nuovo PC, mentre l’altra istruzione prosegue con le altre fasi.
2. Architettura Calcolatore Per La Gestione Di Dati Interi Questo è lo schema dell’architettura del processore che opera sui dati ad aritmetica intera. Sono rappresentati gli elementi, i moduli della parte del processore che opera su dati ad aritmetica intera.
Analizzando la fase di Fetch: cominciando da sinistra (1) il primo modulo è il Program Counter che rappresenta un registro in cui sono scritti dei bit che indirizzano la memoria relativamente al prelievo dell’istruzione da eseguire (fase di fetch). Il PC va quindi in ingresso alla Memoria delle Istruzioni(2). La memoria in generale è divisa in due moduli distinti identici; l’unica diversità è che sono due aree una destinata a contenere le istruzioni dei programmi(Memoria delle Istruzioni (2)) e un’altra che serve a contenere i dati(Memoria dei Dati (11)). Questa architettura della memoria fa comodo perché quando si gestisce l’esecuzione di una istruzione in simultanea a un'altra istruzione questo risolve il problema causato 42
dai conflitti strutturali. Nella tecnica della pipeline, che analizzeremo successivamente, quando viene eseguita una istruzione, questa non viene eseguita da sola ma nel frattempo viene eseguita anche un'altra. Bisogna garantire per permettere questo alcune condizioni: risolvere i conflitti strutturali e i conflitti di dato. I conflitti strutturali sono i conflitti che insorgono tra due istruzioni che vogliono usare la stessa risorsa. Se ci sono due istruzioni che hanno bisogno di usare l’ALU, queste non possono essere eseguite contemporaneamente. Un conflitto strutturale che può avvenire utilizzando un'unica memoria che contiene istruzioni e dati, nasce quando ad esempio ci sono due istruzioni che accedono a memoria, ad esempio avviene la fetch di una istruzione e nel frattempo viene eseguita una load da un'altra istruzione. Durante la fase di accesso a memoria di una Load per esempio che vuole caricare un dato(accesso a memoria) non può avvenire contemporaneamente il prelievo di un'altra istruzione. La memoria o pensa a fare la load oppure pensa a fare il prelievo di una istruzione(conflitto strutturale). Per risolvere questo conflitto la cosa più semplice è di implementare l’architettura Harvard in cui si tengono distinte le due memorie, un area in cui ci sono i dati da leggere e scrivere, e l’altra area che contiene le istruzioni di un programma, che il Loader, la parte del Sistema Operativo che si occupa di caricare in memoria il programma da eseguire, memorizza. Un sistema multi task consente l’esecuzione sul processore di più processi, cioè vengono gestiti simultaneamente più processi. Questo vuol dire che il processore esegue in un istante una istruzione alla volta. Ci sono poi varie politiche della gestione della CPU per esempio in base alla priorità dei processi o alla suddivisione dei tempi. Oltre alla cpu bisogna gestire però anche la memoria. In una zona della memoria ci sono i dati di un processo o di un altro, in fase di compilazione non si conosce per esempio se l’indirizzo 1000 è disponibile a memorizzare una variabile. Ci sono tecniche per gestire questi indirizzi attraverso la rilocabilità di questo indirizzo. L’indirizzo viene gestito in modo da non essere un indirizzo assoluto ma relativo a un area di memoria allocata per quel processo, in modo da non preoccuparsi se all’indirizzo 1000 c’è un altro processo, ma quell’indirizzo 1000 è relativo all’area di memoria dedicata a quel processo. Se il sistema è anche multiutente in contemporanea quel processore sta lavorando per diversi utenti eventualmente ciascun utente ha in attivo diversi processi, e la divisone delle risorse è ancora più delicata. Per risolvere dunque i conflitti strutturali, dato che ci sono più istruzioni da eseguire in simultanea, la memoria è divisa in due diverse aree indipendenti, Memoria delle Istruzioni e Memoria dei dati(Architettura Harvard) in maniera tale che durante la fetch(memoria delle istruzioni) di una istruzione, un'altra istruzione può effettuare una load o una store(memoria dei dati). In questo modo si evita il conflitto strutturato per l’accesso a memoria.
La memoria delle istruzioni riceve l’indirizzo dal PC e tira fuori l’istruzione che viene memorizzata nel registro IR Instruction Register(3). Nell’IR c’è l’istruzione da eseguire che deve essere decodificata per essere eseguita. Una parte del processore(che non è disegnata) l’Unita di Controllo CU (Control Unit), si preoccupa di capire che tipo di istruzione è stata prelevata e qual è il compito di questa istruzione, cosa questa istruzione deve fare: fase di decodifica.
43
Durante questa fase di decodifica il processore si porta avanti nel lavoro: fa una precarica degli operandi. Facendo delle ipotesi, se questa istruzione è di tipo R allora questi gruppi di bit identificano i registri, due di input e uno di output (4), quelli di input vengono preparati in ingresso all’ALU nei campi A e B(7). Nello stesso tempo se invece l’istruzione è di tipo I un gruppo di bit identificano i registri e i 16bit invece un immediato che va espanso in segno (5) e questi vengono mandati sempre in ingresso all’ALU nei campi A B e Imm; mentre ancora se l’istruzione è di tipo J i 26bit espansi in segno (6) codificano un immediato che va messo sempre in ingresso all’ALU. Quindi in parallelo durante la decodifica dell’istruzione vengono preparati una serie di operandi. In particolare i registri vengono copiati nei campi A e B, e gli immediati invece nel campo Imm e questi vengono mandati in ingresso all’ALU (7). Sono quindi questi che vengono poi effettivamente messi in ingresso all’ALU. Questa operazione avviene a prescindere dal risultato della decodifica, cioè si preparano tutti questi operandi senza conoscere effettivamente il risultato della decodifica(se effettivamente l’istruzione è di tipo R o I o J). Può sembrare lavoro inutile ma non lo è! Terminata la decodifica sono già tutti pronti i dati per andare in ingresso all’ALU, però solo alcuni di questi operandi andranno effettivamente in ingresso all’ALU. A seconda del tipo di istruzione che deve essere eseguita andranno in ingresso determinati operandi. Infatti In corrispondenza degli ingressi dell’ALU ci sono dei Multiplexer (Mux) (8) che fanno passare in ingresso un solo operando tra i tanti che si presentano. I multiplexer sono circuiti elettronici digitali che dati n ingressi, in uscita ne indirizzano uno solo di questi ingressi a seconda del valore di un ulteriore ingresso chiamato Selettore (SEL). Esempio di un multiplexer a due ingressi(I0 e I1):
Se il selettore vale 0,SEL = 0, l’uscita è I0 altrimenti se il selettore vale 1, SEL=1, l’uscita è I1. Questi dispositivi introducono un cammino critico, il percorso con la latenza più lunga. L’uscita dal multiplexer viene letta dopo un certo tempo che sia tale da essere appena superiore al tempo necessario affinché il segnale dall’origine si è propagato fino alla fine, velocità delle piste su cui scorrono i bit più il tempo dei moduli che hanno ritardi standard. La porta Or ad esempio ha una latenza di tot microsecondi, la porta And avrà un'altra latenza quella con l’inverter avrà un'altra latenza ancora. La latenza totale sarà data dalla latenza dell’OR più il massimo (max) tra le latenze delle porte AND, che potrebbero avere la stessa latenza. Attenzione: le due porte AND sono in parallelo quindi la latenza complessiva di queste non è la somma tra le latenze ma il max tra le due. 44
2.1. Istruzione Di Tipo R Come abbiamo già detto la fase di Fetch e quella di Decode sono uguali per ogni istruzione. Le fasi successive invece si differenziano a seconda del tipo di istruzione, I, R o J e alle volte anche dall’istruzione stessa come nel caso di Load e Store oppure istruzioni aritmetico logiche o salti.
Ora ipotizziamo, finita la decodifica, che l’istruzione è di tipo R ed è una istruzione di tipo aritmetico logico. Si predispone l’ALU a fare l’operazione tra i due ingressi, l’ALU si configura a fare l’operazione , ad esempio AND tra i due ingressi. Questi ingressi non vengono presi dal banco dei registri, poiché i registri operandi dell’istruzione sono stati trascritti in A e B. Quindi in ingresso all’ALU vanno A e B. Ritornando all’ingresso degli operandi nel multiplexer, essendo l’istruzione aritmetico logica di tipo R(per ipotesi) in ingresso all’ALU devono andare A e B. Alla prima porta andrà il latch A(multiplexer in alto, lasciando stare per adesso l’altro ingresso NPC) e in ingresso alla seconda porta dell’ALU il multiplexer, con il selettore impostato in una certa maniera dall’unità di controllo, farà passare il latch B. L’unità di controllo, visto che l’istruzione è di tipo R, setta il selettore del primo multiplexer a 1 per esempio per far passare A, mentre setta il selettore del secondo multiplexer a 0 per far passare B. Una volta che gli operandi sono entrati nell’ALU questa eseguirà l’operazione che l’Unità di Controllo imporrà di fare in base al codice operativo, per esempio AND. 2.2. Istruzione di tipo I Ora se invece l’istruzione aritmetico logica è di tipo I, gli operandi sono quindi un registro e un immediato di 16 bit. Ora A conterrà il valore del registro, in B ci sarà sempre qualcosa, solo che ora mentre il primo multiplexer farà passare comunque A, il secondo multiplexer non farà passare B in ingresso all’ALU ma farà passare l’immediato Imm. L’Unità di Controllo imporrà quindi l’operazione nell’ALU fra il registro e l’immediato. Ora il risultato prodotto dall’ALU(a seconda se l’operazione è di tipo R o I o J) viene memorizzato in un latch: ALU output (9). Il LATCH è un circuito o elemento di circuito impiegato per mantenere un particolare stato, per esempio, acceso o spento oppure vero logico o falso logico. Un latch cambia stato solo in risposta a un particolare input. Da ALU output questo partono tre diramazioni, che verranno percorse in base al tipo di operazione: Aritmetico-Logica, Accesso a memoria (Load-Store), Salto.
45
Poiché stiamo considerando l’operazione di tipo aritmetico logico, l’Unità di Controllo attiverà il percorso più in basso, quello che porta all’ultimo multiplexer(10), in realtà quelle tre linee sono tutte attive però le altre due linee termineranno li e non andranno avanti. Quella che termina nel multiplexer (10) sarà propagata in uscita da quel multiplexer, l’Unità di Controllo setta il selettore di questo multiplexer in modo tale da far passare appunto il valore di ALU output, che ritorna indietro al banco dei registri e si andrà a scrivere nel registro destinazione, quello che scopriamo essere codificato in quei 5 bit che sono la terza freccia a partire dall’alto (registro di destinazione codificato nell’istruzione). Questo processo di scrittura del risultato dell’operazione in un registro si chiama WRITE BACK. Vediamo cosa succede se invece l’operazione non è logico aritmetica ma è una LOAD. Una LOAD contiene due registri e un immediato, istruzione di tipo I. Un registro per indicare dove andare a scrivere il dato letto dalla memoria e l’altro registro che invece viene sommato all’immediato per ottenere l’indirizzo della memoria. Con una LOAD ciò che avviene fino alla decodifica è lo stesso come per un operazione di tipo logico aritmetico. La situazione è questa: in ingresso all’ALU ci sono A e B che contengono il contenuto dei due registri. In realtà A contiene il registro per lo spiazzamento da sommare all’immediato mentre B non dovrebbe contenere nessun dato utile, e poi c’è l’immediato da 16 bit espanso in segno (5). Una volta decodificata l’istruzione LOAD, l’ALU non farà la somma tra A e B, ma tra A e l’immediato che darà come risultato questa volta un indirizzo di memoria. L’unità di controllo quindi setterà il selettore del secondo multiplexer in maniera tale da far passare in ingresso all’ALU Imm e non B. Dal punto di vista dell’ALU la somma in questo caso è equivalente alla somma con un istruzione di tipo ADD. Il risultato viene poi memorizzato sempre in ALU output (9), ma il percorso che verrà effettuato tra le tre diramazioni non è più quello seguito dall’operazione logico aritmetica cioè verso l’ultimo multiplexer(10) per andare in write back, ma sarà quello che va nella Memoria dei Dati (11). La Memoria dei Dati è costituita da un ingresso(monodirezionale) che contiene un indirizzo, un ingresso per la scrittura di un dato in memoria, un uscita per il dato che viene letto dalla memoria e un ingresso di controllo che dice alla memoria se l’operazione da fare è di scrittura o di lettura. Facciamo un esempio: Lettura(ingresso di controllo): viene letto l’indirizzo(ingresso indirizzo), viene preso quello che c’è in quell’indirizzo e viene messo in uscita(uscita dati); Scrittura(ingresso di controllo): viene letto l’indirizzo(ingresso indirizzo), viene preso il dato in ingresso(ingresso dati) e viene scritto in quell’indirizzo. L’Unità di Controllo avvisa, nel caso in esame(istruzione LOAD), la Memoria dei Dati che l’operazione da fare è un operazione di lettura. Dalla Memoria dei Dati quindi uscirà il dato che viene memorizzato adesso in un latch LMD(Load Memory Data) (12). Nella gran parte dei processori esiste anche MDR(Memory Data Register) che lavora per le operazioni di tipo STORE. Nel processore in esame invece esiste solo LMD che lavora per le LOAD, perché le STORE vedremo più avanti vengono gestite diversamente.
46
Ora questo dato, memorizzato in LMD, deve essere scritto nel registro(registro di destinazione specificato nella istruzione). LMD subisce anch’essa una write back: il mux (10) farà passare in retroazione LMD invece che ALU output (l’Unità di controllo setterà il selettore di questo mux in maniera tale da far passare questa volta LMD). Quello che sta in ALU output conterrà sempre qualcosa, quando viene scritto qualcosa in un latch questo ci resta sempre fino a quando non viene scritto qualcos’altro sopra. Per cui quel mux(10) manderà LMD nel banco dei registri e memorizzo questo nel registro destinazione (WB). Vediamo cosa succede nel caso di una istruzione di tipo STORE. Ciò che avviene in una STORE è abbastanza simile alla LOAD, cambia solo l’uso della memoria: questa volta la memoria viene utilizzata per scriverci dentro. Nell’operazione STORE come la LOAD ci sono due registri e un immediato. Ora il campo A contiene il registro da sommare all’immediato per ottenere l’indirizzo di memoria in cui scrivere un dato. B questa volta indica il registro da scrivere in memoria, contiene il dato da scrivere in memoria. Infine c’è naturalmente l’immediato da 16bit. Terminata la decodifica dell’istruzione e quindi una volta capito che l’istruzione da fare è una STORE, serve calcolare l’indirizzo tra A e l’immediato. L’unità di controllo quindi farà passare dal primo mux A e dal secondo Imm invece che B. L’ALU effettua la somma e salva il risultato in ALU output. L’ALU effettua la somma come una semplice operazione di somma, non sa che il risultato questa volta è un indirizzo; in realtà l’ALU non sa neanche che in ingresso questa volta c’è un registro e un immediato, questa fa la semplice somma degli operandi al suo ingresso che gli arrivano. Questo risultato ora è un indirizzo che l’unità di controllo invia in ingresso alla memoria dati per effettuare l’operazione di scrittura in quell’indirizzo. L’unità di controllo avvisa inoltre la memoria che l’operazione da fare ora è una operazione di STORE, quindi di scrittura in memoria, e quindi scrive l’ingresso dato che corrisponde a B nell’indirizzo inviato. Il dato prelevato da B viene mandato direttamente in ingresso alla memoria dati (13). In LMD questa volta c’è scritto il valore precedente, il vecchio valore(non viene cancellato niente, quello che c’era prima c’è anche adesso). L’operazione di STORE finisce qui, nella scrittura del dato nella memoria dati. Ricordiamo che in tutte queste istruzioni avviene anche l’incremento del Program Counter: prendere il PC sommarli 4 e scrivere questo risultato nel PC. Normalmente questa operazione può essere effettuata dall’ALU, sommare 4 al PC: dopo aver mandato il PC in ingresso alla memoria istruzioni, si invia al primo ingresso dell’ALU mentre nel secondo ingresso dell’ALU si mette 4 , e avvisata l’ALU di effettuare una somma, scrivere poi questo risultato nel PC. Questa procedura va bene se le istruzioni vengono eseguite una alla volta, però poiché come abbiamo già detto vengono eseguite più istruzioni contemporaneamente, con questo metodo vengono a crearsi nuovamente conflitti strutturali, come nel caso della memoria. Se si utilizza l’ALU per fare incrementare il PC e si usa ancora l’ALU per calcolare un istruzione di somma (ADD) per esempio, si crea nuovamente un conflitto. Come precedentemente abbiamo separato la memoria in due aree di memoria(istruzioni e dati) così ora dovremmo replicare l’ALU! Ma replicare l’ALU per incrementare il PC non ha molto senso, poiché l’operazione da fare è solo la somma con 4, e quindi in sostanza conviene fare un sommatore dedicato (14), che è ancora più semplice del normale sommatore, poiché fa solo la somma +4. Questo sommatore ha ai suoi ingressi sempre il PC e 4.
47
Il PC quindi va in ingresso sia alla memoria delle istruzioni e sia al sommatore. Il risultato di questo sommatore viene memorizzato in un latch: NPC (New PC) (15). New PC va sia in ingresso al mux davanti all’ingresso dell’ALU (16) e sia in ingresso ad un mux (17) che ha ai suoi ingressi il dato preso da ALU output(una delle tre diramazioni viste precedentemente) e appunto NPC. L’uscita di questo mux va a terminare nel PC che viene quindi aggiornato. Se l’istruzione non è di salto, cioè è un istruzione di tipo aritmetico logico oppure LOAD o STORE, mentre vengono eseguiti i procedimenti descritti prima, si esegue anche la somma di PC +4 in NPC, il quale va in ingresso al mux (17). L’unità di controllo setterà questo mux in maniera tale che all’uscita presenti NPC che in andrà poi in PC (1). Cosa succede se l’istruzione è di tipo JUMP, che consiste ora nel prendere l’immediato da 26 bit questa volta, e di sommarlo al PC+4 per ottenere così il nuovo PC. Questa rappresentazione della gestione dell’immediato da 26 bit non è fedele, nel seguito vedremo come avviene realmente quest’operazione, perché sarà un operazione da fare nel minor tempo possibile. Con la rappresentazione descritta conosceremo l’indirizzo dove saltare solo dopo un po’ di tempo, questo crea problemi poiché che istruzioni vengono eseguite in simultanea. Per adesso limitiamoci quindi a schematizzare e a gestire in questo modo questo tipo di operazione con immediati da 26bit(il modulo per l’estensione in segno dell’immediato da 26bit è stato aggiunto dagli autori di questo manuale con il consiglio del professore Francesco Marino). Con questa rappresentazione quindi durante la fase di fetch della JUMP, viene calcolato PC+4 in NPC. Terminata la decodifica dell’istruzione (JUMP) l’unità di controllo dal primo mux in alto farà passare in ingresso all’ALU NPC (16) che ricordiamo vale PC+4, mentre dal secondo mux farà passare l’immediato da 26bit espanso in segno (6). Il salto avviene rispetto a PC+4, come abbiamo già detto.In uscita all’ALU viene calcolato un indirizzo di salto(non un indirizzo di memoria). Delle tre diramazioni che partono dall’ALU output l’unità di controllo abiliterà solo quella verso l’alto, verso il mux (17), mentre dirà alla memoria dei dati tu dormi! E al mux (10) tu fottitene(in americano però!). Il mux(17) questa volta non farà passare più NPC, ma ALU output che si andrà a scrivere in PC: è stato effettuato il salto! Vediamo cosa succede con operazioni di tipo BRANCH(salti condizionati). Il meccanismo è simile al JUMP, presenta solo alcune diversità. L’indirizzo dove saltare viene calcolato sommando al PC+4 un immediato che è di 16bit (5) e non da 26. Inoltre il salto viene effettuato solo se viene verificata la condizione. Questa condizione è un test rispetto a zero di un registro specificato nell’istruzione stessa (BEQZ per esempio). Dei dati A, B(che non verrà utilizzato) e l’immediato posti in ingresso, A rappresenta il dato su cui andare a fare il test, mentre l’immediato è il valore che andrà sommato a NPC. Mentre nella JUMP A non veniva considerato(passava NPC in ingresso all’ALU), ora A viene inviato nel modulo di test Zero (18), che effettua un test di A rispetto a zero. Il risultato del test andrà ad impostare il registro Cond (19) che conterrà quindi il valore Vero(saltare) o Falso(non saltare), a seconda se 48
è stata verificata o meno la condizione. Questo registro andrà a pilotare, a controllare il mux (17). Nel frattempo, durante questo test l’ALU starà facendo l’operazione di somma tra NPC(passato dal primo mux davanti all’ALU) e l’immediato(passato dal secondo mux) e il risultato sarà salvato in ALU output che verrà inviato sempre al mux (17).
Quindi il mux (17) gestito da Cond farà passare ALU output(che contiene l’indirizzo di salto calcolato dall’ ALU) se la condizione è stata verificata e quindi bisogna saltare, se invece la condizione non si è verificata e quindi non si deve saltare quel Cond farà passare dal mux (17) NPC. Come vediamo questi valori che passano dal mux(17) vanno ad aggiornare appunto il PC.
Cosa succede nel quando si cerca di fare un branch confrontando dei registri? Non confrontando il registro con 0. Bisognerebbe usare l'ALU sia per fare la differenza tra i registri e sia per calcolare l'indirizzo a cui saltare. Questo tipo di confronto non può essere fatto con il modello che descritto precedentemente, non si possono far fare due cose contemporaneamente all'ALU. Quindi bisogna aggiungere un altro modulo che si chiama “Comparatore”, e integriamo questo modulo con lo schema che abbiamo studiato precedente.
Prelevati A e B, vengono messi in ingresso al comparatore e l'output di questo può essere 0 se i due ingressi sono differenti, 1 se i due ingressi sono equivalenti.
49
Questo output lo si può far entrare in un altro modulo che in caso di BNE salta se i due registri sono diversi, quindi scrivendo 1 nel latch cond. altrimenti se invece l’istruzione è una BEQ, fa l'esatto contrario mette 0 nel latch se dal comparatore è uscito 1. L'unità di controllo se è una BNE imposterà il selettore del MUX al valore 1, altrimenti 0; in quest'ultimo caso, bisogna saltare quando i due registri sono uguali, dal comparatore è uscito 1, quindi eseguiamo il salto. Un comparatore che dica 0 se sono diversi e 1 se sono uguali si configura come una XOR negata in uscita. Però uno XOR confronta una coppia di bit,e siccome i registri sono di 64-bit bisognerebbe mettere 64bit per ogni ingresso e poi avere un'uscita che è di un solo bit. Ovviamente, l'uscita è 1, solo se tutte le coppie di bit sono uguali, se anche solo una delle coppie è diversa produce uno 0 in output, l'output sarà 0, perchè vorrebbe dire che i registri sono diversi.
3. Meccanismo Di Funzionamento Interno Dell'ALU: Per fare lo schema di una ALU bisogna preoccuparsi di capire cosa va inserito in questo circuito. L'ALU riceve in ingresso una coppia di dati e il processore deve essere pronto a fare tutto quello che è previsto nel set istruzioni della macchina. Il modo più semplice per far operare il processore potrebbe essere quello di far fare a tutti i moduli nell'ALU quello per i quali sono stati creati. In pratica i due ingressi vengono inviati a tutti i moduli dell'ALU(in parallelo). Se i moduli sono molto diversi, possiamo pensare di integrarli fisicamente in maniera diversa. Se due operazioni sono abbastanza simili possiamo integrarli in un unico modulo(per risparmiare dell'hardware). A noi però di tutti i risultati dei moduli presenti nella ALU ne serve solo uno, infatti la ALU ha una sola uscita, tutte le uscite quindi, vanno in ingresso ad un'unità che decide quale di queste uscite costituirà l'output dell'ALU. L'informazione che permette di scegliere quale uscita mettere in output all'ALU è contenuta nel Codice Operativo dell’istruzione. Ma il codice operativo non controlla direttamente il mux presente nella ALU (Mapping diretto: a 0 corrisponde la somma a 1 la moltiplicazione, a 2 la divisone ecc.) ,non esiste una corrispondenza di uno a uno tra codice operativo e i moduli dell’ALU che effettuano le operazioni, questo perché la ALU non fa solo l'operazione di somma tra 2 registri solo quando l’istruzione è una ADD, ma anche nel caso di un BRANCH, somma di l’immediato a un registro. Ad esempio la “somma” va associata ad una serie di codici operativi. Quindi va preso il codice operativo, inserito in una “rete di codifica” la quale riceverà gli n bit del codice operativo(di solito 6) e alla sua uscita 50
avrà un numero di bit. Questo numero di bit dipende dai moduli presenti nella ALU, ad esempio se ci sono 4 moduli nella ALU avremo bisogno di 2 bit. Quindi, si hanno una serie di ingressi(ad esempio a 64 bit) e un'uscita con altrettanti bit, quindi ci sono log2N fili che codificano un numero associato a questi ingressi, questo numero non indica altro che l'ingresso che bisogna considerare(N dipende sempre dal numero di moduli predisposti nell'ALU). Se per esempio il sommatore è l'ingresso zero, la rete sarà una rete combinatoria che per tutti i codici operativi che richiedono la somma deve produrre in uscita zero. (Questa rete rappresenta un Decoder, richiami di elettronica digitale con riferimento al decoder a 7 segmenti). A livello teorico, il codice operativo, dice al MUX quale uscita prendere. In questo modo però ci sarebbe una corrispondenza 1 a 1, in realtà abbiamo spiegato che va inserito un modulo di decodifica. Qual è il problema di un'ALU che lavora in questo modo? Innanzitutto che il tempo in cui l'ALU produrrà il risultato è dato dal “Tempo del cammino critico”. Il cammino critico, ripetiamo, è costituito dal massimo tempo di latenza dei moduli in parallelo sommato a tutti i moduli in serie, in più va anche aggiunto il tempo della rete di controllo. Ricorda che un MUX ha una latenza proporzionale agli ingressi. Sia per risparmiare le batterie di un portatile, che per evitare problemi di riscaldamento, si potrebbe intervenire con un modulo particolare, che serve ad abilitare o disabilitare i vari moduli(evitando sprechi di energia). Questi moduli prendono il nome di Moduli Enable che servono per attivare di volta in volta solo i moduli che servono, ad esempio se in uscita all'enable c'è 1, allora il modulo lavora, altrimenti sta fermo. In questo modo cambia anche la rete di decodifica, perchè non serve più il log2N. Ma ci saranno 64 linee di uscita, delle quali solo una sarà attiva. In questo modo, il controllo è più complicato, ma ci permette di risparmiare energia. Questo ci permette di eliminare un multiplexer, passando ad un sistema di linee “tristate”. O meglio, le linee sono tutte collegate in uscita tra di loro, e vengono messe ad alta impedenza le linee che non servono. Così facendo solo la linea che ha avuto l'enable è collegta fisicamente all'uscita. In pratica, un sistema tri-state prevede più linee di uscita(64 in questo caso) tutte collegate tra di loro; sfruttando l'enable è possibile mettere tutte le linee con l'enable impostato su 0, ad alta impedenza. Ovviamente solo una linea di quelle 64 rimarrà a bassa impedenza e produrrà l’uscita.
51
52
CAPITOLO 7 LA PIPELINE
1. Introduzione alla Pipeline Nel capitolo precedenti abbiamo diviso in 5 stadi la struttura del processore:
1) Reperimento dell’istruzione (IF Instruction Fetch) 2) Decodifica dell’istruzione/ Reperimento dei registri (ID Instruction decode) 3) Esecuzione dell’istruzione/Calcolo dell’indirizzo (EX Execution) 4) Accesso alla memoria (MEM) 5) Scrittura (WB Write-back) Possiamo a questo punto introdurre l'idea di Pipeline, che consiste nel strutturare il processore come un “tubo”. Quando viene eseguita una istruzione non ci si deve preoccupare solo del tempo impiego per quell'istruzione, ma interessa che il processore esegua un intero programma in un tempo minore e che questa cosa valga per molti programmi. In pratica ci interessa il tempo che intercorre tra l'esecuzione della prima istruzione e l'esecuzione dell'ultima. Normalmente si è pensato alla realizzazione di un sistema che prevede il processore impegnato da un'istruzione e quando ha finito di eseguire quell'istruzione, il processore è libero di eseguirne un'altra . Quindi lo si può immaginare come un tubo, dove appena terminata l’esecuzione della prima istruzione, questa si sposterà facendone entrare un'altra. Nell'architettura pipeline se una data istruzione ha liberato determinate risorse, possiamo già iniziare a caricare un'altra istruzione e farle usare quel “pezzo” di processore che si è appena liberato e nel frattempo l'esecuzione della prima istruzione sta continuando. Perché questo funzioni, occorre che non ci siano conflitti strutturali già affrontati nel capitolo precedente. Quindi in questo momento quello che interessa è solo che, quando la seconda istruzione entra nel processore, le risorse a cui deve accedere non servino più alla prima istruzione; se questo non accade si incorre in conflitti strutturali. La pipeline è una trasposizione della catena di montaggio di Henry Ford.
Quindi, prelevata l'istruzione dalla memoria e messa nell'IR e incrementato il PCsi può già iniziare a caricare un'altra istruzione con il PC aggiornato. Allora, supponendo di avere due istruzioni, istruzione 0 e istruzione 1; una volta caricata l'istruzione 0, si può già caricare l'istruzione 1 perchè l'istruzione 0 ha “liberato” la
53
risorsa PC, che serve appunto per caricare l'istruzione. Questo serve per diminuire i tempi di esecuzione, perché di volta in volta non c’è bisogno di arrivare al termine dell'esecuzione di ogni istruzione per iniziare ad eseguirne un'altra. Ora cerchiamo di capire in quale istante termina il programma. La valutazione per ora è approssimativa e consideriamo delle semplificazioni di ciò che accade nel processore. Ci poniamo una domanda: Il tempo finale di una architettura pipeline quanto vale rispetto al tempo finale ottenuto da un'architettura non-pipeline? Supponiamo di avere N istruzioni e per semplicità supponiamo che abbiano tutte la stessa durata, in realtà non è esattamente così perché alcune istruzioni hanno durata diversa. Supponiamo che la durata di ogni istruzione sia t, allora il tempo finale sarebbe T = N * t. Anche se questo non è totalmente vero, perché anche se le istruzioni possano avere tutte la stessa durata, non necessariamente il processore impiega lo stesso tempo ad eseguire le istruzione, perchè ad esempio un'istruzione potrebbe già essere nella cache, un'altra istruzione che non è nella cache impiegherà più tempo ad essere eseguita poiché bisogna aspettare che il processore carichi quell'istruzione. Ora, consideriamo il caso in cui non ci siano stalli di memoria: momenti in cui il processore si ferma perchè non può reperire dalla la memoria un istruzione o un dato, oppure non si può scrivere in memoria perchè nella cache non c'è la pagina in cui scrivere. Nel processore in genere si tende a far durare tutte le fasi dell'istruzione nello stesso modo, perchè altrimenti il sistema non viene velocizzato gran che, ad esempio se la fase di fetch dura 1 sec e la fase successiva ne dura 10, la seconda istruzione verrà caricata dopo un secondo, ma non potrà passare alla seconda fase poiché le risorse di questa sono ancora impegnate dalla prima istruzione che non ha terminato, ha bisogno di 10 sec. Il vantaggio di questo adattamento delle fasi è apprezzabile nel caso dell'esecuzione di molte istruzioni; l'adattamento in genere si fa in base alla fase più lenta, perché è quella che tiene le risorse impegnate per più tempo. In questo modo, aumenta il tempo di esecuzione della singola istruzione, ma diminuisce il tempo di esecuzione di molte istruzioni in ottica Pipeline. Come abbiamo già detto, consideriamo N istruzioni e che la durata di una singola istruzione sia t. Il periodo complessivo, il tempo di calcolo per un processore non pipeline è pari
54
L’esecuzione di una istruzione è suddivisa, come abbiamo visto, in più fasi, precisamente sono necessarie 5 fasi. Nella figura per comodità sono state rappresentate solo 2 fasi. Ipotizzando che la durata di tutte le fasi sia uguale, ogni fase dura
.
Dove nf è il numero delle fasi, nel nostro caso 5; e t è la durata di una istruzione. In questo caso in cui tutte le fasi hanno uguale durata, si parla di Pipeline Bilanciata. Se invece le fasi non hanno tutte la stessa durata, pipeline non bilanciata, bisogna far durare ogni fase quanto la fase più lenta. La singola istruzione nell’ipotesi in cui non è bilanciata, ed è costituita da 2 fasi, per comodità, con durata pari rispettivamente a 1 e 10 durerebbe 20. La durata della fase più veloce viene portata alla latenza della fase più lenta.
La singola istruzione ha aumentato la sua durata rispetto all’architettura non pipeline ma, considerando tutte le altre istruzioni in pipeline la durata complessiva è migliorata.
55
Facciamo l’esempio di una pipeline non bilanciata,sempre con 2 fasi di durata 3 e 7. Bisogna portare la fase con durata minore ad una latenza pari a quella della fase più lenta, in questo caso la fase da 3 va portata ad una latenza pari a 7. La pedata è come vediamo è pari a 7.
La durata complessiva sarà quindi pari alla somma delle pedate, che sono N, moltiplicata per la durata di ogni pedata, che vale 7. Inoltre bisogna sommare la prima fase della prima istruzione che non è conteggiata nelle pedate. In conclusione la durata dell’esecuzione è pari:
Oppure non contando la prima istruzione nel calcolo delle pedate, adesso N-1, e aggiungendo infine la durata di tutta la prima istruzione, che vale 14:
Il tempo necessario invece per una architettura non pipeline è pari, come abbiamo detto, a:
Dove 10 è pari alla durata dell’istruzione non pipeline, cioè quella costituita dalle 2 fasi non bilanciate di durata rispettivamente di 3 e di 7. t è la durata dell’istruzione che è data dalla somma quindi delle due fasi 3+7. T è la durata dell’esecuzione, N è il numero delle istruzione da eseguire. Quindi in generale la durata dell’esecuzione in pipeline è pari a:
Dove con t’ abbiamo indicato il periodo di una fase, quella più lenta per una pipeline non bilanciata, che corrispondete alla pedata nella figura. Il termine t indica la durata di tutta l’istruzione. Bisogna però aggiungere al tempo di ogni fase una extradurata necessaria nella pipeline, dovute al fatto che quando si esegue una istruzione in pipeline bisogna inserire tra uno stadio e l’altro delle unità di memorizzazione(rettangoli stretti e grigi), che verranno analizzati nel prossimo paragrafo, che servono a contenere dei latch. Queste unità di memorizzazioni introducono una latenza dovuta alle operazioni di lettura( ) e scrittura( ) su questi. Quindi la durata complessiva di una singola fase sarà pari a:
Dove tmax è il tempo della fase con la latenza maggiore.
56
2. Architettura Pipeline Nella tecnologia pipeline(il cui termine vuol dire canale, condotto, tubatura) il processore viene visto come un tubo, nel quale le istruzioni entrano non necessariamente attendendo che l’istruzione precedente sia uscita. Normalmente si è portati a pensare che solo al termine dell’esecuzione di una istruzione venga eseguita la successiva. Nella tecnologia pipeline una istruzione viene immessa nel ciclo di esecuzione quando ancora non è terminata l’istruzione precedente. Questo grafico evidenzia come le istruzioni impegnano il processore durante le varie fasi scandite ad ogni colpo di clock.
La prima istruzione in alto rappresenta l’ultima istruzione entrata, mentre l’istruzione più in basso rappresenta l’istruzione che sta per uscire, la prima tra queste ad essere entrata. Al CC1 (colpo di clock 1) entra una generica istruzione; al CC2 la prima avanza, liberando delle risorse, ed entra una seconda istruzione e così via nei colpi di clock successivi. Al CC5 il tubo è pieno; l’istruzione 5 è appena entrata nel processore, l’istruzione 4 è nella fase successiva così come tutte le altre istruzioni fino all’istruzione 1 che si trova all’ultimo stage e dopo terminerà.
57
A un certo colpo di clock entra nel processore l’istruzione 5, l’istruzione 1 passa all’ultima fase, l’istruzione 2 alla penultima fase e così via. Al colpo di clock successivo l’istruzione 1 uscirà, l’istruzione 2 passa all’ultima fase e così le altre, e un un'altra istruzione si appresterà a entrare nel processore. Affinché questo tubo possa funzionare non possiamo attenerci all’architettura del processore vista nel capitolo 6, ma dobbiamo utilizzare un architettura di processore che prevede la tecnica pipeline. L’architettura del processore analizzato nel capitolo 6, non si atteneva adeguatamente alle esigenze richieste dalla tecnica pipeline. Il processore era stato giustamente suddiviso in fasi, e in ciascuna fase venivano utilizzate risorse che nelle fasi successive non erano usate. Ad esempio analizziamo l’incremento del PC. Questa operazione poteva benissimo essere svolta dall’ALU, ma come abbiamo visto è stato introdotto il modulo sommatore dedicato al suo incremento. Questo perché se l’incremento venisse svolto dall’ALU, durante la terza fase, la fase di Esecuzione(EX), dove si richiede l’uso dell’ALU, si andrebbe in conflitto a causa dell’operazione di incremento del PC. E’ anche per queste ragioni che si è pensato di suddividere la memoria in due aree: memoria istruzioni e memoria dati (architettura Harvard). Questa architettura quindi sembra essere predisposta a supportare una tecnologia pipeline visto che non presenta conflitti strutturali. Nonostante questo, però, questa architettura non è di tipo pipeline. Osserviamo cosa succede nell’esecuzione pipeline: quando entra un istruzione nel processore questa viene scritta nell’IR. Nel colpo di clock successivo questa istruzione procede nella fase successiva, e intanto una nuova istruzione sta entrando nel processore, andandosi a scrivere nell’IR e cancellando il valore scritto prima, quello relativo alla prima istruzione ad essere entrata. Ora non c’è più il codice operativo della prima istruzione, e nelle fasi successive non si saprà più che cosa fare! Caricata una istruzione nell’IR, ci si prepara a eseguire le prossime fasi, ma successivamente viene caricata nell’IR un'altra istruzione e si perde traccia della prima. Un morto dimenticato ..celebriamo la giornata della memoria delle istruzioni! Con questo tipo di architettura nascono problemi, come per l’IR, anche sui valori degli Immediati: il latch Imm viene sovrascritto anch’esso; ci sono problemi anche nelle Load e nelle Store poiché vengono sovrascritti i valori di LMD quando vengono eseguite due istruzioni LOAD una di seguito all’altra; anche il latch B viene sovrascritto quando vengono eseguite due STORE, questa volta, una dopo l’altra. Questa architettura presenta una serie di problemi per la gestione della pipeline. Sono necessarie zone, aree, dove conservare i dati che di volta in volta vengono prodotti dalle varie istruzioni (la giornata della memoria!). Un processore per la pipeline è molto simile a quello analizzato nel capitolo 6 ma prevede l’inserimento di moduli che contengono delle unità di memorizzazione.
58
Questa figura rappresenta il modello dell’architettura del processore pipeline.
Come vediamo in questa architettura sono stati eliminati i latch come IR,NPC, Imm, A,B, ALUOutput, LMD e sono stati inseriti dei moduli, quei rettangoli stretti e lunghi in grigio, che sono delle unità di memorizzazione. Come si nota l’IR è stato integrato nel primo modulo a sinistra, anche NPC non c’è più perché è stato integrato in questo stesso modulo. I latch A,B, Imm sono stati tutti integrati invece nell’altro modulo, il secondo a partire da sinistra. Tutti i latch sono inseriti in uno stadio intermedio fra due stadi adiacenti. Uno stadio che sostanzialmente contiene dei latch. Sarà necessario replicare questi registri, questi latch, in tutti questi quattro moduli. Ci saranno una serie di repliche che scorrono lungo il processore durante tutte le fasi di esecuzione dell’istruzione. Il processore si porterà tutti questi latch avanti per avanti in questi moduli. Ad esempio caricata una istruzione nell’IR e al prossimo colpo di clock quando verrà caricata l’istruzione successiva, non si perderà il valore precedente di IR, perché, con il meccanismo di shift register, IR passerà dal primo modulo al secondo modulo, si replicherà. Spieghiamo il meccanismo dello shift register. Un normale registro quando riceve un dato lo memorizza, e quando viene interrogato(lettura dal registro) lo restituisce, conservandolo sempre al suo interno. Quando viene letto un dato da un registro il contenuto resta sempre nel registro fin quando non viene inserito un nuovo dato. Lo shift register invece è un registro che nel momento in cui riceve un dato(aggiornamento del contenuto del registro) quello che conteneva viene propagato nella cella adiacente. Il meccanismo è simile a una coda: a ogni colpo di clock i dati nella prima cella vengono trascritti nella seconda soltanto dopo che quelli della seconda saranno trascritti nella terza e così via. Solo il dato dell’ultima cella verrà perso. Questi moduli sono gestiti proprio con un meccanismo di shift register. Quando viene inserita una nuova istruzione, l’IR del primo modulo viene prima copiato(shiftato) nell’IR del secondo modulo, e poi viene sovrascritto dalla nuova istruzione. E quindi nella fase di esecuzione, in cui si dovrà andare a fare l’operazione prevista dall’istruzione, verrà letto il codice operativo dell’istruzione che si trova nell’IR del secondo modulo.
59
2.1. La Tipica “Vita” Di Un’istruzione In Pipeline Analizziamo il percorso che compie un’istruzione. Consideriamo l’avvio, l’esecuzione di una istruzione. Durante un certo colpo di clock (parte il periodo di un colpo di clock) avviene la fase di fetch di una istruzione( PC –> memoria istruzioni –> IR). Soltanto al termine di questo colpo di clock, l’istruzione viene scritta nell’IR. Al termine del colpo di clock in IR c’è scritta l’istruzione di cui stiamo analizzando la vita. Parte il secondo colpo di clock. L’IR del primo modulo, che contiene l’istruzione prelevata prima, trasferisce il suo contenuto nell’IR del secondo modulo. L’IR del primo modulo viene copiato nell’IR del secondo modulo. Poiché questa operazione è abbastanza veloce, il contenuto di IR c’è ancora nel primo modulo, non è stato ancora sovrascritto, verrà sovrascritto solo al termine di questo colpo di clock, quando verrà letta l’altra istruzione. Durante questo colpo di clock quindi avviene la decodifica dell’istruzione e quindi la precarica degli operandi: prelevo dal banco dei registri A e B, ed estendo in segno gli Immediati. Terminato il secondo colpo di clock nel vecchio IR, quello del primo modulo, non c’è più la prima istruzione ma quella nuova, mentre la prima si trova adesso in IR del secondo modulo. Al terzo colpo di clock l’istruzione(la prima in assoluto) viene eseguita, fase di EX. Se l’istruzione deve fare per esempio la somma tra un registro e un immediato, l’Unità di Controllo legge il codice operativo dell’IR che si trova nel modulo 2 e prepara l’ALU per fare la somma tra A e l’immediato. Allora i due multiplexer davanti agli ingressi dell’ALU faranno passare ,opportunamente gestiti dall’unità di controllo, A e l’Immediato che erano memorizzati nel secondo modulo, e il risultato viene salvato in ALU output che si trova nel terzo modulo. Intanto l’istruzione successiva è giunta al secondo modulo(i nazisti sono arrivati al secondo modulo!) ma IR è stato già copiato nel terzo modulo, altrimenti non si saprebbe più cosa fare con l’ALU output. La stessa cosa avviene per A e B. B viene a copiarsi nel terzo modulo. Quindi fase dopo fase vanno replicati ogni volta i latch da un modulo al modulo successivo. Mentre nel processore non pipeline c’era un solo IR, di cui si sapeva dove era, chi era e che faceva, così come tutti gli altri latch, nel processore pipeline ci sono diversi IR, diversi B, diversi Imm, diversi LMD, perché sono stati replicati nei quattro moduli. Per non confonderci, per identificare ciascuno di questi latch, per sapere di quale IR, di quale B, di quale LMD stiamo parlando, a quale di questi ci stiamo riferendo, diamo un nome a questi utilizzando questa notazione: Nome modulo IF/ID ID/EX EX/MEM MEM/WB
Seguito dal “.” e il nome del latch, intendendo così che ci stiamo riferendo a questo latch che si trova in questo modulo. Ad esempio con IF/ID.IR intendiamo il latch IR del modulo IF/ID, cioè ci stiamo riferendo al latch IR che si trova tra lo stage di IF e lo stage di ID. Con EX/MEM.IR stiamo focalizzando l’IR che contiene l’istruzione pronta a eseguire la fase di MEM. IR si propaga nei diversi moduli quindi avremo anche ID/EX.IR e MEM/WB.IR Gli altri latch saranno: ID/EX.A, EX/MEM.B oppure MEM/WB.B e così via anche per Imm, LMD e NPC 60
3. Microcodice o Microistruzioni Vediamo come si eseguono le istruzioni in un processore pipeline passo dopo passo attraverso delle microistruzioni, o microcodice.
Questa figura rappresenta, con un linguaggio comprensibile, microcodice, quello che avviene all’interno del processore durante l’esecuzione di una qualsiasi istruzione. Sono rappresentate le cinque fasi e viene descritto ciò che avviene in ogni fase. Ci accorgiamo che nella fase EX ci sono 3 colonne che differenziano il 61
comportamento dell’istruzione a seconda del suo tipo, mentre nelle prime fasi non avviene questo, perché nella fase di IF o nella fase ID non si conosce ancora il tipo dell’istruzione, bisogna aspettare la decodifica infatti. Fino al decode quindi si fanno cose uguali per tutte le istruzioni e “speculative”(precarica degli operandi, viene fatta una cosa che può essere utile oppure no). La differenza del processore non pipeline è che oltre a svolgere il compito che quella fase prevede bisogna gestire “la fuga dal ghetto di Varsavia”: gli spostamenti delle varie informazioni che altrimenti verrebbero perse per l’arrivo di altre istruzioni nel processore(arriva la pattuglia di Nazisti!). Tutto quello che avviene in ogni fase, avviene in un colpo di clock. Il colpo di clock deve essere tale da permettere l’esecuzione di ogni fase, è dimensionato sulla latenza della fase più lenta. Dividiamo il periodo di clock in due semifasi. Nella prima semifase avviene la lettura dei registri e dei latch, mentre nella seconda semifase avviene la scrittura di questi.
Analizziamo ora ogni fase: Premettiamo che tutto quello che avviene in questa tabella avviene contemporaneamente, cambiano solo le istruzioni su cui si agisce, ogni fase agisce sempre su una istruzione diversa. Tutte queste microistruzioni (o microcodice) avvengono tutte nello stesso tempo, contemporaneamente.
3.1. IF (ISTRUCTION FETCH) Durante la fase di Istruction Fetch, come nel caso di un processore non pipeline, si legge il PC, si estrae dalla memoria istruzioni l’istruzione relativa al PC e la si memorizza in IR. Un attimo dopo aver letto il PC e averlo mandato in memoria, lo si incrementa.
62
Quindi nell’IR del banco IF/ID viene scritto il componente del vettore Mem(memoria indirizzi) puntato da PC e nel fra tempo viene aggiornato il PC, che può provenire da due possibili alternative, o è PC+4 nei casi “normali”, oppure è il valore dove saltare quando c’è una istruzione di salto jump o branch, in quest’ultimo caso(branch) PC verrà aggiornato con l’indirizzo di salto solo se la condizione di branch è stata verificata, altrimenti deve continuare a valere PC+4. Nel secondo rigo viene quindi verificato se opcode dell’IR del modulo EX/MEM è una istruzione di tipo branch, e se è vero viene verificata la condizione, cioè cond del modulo EX/MEM, e se questa è vera in PC e in NPC andrà il risultato di ALU output(indirizzo di salto), altrimenti PC+4. Non è codificato il caso di JUMP, e l’istruzione potrebbe essere modificata in questo modo: IF/ID.NPC, PC ← (if(((EX/MEM.opcode == branch) & EX/MEM.opcode == jump) {EX/MEM.ALUOutput} Else {PC+4});
EX/MEM.cond)
||
Oppure si può pensare che la branch sia anche una jump però con il latch cond settato automaticamente a 1, in modo da avere la condizione sempre vera, ed effettua sempre il salto. Notiamo che PC non è in quei moduli, è un registro esterno, per questo non è identificato con IF/ID. Ricordiamo che la lettura dei latch avviene nella prima semifase del periodo di clock, per cui il PC nella prima semifase viene letto e trasferito alla porta indirizzi della memoria. Mentre nella seconda semifase, in cui avviene la scrittura dei latch, PC riceve ciò che c’è scritto al secondo rigo. Il PC quindi instrada la memoria istruzioni però anche prima che questa memoria tiri fuori l’istruzione, PC viene aggiornato, anche se la memoria non ha ancora dato l’istruzione, ma non fa niente, PC il suo lavoro lo ha già svolto. Notiamo che oltre a sovrascrivere il PC si sta aggiornando NPC che si trova nel modulo IF/ID. A questo punto parte la decodifica dell’istruzione che terminerà alla fine della fase ID. 3.2. ID (ISTRUCTION DECODE) Nel primo rigo avviene la copia dei registri in A e B: l’elemento del banco dei registri(Regs) che interessa IF/ID:IR[rs], cioè quello indicizzato dal valore rs(che è un numero) viene copiato in ID/EX.A e così il registro IF/ID:IR[rt] viene copiato in ID/EX.B.
63
Nel secondo rigo vengono trascritti nel modulo successivo il valore di NPC e di IR: vengono copiati NPC e IR dal modulo IF/ID in NPC al modulo ID/EX. ATTENZIONE: Trascrizione moduli NPC e IR Nella prima semifase in ID: lettura IF/ID.NPC lettura IF/ID.IR Nella seconda semifase in ID: scrittura ID/EX.NPC scrittura ID/EX.IR Nella seconda semifase in IF: sovrascrittura IF/ID.NPC e sovrascrittura IF/ID.R con i nuovi valori per la nuova istruzione Nel terzo rigo(ricordiamo che queste righe avvengono tutte in simultanea!) avviene l’estensione in segno del campo immediato in ID/EX.Imm. Al termine di questa fase è terminata la decodifica dell’istruzione. 3.3. EX (EXECUTE) Decodificata l’istruzione si viene a conoscenza del tipo dell’operazione che si andrà ad eseguire e quindi sia in questa e sia nelle prossime fasi ci sarà una distinzione delle microistruzione a seconda del tipo di istruzione che si sta per eseguire, che come abbiamo detto precedentemente si distinguono in istruzioni della ALU, istruzioni load o store e istruzioni di diramazione(salto). Durante queste istruzioni avviene comunque la trascrizione dell’IR dal modulo ID/EX al modulo EX/MEM.
-
-
Istruzione della ALU: ci sono due modi differenti di operare a seconda del tipo di operandi; o Due operandi registri : in ALUOutput di EX/MEM viene memorizzato l risultato dell’operazione(somma, prodotto ecc.) tra ID/EX.A e ID/EX.B , quindi un operazione tra i due registri o Un operando registro e un operando immediato: in ALUOutput ci sarà il risultato dell’operazione tra EX/MEM.A e l’immediato ID/EX.Imm. Istruzione load o store: Nelle load e le store l’ALU viene utilizzato per calcolare l’indirizzo di memoria, e il calcolo avviene tra un indirizzo e un immediato. In ALUOutput quindi va sempre la somma tra A e l’immediato. Oltre a questo trasferisco IR e il latch B dal modulo ID/EX al modulo EX/MEM. In realtà il trasferimento di B potrebbe non farsi se si sta facendo una Load, ma gestire
64
-
questa cosa cioè se è Store trasferisci B e se è una Load fregatene è una cosa che complica la vita, si porta avanti B comunque piuttosto che portarlo solo se serve. Istruzione di diramazione: Quando c’è un salto l’ALU viene utilizzata per calcolare l’indirizzo dove saltare. Nella fase IF nel PC eventualmente invece di inserire PC +4, si va a mettere ALUOutput che si trova in EX/MEM il cui valore si ottiene dalla somma tra NPC che si trova in ID/EX.NPC (che contiene PC+4) e l’immediato ID/EX.Imm però shiftato a sinistra di due posti poiché nei salti non si codificano i due zeri meno significativi dovuto al fatto che l’indirizzo è multiplo di quattro. L’Immediato esteso in segno diventa l’addendo a NPC. Nell’ultimo rigo op indica i vari test su cui è condizionato il test(==, !=) . Non è presente il confronto tra i registri. L’istruzione di salto termina nel momento in cui viene aggiornato il PC. Non c’è la fase di MEM o di WB. In questo caso l’IR non c’è bisogno di trascriverlo visto che il ciclo di vita di questa istruzione termina qui..
3.4. MEM
-
-
Istruzione della ALU: Il risultato calcolato nella fase precedente, memorizzato in ALUOutput di EX/MEM, deve essere trasferito sempre in ALUOutput però del modulo MEM/WB. Analogamente si trasferisce IR da EX/MEM a MEM/WB, altrimenti viene perso il registro e si deve salvare il risultato che è codificato nell’istruzione, non si può ancora perdere IR. In questo caso la fase MEM consiste nella trascrizione dei dati: il risultato e l’IR. Istruzione load o store: C’è una distinzione tra Load e Store, viene comunque propagato IR in entrambi i casi: o Load: c’è un accesso a memoria in lettura, e viene memorizzato il dato che si trova in memoria all’indirizzo di ALUOutput in LMD. o Store: accesso alla memoria in scrittura. Viene scritto il valore B nella memoria indirizzata da ALUOutput.
3.5. WB
65
-
-
Istruzione della ALU: Viene scritto il risultato di ALUOutput nel registro, nel banco dei registri indicizzato dai bit che sono nel campo rt dell’IR che è stato trascritto prima in MEM/WB. Il risultato viene scritto nel banco dei registri. Istruzione Load o Store: Se l’istruzione è una store, come abbiamo visto l’istruzione termina alla scrittura in memoria. Quindi la fase di WB è eseguita soltanto per una istruzione Load. Il dato appena letto dalla memoria, che si trova in MEM/WB.LMD e viene inserito nel banco dei registri, nel registro indicizzato dai bit del campo rt dell’IR che si trova in MEM/WB che opportunamente è stato trascritto nella fase precedente.
66
CAPITOLO 8
PRESTAZIONI PROCESSORE PIPELINE Nei precedenti capitoli abbiamo detto che se un istruzione consta di due fasi di cui la prima , ad esempio, dura 1 e la seconda dura 9, bisogna portare la fase più breve ad avere la latenza della fase più lunga, in questo caso la fase che dura 1 deve durare 9. Invece di pensare a una situazione in cui lo stadio più veloce viene dilatato, si cerca di far durare la fase con la latenza più lenta quanto quella più veloce, cioè far durare 1 la fase con latenza 9. Questo è possibile attraverso due tecniche: 1) Parallelismo: aumentare le risorse di una fase per accelerare le prestazioni. Se bisogna far durare 1 una fase che dura 9, mettendo 9 risorse in più in quella fase, maniera tale che se 1 risorsa esegue l’operazione in una durata pari a 9, nove risorse lavoreranno in 1. 2) Super pipeline: separare la fase più lenta in sottofasi che durino ognuno quanto la più veloce. Suddividere la fase da 9 in sottofasi da 1. Queste due soluzioni sono entrambi soluzioni che vanno considerate. Analizziamole entrambe partendo dalla seconda. 1. Miglioramento prestazioni: super pipeline Suddividiamo la fase più lunga, che chiamiamo per semplicità B e invece quella più breve A, in sottofasi, magari 9 sottofasi da un colpo di clock. Una data istruzione che ha terminato la fase A potrà procedere nella prima sottofase di B perché l’istruzione precedente a questa avrà già terminato la prima sottofase di B. Questo è il modo più semplice di risolvere il problema, però nell’ipotesi che effettivamente questa fase possa essere suddivisa in 9 sottofasi. Attenzione però che per migliorare le cose non è necessario che la fase B sia divisibile in 9 sottofasi, potrebbe anche essere una cosa positiva, se questa fase può essere suddivisa in 3 sottofasi. In tal caso se le tre sottofasi fossero bilanciate, noi avremmo una situazione in cui invece di avere una latenza ogni 9 colpi di clock adesso diventerebbe ogni tre colpi di clock, con un accelerazione del 300%. Però perché è fattibile se quella fase è divisibile in delle sottofasi. In queste ipotesi, in cui la fase B è divisibile in 9 fasi equilibrate, un istruzione che in una pipeline è divisa in due stadi diventa un istruzione strutturata su una pipeline da 10 stadi: super pipeline. Non è detto comunque che individuare in questa fase B 9 sottomoduli casualmente tutti di durata bilanciata pari a 1 e strutturare una pipeline del genere che si vadano a migliorare le cose. Bisogna considerare che aumentando le fasi si vanno ad aggiungere dei latch che hanno una durata di lettura e scrittura. Per cui queste sottofasi non saranno più 9 sottofasi di durata 1, ma 9 sottofasi di durata 1 più il tempo di lettura e scrittura del latch iniziale e di quello finale. Quindi affinché la durata di una sottofase valga 1, bisogna pensare di dividere la fase B in 10 fasi, invece che 9, di durata 0.9 che considerando la lettura e scrittura dei latch si arrivi a una durata totale per sottofase pari a 1. Fatta questa precisazione e supposto che alla fine anche contando i latch si riesce a stare in un tempo 1; qual è l’ulteriore problema si viene a generare in questa situazione? Il problema dovuto al conflitto dei dati. I conflitti di dato sono dei conflitti che sopraggiungono quando un’istruzione necessita di usare un dato, che è il risultato di una certa operazione appena precedente e che quindi non è disponibile nel registro perché
67
questa non lo ha ancora calcolato. Analizzeremo le diverse casistiche. Per cui alla fine aver attraversato questo throughput (tempo di attraversamento del processore con ritmi di ogni istruzione per colpi di clock) porta poco giovamento perché comunque si dovrà aspettare che l’istruzione precedente calcoli il risultato. La complicazione effettiva che si paga in un sistema di questo genere è data dall’unità di controllo. Nelle lezioni precedenti, quando abbiamo introdotto la Pipeline, abbiamo detto che l’unità di controllo supervisiona simultaneamente l’esecuzione delle 5 istruzioni che sono nei 5 pezzi di quel processore. E’ evidente che un’istruzione strutturata in una pipeline di 10 fasi costerà un’unità di controllo più complessa di una che lavora su una pipeline di 5 fasi. Visto che quante sono queste fasi sono anche le istruzioni presenti simultaneamente nel processore, l’unità di controllo deve gestire molte più istruzioni in questo caso. Quindi con una pipeline di 10 stadi significa che, siccome in ogni stadio c’è un istruzione, in un determinato momento il processore sta eseguendo 10 istruzioni. Quindi l’unità di controllo è più complessa visto che deve gestire 10 istruzioni contemporaneamente. Inoltre è molto più semplice trovare le dipendenze fra le istruzioni in una pipeline da 2 fasi, quindi due istruzione, piuttosto che in una da 10 In conclusione se la fase con la latenza è suddivisibile in più sottofasi, si crea una super pipeline che non è sempre una cosa intelligente. Ci sono casi in cui non è possibile farlo e altri in cui si può, però bisogna valutare se questa soluzione convenga. 2. Miglioramento prestazioni: parallelismo Analizziamo ora la prima soluzione. Mettendoci nella stessa situazione vista prima, in cui c’è una pipeline con due fasi di durata 1, fase A, e 9, fase B. Supponendo che nella fase B ci sia un modulo che per compiere un certo lavoro necessità di 9 unità di tempo, mettendo 9 moduli si riesce a far durare la fase 1 unità di tempo. In realtà questa cosa va interpretata meglio!! “ Se un operaio impiega 9 ore per compiere un certo lavoro, 9 operai impiegherebbero 1 ora” L’esempio degli operai in questo caso non è esattamente calzante. Dipende se le operazioni della fase B sono parallelizzabili o meno. Consideriamo il caso dell’avvitamento di una vite:
Per avvitare la vite si necessita di un certo tempo, cioè il tempo in cui essa entri ed effettui il percorso per arrivare alla fine. Quanto tempo si impiega per avvitare questa vite? Mancano dei dati. Serve conoscere il passo della filettatura, quanti giri al minuto si effettuano con l’avvitatore. Supposto: 1 giro/minuto; il passo della filettatura è 1 cm e bisogna percorrere 10 cm, allora è evidente che servono 10 minuti per avvitare tale vite. Ora mettendo 10 operai di certo non si risparmia tempo! Diverso è se bisogna avvitare 10 viti: operazione parallelizzabile. Non sempre quindi il processo è parallelizzabile. Però si può sfruttare un meccanismo in cui duplicando la risorsa, non si abbatte il tempo della singola fase, ma si abbatte il tempo di ingresso di una nuova istruzione nella pipeline, il tempo in cui nella pipeline si può inserire un nuovo Task. Anche se il processo non è parallelizzabile si possono utilizzare, ad esempio, 9 ALU che compiono la fase B , ciascuna di queste fa quello che deve fare in 9 unità di tempo. Escludo che questi 9 lavori tutti sulla stessa istruzione, perché il task non è detto che sia affrontabile in parallelo. Se è affrontabile in parallelo allora si può pure pensare a un caso come detto prima in cui c’erano 9 viti da 68
avvitare, utilizzando 9 operai, ciascuno avvitava una vite, e si va al ritmo 1 piuttosto che 9. Poiché questo non è sempre possibile, perché l’operazione da fare non è sempre parallelizzabile, si replica questa risorsa e, l’istruzione, terminata la fase A, entra allo stadio B. Ora questa istruzione impiegherà, utilizzerà, una delle 9 ALU messe a disposizione per 9 unità di tempo(quanto la durata sempre della fase B). Al prossimo colpo di clock, un’altra istruzione entrata nel processore, terminata la fase A, si accinge a entrare nella fase ; quest’ultima istruzione verrà destinata a un'altra ALU a disposizione, di certo non all’ALU di prima visto che è occupata ancora dall’istruzione appena precedente. Questo avviene per i nove colpi di clock successivi. Al 10° colpo di clock, in cui un’altra istruzione si accinge ad entrare nella fase B, le ALU sembrerebbero tutte occupate, ma la prima istruzione in assoluto che abbiamo discusso, ha avuto nove colpi di clock per fare il suo lavoro e quindi uscirà dal processore liberando l’ALU, pronta ad essere nuovamente occupata dall’istruzione di questo colpo di clock. In sostanza il processore lavora con un ritmo di un istruzione per colpo di clock, la fase B con 9 moduli che lavorano con un ritmo di 9 colpi ciascuno. Al termine di questi 9 colpi di clock, la prossima istruzione può andare di nuovo all’inizio. In sostanza, si è gestito in parallelo un processo più lento in maniera tale da avere un throughput trasparente per lo stadio precedente, perché lo stadio precedente non se ne è accorto che la fase B ci mette 9 unità di tempo a compiere il suo lavoro perché per lui ce ne mette uno. Il processore lavora con un ritmo di un colpo di clock alla volta. Il costo di questa soluzione è, intanto, di aver messo 9 di questi oggetti (9 ALU) e poi gestire un meccanismo di instradamento a una corsia piuttosto che a un'altra. Questo avviene anche al casello autostradale: se mediamente arriva una macchina al minuto, se io gestisco l’operazione del casello in 10 minuti, mettendo 10 caselli non c’è coda se chi arriva è sufficientemente intelligente di capire qual è il casello che si è liberato. Per realizzare questo a livello di hardware si utilizzare un dispositivo che funziona con la logica contraria del Mux, si chiama DeMux (de multiplexer).
Il Mux riceve tanti ingressi e poi decide in base al selettore quale tra questi deve andare in uscita. Il DeMux (Demultiplexer) invece ha un ingresso e tante uscite e decide su quale uscita pilotare questo ingresso. Come il Mux ha un controller, il DeMux ha un contatore (contatore modulo N). Un contatore è un oggetto che ha un ingresso e un uscita che in sostanza è un numero. Il contatore controlla su questo ingresso se arrivano impulsi, e ogni volta che ne rileva uno scatta. Si da un numero massimo, e arrivato a quel numero si riparte da 0. Quindi per esempio modulo 8 significa che invece di scrivere 8 mi scrive di nuovo 0. Conta gli eventi, possono anche essere infiniti ma l’uscita non è il numero dell’evento di per sé, altrimenti avrei bisogno di infiniti bit per esempio, ma è il numero dell’evento diviso 8 e di questa divisione si prende il resto(operazione modulo). Quando dato un certo numero di eventi si effettua l’operazione modulo N di 69
questo numero di eventi, il risultato rappresenta l’uscita del contatore. Quindi considerando l’operazione modulo 8, si avranno uscite che vanno da 0 a 7.
70
CAPITOLO 9
CONFLITTI NELLA PIPELINE
Analizziamo cosa accade durante l'esecuzione di un programma colpo di clock per colpo di clock.
Dividiamo lo schema del processore pipeline in righe e colonne, dove sulle righe troviamo le fasi dell’istruzioni nel processore e sulle colonne sono rappresentati i colpi di clock del processore: fasi istruzioni per colpo di clock.
Notiamo una specie di “cortocircuito” (che non tutte le fasi hanno, ci sono solo nella fase ID in cui vanno in ingresso i dati all’ALU in quel caso l’immediato, nella fase EX per inviare il latch B direttamente alla fase successiva, e nella fase MEM per andare direttamente in WB senza passare per memoria dati DM, Data Memory).
Come si nota il blocco dei registri è nella fase di ID tratteggiato a sinistra e continuo a destra, mentre nella fase di WB è continuo a sinistra e tratteggiato a destra. Analizzando colpo di clock per colpo di clock, notiamo che nel primo colpo di clock non c'è nessun pericolo di conflitto, così come nel secondo colpo di clock così come negli altri fino al quinto(il fatto che nei primi quattro colpi di clock non ci siano problemi è
71
dato dal fatto che la memoria è stata divida in memoria delle istruzioni e memoria dei dati, evitando i conflitti strutturali. Al quinto colpo di clock però, subentra un problema poiché la prima istruzione e la quarta istruzione vogliono lavorare entrambe sul banco dei registri. La prima in realtà se fosse un’istruzione di tipo jump non avrebbe bisogno di scrivere sul registro(istruzioni che non eseguono la fase di WB), e l’istruzione sarebbe terminata prima. Nel caso in cui la prima istruzione ha la necessità di scrivere, quindi accedere al banco dei registri, nasce un problema dovuto al fatto che anche la quinta istruzione, in contemporanea alla prima (steso colpo di clock), necessita di accederci in lettura. Fortunatamente le durate delle due fasi sono state dimensionate sulla fase più lenta( che potrebbe essere o la fase di Fetch o la fase di MEM) e la fase più lenta è abbastanza ampia da permettere in maniera seriale 2 accessi al banco dei registri. Quindi, accede la prima istruzione per fare il write back(prima metà del colpo di clock) e poi accede la quinta istruzione per fare la precarica degli operandi(seconda metà del colpo di clock). Ricordiamo però che se si decide che la precarica degli operandi avviene nella seconda metà del colpo di clock, questo vale per tutte le istruzioni, altrimenti genero altri conflitti, oltre al fatto che farei confusione perché le istruzioni non verrebbero eseguite tutte nello stesso modo. 1. Conflitti di Dato Nei capitoli precedenti abbiamo introdotto ciò che veramente rende un processore pipeline: i latch, e abbiamo visto quindi come le latenze per ogni fase devono aumentare e il colpo di clock è dimensionato sulla latenza della fase più lenta, visto che tutte le fasi vengono eseguite in parallelo. Per adesso, la divisione delle memorie, l'introduzione del sommatore “PC+4” e la divisione degli accessi al banco dei registri hanno evitato conflitti strutturali, quindi sicuramente non avremo conflitti di questo tipo all'interno del nostro processore. Occorre però anche gestire i conflitti di dato, conflitti che si verificano nel momento in cui un'istruzione vuole usare un dato che è il risultato prodotto da un’istruzione precedete, nasce quindi un conflitto tra le due istruzioni sullo stesso dato.
72
Supponiamo di avere un programma fatto come nell'immagine, in cui la prima istruzione vuole fare la somma tra R2 ed R3 e scrivere il risultato in R1, mentre un'altra istruzione esegue la differenza tra R1 ed R5, poi un'altra istruzione utilizza R1 come operando di un’operazione AND e un'altra istruzione ancora che vuole usare R1 per un operazione OR. 1. DADD R1,R2,R3 2. DSUB R4,R1,R5 3. AND R6,R1,R7 4. OR R8,R1,R9 5. XOR R10,R1,R11 Quindi R1 è input per tutte le istruzioni che seguono. Questo è un esempio limite che però mette in evidenza tutti i problemi che si possono incontrare durante un'esecuzione pipeline di un programma del genere(i problemi si hanno solo nell'esecuzione in pipeline). Il problema nasce dal fatto che l'istruzione 1 scrive il risultato in R1 solo nella sua fase di write back, colpo di clock 5, mentre la seconda utilizza R1 nella sua fase di ID, colpo di clock 3, prendendo un valore di R1 che non è il risultato dell'istruzione di prima, visto che non è stato ancora scritto in R1, ed è un valore che non ha significato per la logica del programma. Questo problema nasce in tutte le successive istruzione che vogliono utilizzare R1 prima ancora che questo sia stato prodotto e scritto. Effettivamente in questo programma l'unica istruzione che non ha problemi è l'istruzione 5 che fa il fetch nel colpo di clock 6, quando R1 è già stato scritto. Nel paragrafo precedente si è visto che nello stesso colpo di clock si accede ai registri prima per effettuare la write back e poi per la precarica: nella prima metà si esegue la WB e nella seconda la precarica degli
73
operandi. Infatti la write back della prima istruzione, viene eseguita prima della lettura della quarta istruzione. La quarta istruzione ottiene il valore corretto di R1 proprio perché viene eseguita prima la WB dell’istruzione uno e dopo la precarica dell’istruzione quattro; quindi la lettura dell’istruzione quattro preleverà il valore di R1 corretto perché è stato appena scritto dall’istruzione uno. Questo è possibile perché nello stesso colpo di clock, nella prima metà avviene la scrittura e nella seconda metà la lettura. Fino ad ora, abbiamo visto che se un'istruzione (2) necessita del risultato di un'altra istruzione precedente (1), non ci sono problemi se (2) viene eseguita 4 colpi di clock dopo, perché la write back di 1 è già stata eseguita quando (2) effettua la precarica; e con l'ultima considerazione appena fatta ci siamo accorti che non ci sono problemi nemmeno se (2) viene eseguita 3 colpi di clock dopo (1) perchè la write back viene fatta nella prima metà dello stesso colpo di clock in cui (2) effettua la precarica. Le cose sono più complesse man mano che ci avviciniamo all'istruzione che produce R1. Infatti la seconda e la terza istruzione richiedono R1 prima che la prima istruzione abbia scritto al banco dei registri. Una soluzione potrebbe essere quella di creare stallo, o meglio fermare l'esecuzione di una determinata istruzione per un colpo di clock, ma non è conveniente perché stallando una determinata istruzione bisogna necessariamente stallare tutte le altre istruzioni, altrimenti verrebbero generali conflitti strutturali. Per cui evitare gli stalli è molto importante, anche se ci sono degli stalli obbligatori, come lo stallo a causa di un fallimento di accesso a memoria(ad esempio se la cache non contiene l'istruzione che a questo punto va caricata dai livelli sottostanti). A questo punto bisogna trovare una soluzione per eliminare questo stallo. Per evitare questo stallo, ci serve ricordare che la seconda e la terza istruzione richiedono il risultato che va scritto in R1. Quindi all'istruzione non serve leggere il dato da R1 ma serve solo il contenuto che dovrebbe avere R1 al termine della WB dell’istruzione uno. Adesso, rinominiamo le 3 istruzioni dalla prima alla terza con (1) (2) (3). L’istruzione (1) produce il risultato, che verrà scritto in R1 nella WB, già al colpo di clock 3 in uscita all’ALU: EX/MEM.Aluoutput. SI può pensare con un meccanismo che prende il nome di cortocircuitazione dell’alu di collegare EX/MEM.Aluoutput dell’istruzione (1) all’ingresso dell’ALU dell’istruzione (2). Si avverte l’ALU di utilizzare, invece di R1 preso dal banco dei registri con un valore errato, il dato appena passato attraverso il cortocircuito. Nella fase di exe di ogni istruzione l'unità di controllo deve accertarsi che gli input non siano output di istruzioni precedenti, perchè altrimenti bisogna ricorrere ad alternative come quella spiegata prima. Il meccanismo è il seguente, l’istruzione (2) durante la precarica comunque metterà R1 in A; però durante la fase di EXE l’unità di controllo verificherà se R1 è destinazione dell’istruzione precedente, se questo è vero allora invece di inviare A nell’ALU invierò EX/MEM.Aluoutput cortocircuitato. Avviene lo stesso per l’istruzione (3), però a questo punto l'unità di controllo oltre a verificare se l'operando è output di (2), deve anche verificare se l'operando è output di (1)(come effettivamente è); a questo punto quindi, in ingresso non prenderà più R1, ma prenderà MEM/WB.ALUoutput perchè adesso il risultato che andrà scritto in R1 si trova in questo latch, in EX/MEM.ALUoutput troveremmo il risultato dell’istruzione (2). Analizziamo ora cosa accade quando il Risultato di istruzione Load è input di altre istruzioni successive:
74
Supponiamo che ci siano 3 istruzioni, la prima carica R1 dalla memoria, la seconda usa R1 per produrre R4 e la terza usa sia R1 che R4(il programma nell'immagine non fa altro che spostare un dato). In questo caso il risultato della load è sorgente per una store. La seconda istruzione porta in ingresso all'ALU EX/MEM.Aluoutput, mentre la terza metterà in ingresso all'ALU MEM/WB.Aluoutput, , come spiegato nel paragrafo precedente. La cosa aggiuntiva di questo esempio è che la terza istruzione usa anche R4 che non è un risultato ALU, ma è la destinazione di una Load, quindi nel processore quel valore compare per la prima volta in MEM/WB.LMD(colpo di clock 5) e quindi la terza istruzione userà R4 prelevandolo da MEM/WB.LMD. In questa situazione non nascono problemi per il semplice fatto che R4 viene scritto in memoria(è sorgente si una Store). Supponendo che la seconda istruzione invece di essere una load fosse un'operazione ALU a produrre R4, in ingresso all'ALU all'istruzione 3 andrebbe EX/MEM.B(che proviene dall'istruzione 2, sarebbe sempre un ALUoutput). Ora abbiamo risolto questi casi senza dover ricorrere a stalli, semplicemente complicando un po' l'unità di controllo. Vediamo cosa succede se un'istruzione produce R1 come risultato di una LOAD e R1 è input di altre istruzioni aritmetico logiche successive.
75
Ovviamente l’istruzione entrata nel processore 3 colpi di clock dopo la prima non avrà problemi a prelevare R1 dal banco dei registri. L’istruzione entrata nel processore 2 colpi di clock dopo, che in ingresso vuole il risultato di un'istruzione che è avvenuta 2 colpi di clock prima, che è una load in ingresso non avrà MEM/WB.Aluoutput (perchè non usa l'ALU!), ma avrà MEM/WB.LMD. Nell’istruzione 2, quella in ingresso al colpo di clock 2, il vero valore di R1, che serve all'inizio del colpo di clock 4, non c’è da nessuna parte all’interno del processore, è in memoria dati, quindi per usare quel valore c’è la necessità di aspettare che quel dato venga prodotto all'interno del processore. Quindi è necessario stallarsi per un colpo di clock e leggere R1 al prossimo colpo di clock da MEM/WB.LMD. Questo stallo determina lo stallarsi di tutte le istruzioni seguenti, altrimenti si verificherebbero conflitti strutturali. Vediamo ora come è fatto l'HW aggiuntivo per gestire questa corto circuitazione dell’ALU:
76
I Mux che come visto nei capitoli precedenti servivano a far entrare in ingresso all’ALU i latch A o NPC, e B oppure l’immediato. Con le soluzioni descritte in questo capitolo si dovranno gestire anche altri ingressi: EX/MEM.Aluoutput collegato a entrambi i mux, perché questo potrebbe andare a sostituire o il primo o il secondo operando; come possibile altro candidato ad entrare nell’ALU c’è EX/WB.ALUoutput, anche questo collegato a entrambi i mux per il motivo detto prima; e ci sarà anche MEM/WB.LMD. Entrambi i mux saranno controllati da una logica: se il sorgente di questa operazione è la destinazione dell’istruzione immediatamente precedente, verifica se si stratta di un operazione ALU o un operazione LOAD; se è una Load bisognerà stallare altrimenti in ingresso all’ALU andrà EX/MEM.ALUoutput. Se invece non è destinazione dell’istruzione immediatamente precedente verifica se è destinazione dell’istruzione prima ancora (due istruzioni sopra); se è destinazione verifica se si tratta di un operazione ALU oppure LOAD. Se si tratta di un operazione LOAD in ingresso all’ALU andrà MEM/WB.LMD, altrimenti MEM/WB.ALUoutput. Questo vale sia per il latch A e sia per il latch B. Una cosa analoga vale per le istruzioni store, in cui bisogna decidere se in ingresso alla memoria bisogna inviare EX/MEM.B oppure qualcos’altro se questo è destinazione di un operazione precedente. Quindi in ingresso alla memoria dati ci sarà un Mux con una logica come quella appena descitta. Ricapitolando, nei conflitti di dato, quindi attraverso degli accorgimenti sull’unità di controllo, come la corto circuitazione dell’ALU, si possono evitare stalli nel caso di conflitti tra dati con operazioni ALU. Invece nel momento in cui l’istruzione generica fa riferimento a un sorgente che è destinazione di un istruzione LOAD, si è costretti in questo caso a pagare uno stallo.
77
LOAD R6, 128(R0) ADD R8,R6,R7 Durante l’istruzione ADD, R6 non è disponibile in nessun punto del processore in maniera tale che l’istruzione ADD possa disporre di questo valore un attimo prima della fase EX. In LMD c’è il valore di R6 solo al termine della fase MEM della LOAD, che corrisponde alla fase EX della ADD, e invece R6 si necessita all’inizio di questa fase EX. In casi analoghi, come abbiamo visto, dirottiamo EX/MEM.ALUOutput oppure MEM/WB.ALUOutput all’ingresso dell’ALU. In questo caso invece non c’è niente da fare, l’unica soluzione è eseguire l’istruzione LOAD con tutte le sue fasi, e l’istruzione successiva eseguirà la fetch e la decode e poiché la fase di EX non potrà avere ancora il valore di R6 l’unità di controllo stallerà l’esecuzione di questa, che riprenderà successivamente una volta ottenuto il dato in LMD. Anche l’istruzione successiva alla ADD dovrà stallarsi per evitare conflitti strutturali. Altrimenti verrà utilizzata l’ALU da entrambe le istruzioni. Così anche le successive istruzioni per lo stesso motivo dovranno stallarsi anch’esse. In conclusione viene perso un colpo di clock. Un programma di quattro istruzioni così fatto invece di terminare dopo 8 colpo di clock, (N-1) + 5, terminerà dopo 9 colpi di clock, (N-1) +5+ n. stalli per istruzione, nel nostro caso 1. Si può fare qualcosa per non perdere questo colpo di clock ed evitare lo stallo? Dipende. Dipende dall’istruzione che c’è dopo l’istruzione ADD. Ipotizziamo di avere questa situazione LOAD R6, 128(R0) ADD R7,R6,R2 MULT R1,R2,R3 Il compilatore potrebbe scambiare le due istruzioni ADD e MULT: LOAD R6, 128(R0) MULT R1,R2,R3 ADD R7,R6,R2 Dopo la LOAD conviene eseguire l’istruzione MULT che non utilizza R6. Quando opera la ADD, la EX si trova al termine della MEM della LOAD e potrà quindi utilizzare LMD per ottenere R6 senza stallarsi questa volta. Questa operazione si chiama schedulazione statica delle istruzioni. Schedulando una istruzione fra la LOAD e l’istruzione che usa il destinazione della LOAD non c’è più bisogno di stallare. Se fosse stato: LOAD R6, 128(R0) ADD R7,R6,R2 MULT R1,R7,R3
78
La mult utilizza questa volta R7. Scambiando le due istruzioni, ADD e MULT come prima, nella MULT viene usato R7 che non è la somma di R6 e R2, è un valore errato. In questo caso la schedulazione vista prima non è effettuabile. Il compilatore, dunque, normalmente compila il programma traducendo il codice nel linguaggio di più alto livello, come il C ad esempio, in linguaggio macchina. Dopo esegue delle passate di ottimizzazione. Si accorge che il programma dovrà stallare e controlla se questo stallo si può evitare inserendo un'altra istruzione. Cerca un istruzione da mettere in mezzo alle due senza sconvolgere il programma. In generale nessun sorgente di questa istruzione “tappa buchi” deve avere come sorgente una destinazione di una istruzione che viene scavalcata con la schedulazione. Oppure se è necessario farla, bisogna utilizzare un registro differente, bisogna modificare i registri. Un modo quindi di ottimizzare le prestazione limitando gli stalli avviene a livello di schedulazione. Si mettono le istruzioni al posto dello stallo. Se si riesce si ritorna nella condizione ottimale. Si potrebbe pensare, allora, che con questo “stratagemma” si potrebbe evitare la complicazione dell’unità di controllo, evitando cioè la corto circuitazione dell’ALU. Se così fosse gli stalli aumenterebbero. Se non ci fosse la corto circuitazione bisognerebbe aspettare ogni volta che i dati delle istruzioni precedenti vengano scritti , l’ID di una istruzione andrebbe eseguita solo dopo la WB della precedente. Verrebbero aggiunti tre stalli. Il compilatore per evitare tutto questo dovrebbe trovare tre istruzioni da scambiare, il quale diventa molto più complicato. La corto circuitazione riduce il numero degli stalli e il numero di istruzioni da rimpiazzare che lo schedulatore dovrebbe trovare. 2. Conflitti sulle Diramazioni Entra in gioco il problema legato al flusso delle istruzioni. Come abbiamo visto le istruzioni vengono eseguite ad ogni colpo di clock. Il problema che si presenta è che bisogna conoscere l’indirizzo dell’istruzione da prelevare. Affinché si possa prelevare ad ogni colpo di clock una istruzione, bisogna conoscere ogni volta l’indirizzo di questa. Per poter prelevare un istruzione è necessario che l’aggiornamento del PC avvenga nella fase di fetch di una istruzione. Il PC, appena spedito alla porta indirizzi viene aggiornato, PC+4, in maniera tale che al prossimo colpo di clock rispendendolo nuovamente alla memoria indirizzi, venga prelevata l’istruzione successiva. Questo è corretto poiché siamo nella situazione che l’istruzione da prelevare è subito dopo questa, le istruzioni sono scritte una dopo l’alta, e inoltre sono di lunghezza fissa. Queste operazioni però non risolve il problema che si presenta quando l’istruzione è di salto. Quando ci si trova di fronte ad una istruzione di salto, condizionato o incondizionato, la prossima istruzione non è a PC+4, ma a un altro indirizzo. Se questa istruzione è di salto, al prossimo colpo di clock non si può prelevare l’istruzione da eseguire? Dipende. Si può fare se nella fase di fetch si è già calcolato l’indirizzo dove saltare, e inoltre si è calcolato se il salto si deve fare oppure no. Però si conosce se l’istruzione è di salto o meno soltanto al termine della fase di decode (sembra che un cubo di Rubik dove hanno staccato i pezzi dei colori e gli hanno messi in modo che non lo si può più risolvere!). A livello deterministico non si può garantire niente, si possono fare però delle speculazioni. Attraverso dei meccanismi si può prevedere se questa è un istruzione di salto, si potrebbe scommettere anche s il salto si verifica o meno, e anche dove saltare. In fase di esecuzioni si attivano meccanismi speculativi, che non garantiscono se davvero è la cosa giusta da fare. Si possono fare
79
delle previsioni dinamiche in fase di esecuzione di quello che potrebbe succedere, saltare a una istruzione o meno. Questo discorso verrà analizzato successivamente nel corso dei capitoli. Quindi dopo aver prelevato l’istruzione, e se questa è di salto non è possibile saperlo ancora prima del termine della fase di decode, normalmente sarà prelevata l’istruzione successiva, mentre la prima quindi è in fase di esecuzione. Effettivamente si può prelevare l’istruzione dove saltare soltanto una volta determinato l’indirizzo dove saltare e dopo aver terminato se la condizione è stata verificata o meno. Questo avviene al termine della fase di EX. Nella fase di MEM, invece, ALUOutput va in PC al posto di NPC. Al termine della fase MEM in PC c’è il valore corretto. Soltanto ora il fetch si troverà ad utilizzare il vero indirizzo dell’istruzione. Intanto però sono state eseguite delle istruzione. E queste istruzioni che sono state avviate fintanto che non si è calcolato l’indirizzo di salto, hanno fatto lavoro inutile. Queste istruzioni, però, non hanno fatto danno. Si può pensare che queste istruzioni modifichino dei registri, ma i registri vengono scritti soltanto nella fase di WB. Fino alla fase di WB di questa, l’istruzione di salto è già arrivata nella fase di EX e in qualche modo si è deciso se saltare o no. L’unità di controllo appena verificato se saltare o meno, se il salto si deve effettuare abortisce le istruzioni avviate dopo di lei. Le istruzioni STORE scrivono in memoria durante la fase di MEM, la EX dell’istruzione di salto sarà stata eseguita e avrà quindi abortito questa. A seconda di come gestire il meccanismo di calcolo e di decisione se saltare o meno, ci sono un certo numero di istruzione che sono partite dopo l’istruzione di salto nel frattempo che si è deciso cosa fare. Questo numero di istruzioni prende il nome di penalità di salto. Ci sono diverse soluzioni per gestire queste penalità di salto. 1)
Queste istruzioni potrebbero non essere eseguite, cioè fermare per un certo numero di clock il sistema finché non viene calcolato l’indirizzo e viene verificata la condizione per saltare. (Reginella quanti passi devo fare per arrivare al tuo castello con la fede e con l’anello?!) In base al numero di colpi di clock necessari, ad esempio n colpi di clock, si ha una penalità di salto di n colpi di clock. Si cerca di ridurre questa penalità e anticipare al prima possibile il calcolo dell’indirizzo dove saltare e la decisione se il salto si verifica o meno. 2) Una soluzione più intelligente sarebbe quella, come abbiamo detto prima, di continuare l’esecuzione delle istruzione, a patto che nel momento in cui si è deciso di saltare vengano bloccate queste istruzioni. Il processore, perché questa fase di calcolo dell’indirizzo e decisione dipende dal tipo di processore, mi deve garantire che si è in tempo ad abortirle queste istruzioni speculative prima che queste facciano danni. Con un struttura appena descritta in cui si decide se saltare o meno prima della fase MEM possiamo utilizzare questa soluzione. Consapevoli che se l’istruzione è di salto vengono bloccate le istruzioni. L’importante è quindi che non siano stati scritti registri, e non sia stata scritta la memoria. Se l’istruzione di salto invece non viene verificata non si è perso tempo, non si verifica non si paga penalità. Quando il salto si verifica, queste istruzioni vengono abortite in tempo
80
3) Un ulteriore soluzione potrebbe essere quella mettere le istruzioni precedenti al salto dopo e non abortire più queste. Supponiamo di avere una penalità di salto pari a n istruzioni, che eventualmente potrebbero essere bloccate se il salto si verifica. Dopo l’istruzione di salto vengono avviate n istruzioni in tanto che si è deciso se saltare o no. Il compilatore può prendere n istruzioni precedenti all’istruzione di salto, che devono essere comunque eseguite, e che siano indipendenti dall’istruzione di salto, e metterle dopo le istruzioni di salto. L’istruzione di salto ora è diversa, avrà un codice operativo diverso, perché a prescindere se saltare o meno le istruzioni avviate dopo non devono essere abortite, perché sono istruzioni che vanno comunque eseguite. Una volta calcolato il nuovo PC e aver calcolato la condizione, le istruzioni dentro la pipeline vanno portate a termine, perché non sono istruzioni, dal punto di vista logico, dopo l’istruzione di salto e quindi da bannare se il salto si verifica, ma sono istruzioni che andrebbero comunque eseguite. Invece di avere una penalità di salto a rischio aborto quelle istruzioni vengono portate a termine. Il compilatore deve trovare un numero di istruzioni da eseguire che non siano dipendenti dall’istruzione di salto. Ad esempio se l’istruzione di salto è, salta quando R1 != 0, e ci sono istruzioni precedenti che lavorano su R1, queste non possono essere spostate. Si codifica l’istruzione di salto con un codice operativo diverso a indicare che le istruzioni dopo non sono speculative. Se invece non vengono trovate queste istruzioni, le istruzioni vengono eseguite in maniera speculativa e se non si effettua il salto queste verranno bloccate perché erano quelle successive al salto dal punto di vista logico. Se il compilatore trova n istruzioni, dove n è la penalità di salto, le sposta dopo il salto. Il codice operativo dell’istruzione di salto cambia, perché le istruzioni dopo vengano portate a termine. Se queste istruzioni non vengono trovate, verranno eseguite le istruzioni dopo speculative. L’istruzione di salto ha un altro codice operativo che bloccherà quando bisognerà saltare. 4) Analizziamo un'altra soluzione utile da adottare quando il compilatore non trova istruzioni da mettere dopo il salto, e questa istruzione salta nel 90% dei casi. Cioè nel 90% dei casi queste istruzioni speculative verranno abortite, verranno pagate nel 90% n penalità di salto: 0.9*n. Come si migliora questa situazione? Ipotizziamo di avere una penalità di salto pari a 3, e nella situazione descritta il compilatore non ha trovato 3 istruzioni da inserire dopo il salto da eseguire comunque. Il compilatore sposta le 3 istruzioni che sono all’indirizzo di salto subito dopo l’istruzione di salto. La quarta istruzione che c’era all’indirizzo di salto diventerà ora l’indirizzo di salto. Iniziata l’istruzione di salto verranno comunque eseguite le successive, quelle che sono state spostate, che sono destinazione del salto. L’istruzione di salto, con un codice operativo differente anche questa volta, opererà in questo modo: se bisogna saltare le istruzioni che avviate verranno portate a termine, se invece non bisogna saltare queste istruzioni avviate verranno abortite. Se l’istruzione di salto è da fare, in automatico sono state fatte quelle istruzioni e poi si andrà all’indirizzo di salto. Se invece il salto non si deve fare quelle non dovevano essere eseguite e allora verranno abortite. Il ragionamento è inverso al secondo caso analizzato. Come si fa a capire se il salto avviene al 90% 81
dei casi? Se quella è un istruzione di salto al termine di un loop, il compilatore sa che il loop il più delle volte salterà e quindi il più delle volte avverrà il salto. Si può avere una situazione di questo genere con il salto indietro piuttosto che in avanti. Le istruzioni destinazione del salto questa volta verranno replicate e non spostate come prima. Se il salto è più facile che non si verifichi, si lasciano le cose come stanno, e se il salto si verifica verranno abortite le istruzioni. Se il salto, invece, è più facile che si verifichi, verranno prelevate le n istruzioni di penalità dalla destinazione del salto e si andranno a mettere subito dopo l’istruzione di salto, e se il salto non si verifica verranno abortite le istruzioni avviate, altrimenti verranno eseguite e si andrà all’indirizzo di salto. Se il salto è indietro questa è una copia e non uno spostamento. La situazione è così schematizzata:
Riguardo la schedulazione del codice nel momento in cui si ha un istruzione di salto l’idea è quella di inserire nel ritardo di salto che viene chiamato DELAY SLOT, un’istruzione che deve essere comunque eseguita. Nel caso a) la somma di R2 ed R3 in R1 può essere tranquillamente messa al di sotto del test. Questo spostamento non poteva essere effettuato qualora la somma aggiornasse il valore di R2, perché altrimenti si sarebbe effettuato un test con un valore di R2 errato. Con la situazione così descritta non si avranno problemi quindi sia se il salto dovrà essere effettuato e sia se il salto non dovrà svolgersi. Il codice operativo che codificherà questo branch sarà un codice operativo che avviserà il sistema (l’unità di controllo) che nel momento in cui questa istruzione avrà verificato che il salto si dovrà effettuare, l’istruzione avviata speculativamente deve comunque essere portata a termine. Se invece l’unità di controllo si accorge che il salto non va effettuato, l’istruzione avviata speculativamente verrà abortita. Nel caso b) invece nel delay slot in sostanza, si inserisce l’istruzione che andrebbe eseguita qualora non bisogna saltare, e quando il salto dovrà essere effettuato, l’unità di controllo abortirà questa istruzione. Questo
82
branch sarà codificato con un ulteriore codice operativo. Quindi ci sono tre codici operativi diversi per codificare un istruzione di tipo branch. Come visto nei capitoli precedenti, durante la fase EXE avviene il calcolo di un risultato oppure il calcolo di un indirizzo di salto oppure il calcolo di un indirizzo di memoria.
Per quanto riguarda un istruzione di salto oltre alla parte ALU, per il calcolo del nuovo indirizzo, si aggiunge questa logica che verifica se il salto va fatto oppure no, andando a sovrascrivere nel latch “Cond” un valore, che potrebbe essere per esempio 1, se il salto va eseguito, 0 altrimenti. Questo latch pilota l’uscita del Mux in alto. Se Cond vale 1, il Mux piloterà in uscita il valore proveniente da ALUoutput, cioè NPC +Immediato, altrimenti NPC; NPC che in sostanza vale PC+4 cioè punta all’istruzione successiva a quella corrente. Quindi quando viene decodificata un istruzione di branch , al prossimo colpo di clock, l’unità di controllo farà passare NPC sommato all’immediato che è da 16 bit. Se invece l’istruzione decodificata è una jump passerà l’immediato da 26 bit invece, perché l’istruzione di tipo jump è costituita, a parte i bit per il codice operativo, da 26 bit per l’immediato. In sostanza nella fase di EXE di questa istruzione si deciderà se saltare o meno. Durante questa fase di EXE si è effettuato il fetch di altre due istruzione che verranno avviate quindi speculativamente, e solo dopo queste due verrà fatto il fetch dell’istruzione corretta, perché solo quando questa fase è terminata effettivamente si conoscerà l’indirizzo a cui saltare. Per cui il delta slot con questa architettura è in sostanza di 2 istruzioni.
83
La figure seguente mostra invece l’architettura del processore pipeline.
Il test viene eseguito su ID/EX.A, che contiene il valore di un certo registro. Se questo che è maggiore o minore di 0 si decide se saltare o meno e si setta il latch Cond che in questa architettura è EX/MEM.Cond il quale controlla il mux. Quindi la situazione è la seguente: ad un certo colpo di clock entra nella fase di IF un istruzione di salto che al prossimo colpo di clock verrà decodificata e al successivo colpo di clock verrà calcolato l’indirizzo di salto e in sostanza c’è un delay slot di 2 istruzioni. Solo dopo che l’istruzione di salto ha terminato la fase di EXE , cioè quando entra nella fase di MEM, c’è un’instruction fetch “garantito”. Intanto è avvenuto il fetch e l’esecuzione di due istruzioni che rappresentano uno stallo oppure una speculazione, oppure delle normali esecuzioni in quanto il compilatore è riuscito a trovare due istruzioni da inserire che andrebbero eseguite a prescindere dal risultato del test. In quest’ultimo caso il compilatore deve trovare due istruzioni che non abbiano dipendenze con il test e che possono essere spostate dopo la condizione. Questo può rappresentare un problema perché non è detto che il processore riesca a trovare delle istruzione di inserire nel delay slot, e quindi si otterrebbe uno stallo oppure una esecuzione di istruzioni speculative. Si cerca allora di ridurre questo numero di istruzioni, diminuire il delay slot di 2 istruzioni. Si può scommettere sul branch, cioè si inseriscono delle istruzioni perché si scommette che il branch non verrà eseguito per la maggior parte delle volte. Questo comporta che se la previsione è errata vengono avviate 2 istruzioni che verranno abortite per la maggior parte delle volte. Bisogna valutare quanto bravo è il predittore (branch prediction), ovvero un modulo dell’unità di controllo che fa delle predizioni, una sorta 84
di book-maker! Se il predittore sbaglia tante volte , tante volte verranno pagati n stalli, dove n in generale è il numero di istruzioni da inserire nel delay slot. E’ possibile diminuire il delay slot al valore0? Un delay slot pari a 0 significa che ogni volta si esegue un fetch di un istruzione certo, cioè il calcolo dell’indirizzo di salto andrebbe fatto nella fase di IF dell’istruzione di salto, ma questo non è possibile poiché sta ancora avvenendo il prelievo dell’istruzione, e inoltre non si conosce l’istruzione non si sa se è di salto o meno. Non è possibile avere un delay slot uguale a 0. Vediamo se è possibile determinare l’indirizzo di salto nella fase di ID dell’istruzione di salto e quindi avere un delay slot uguale a una sola istruzione. Questo significa che il compilatore dovrà trovare una sola istruzione, che deve comunque essere eseguita, dopo questa istruzione di salto. Quando questa istruzione non è trovata, perchè non esiste nel codice o anche perché il compilatore non la sa individuare, se il branch prediction fallirà, si avrà un aborto di una sola istruzione invece che 2 come abbiamo visto prima: ridurre a uno la penalità di salto, il delay slot. Ridurre a una istruzione questo delay slot significa in sostanza calcolare l’indirizzo di salto nella fase di ID. Attenzione però che nella fase di ID sta avvenendo la decodifica dell’istruzione , cioè non si conosce se l’istruzione corrente è di salto o meno, e quindi a cosa serve calcolare l’indirizzo di salto? Si calcola l’indirizzo di salto nella fase di ID in maniera speculativa, come il resto delle altre operazioni, si calcola l’indirizzo comunque mentre si cerca di capire se quella è un istruzione di salto . Questo indirizzo di salto viene posteggiato da qualche parte e alla fine di questa fase di ID, quando si è deciso se l’istruzione è di salto, quell’indirizzo calcolato verrà scritto nel PC, altrimenti, se l’istruzione decodificata non è un istruzione di salto, l’indirizzo rimarrà inutilizzato. Bisogna però anche capire se il salto si verifica o meno, valutare la condizione di branch. Quindi bisogna anticipare nella fase di ID sia il calcolo dell’indirizzo e sia la valutazione della condizione . Per la valutazione della condizione si inserisce il modulo “Zero?” nella fase di ID, mentre per il calcolo dell’indirizzo di salto non si può utilizzare l’ALU, perché durante la fase di ID l’ALU è utilizzata dalla fase di EXE dell’istruzione precedente, quindi è necessario utilizzare un sommatore, che esegue la somma tra l’immediato e NPC. L’architettura del processore diventa la seguente:
85
Come si nota il modulo per la verifica del test è spostato nella fase ID, però è stato necessario pagare un sommatore in più. Per cui il modulo “Zero?” decide se il salto va eseguito o meno e se necessario il risultato viene congelato nel caso l’istruzione si scopre non essere di salto. A questo punto si è certi che al colpo di clock successivo a quello in cui l’istruzione di salto ha eseguito l’ID , l’IF della nuova istruzione è corretta. Prelevata l’istruzione a un certo colpo di clock, al colpo di clock successivo questa passa alla fase di ID, e viene prelevata speculativamente una certa istruzione. Al prossimo colpo di clock, decodificata l’istruzione di salto viene prelevata l’istruzione corretta. La figura è un po’ carente perché non mostra cosa accade nel caso di una istruzione jump, però in realtà un jump non ha bisogno di verificare la condizione, e l’indirizzo viene calcolato dall’immediato da 26 bit.
86
Esercizi Esercizio 1. Scrivere un codice in Assembler che calcoli il valore massimo di una matrice double, con n righe e m colonne, caricata in memoria per righe. Soluzione:
R1 = n, R2 = m, R3 = M[0][0]
R5 <- 0
R4 <- R3 R6 <- R1*R2
F R5 < R6
V
R3 <- R3+8
R7<- M[R3]
V F R4
R4<- R7
R4
87
Codice non ottimizzato: 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12.
DADDI R5,0(R0) LD R4,0(R3) DMUL R6,R1,R2 SLT R8,R5,R6 BEQZ R8,6 DADDI R3, 8(R3) LD R7,0(R3) SLT R8,R4,R7 BEQZ R8,1 DADDI R4,0(R7) DADD R5,1(R5) J -8
Un' algoritmo diverso (non ottimizzato): 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12.
LD R4,(R3)0 DMUL R5,R1,R2 DSLL R5,(R5)3 DADDI R6,(R0)8 DADD R10,R6,R3 LD R7,(R10)0 SLT R8,R4,R7 BEQZ R8, 1 DADD R4,R0,R7 DADDI R6,(R6)8 SLT R8,R6,R5 BEQZ R8,-8
Su quest’ultimo codice si possono eseguire diverse modifiche o effettuare nuove varianti. Una variante potrebbe essere quella di percorrere il vettore dal basso verso l’alto evitando quindi alcune istruzioni e riducendo il numero di colpi di clock necessari all’esecuzione. Quest’ultimo codice impiega 4+n*m*8 colpi di clock.
88
Esercizio 2. Scrivere un codice Assembler che legga un vettore double, di dimensione n, a partire da un indirizzo, e inserisca in un altro vettore, a partire da un altro indirizzo, gli elementi del primo moltiplicati per 2. Soluzione:
R1 = V[0], R2 = n, R3 = W[0]
R5 <- R2
R5 <- R5*8
R5 <- R5 -8
R6 <- R5+R1
R4 <- M[R6]
R4 <- R4*2
R6 <- R5 +R3
M[R6] <- R4
F R5 == 0
V
END
89
Codice non ottimizzato: 1. 2. 3. 4. 5. 6. 7. 8.
DSLL R5, (R2)3 DADDI R5,(R5)-8 DADD R6,R5,R1 LD R4,(R6)0 DSLL R4,(R4)1 DADD R6,R5,R3 SD R4,(R6)0 BNEZ R5, -7
Questo codice impiega 2+n*7 colpi di clock. Il codice ottimizzato: 1. 2. 3. 4. 5. 6. 7. 8.
DSLL R5, (R2)3 DADDI R5,(R5)-8 DADD R6,R5,R1 LD R4,(R6)0 DADD R6,R5,R3 DSLL R4,(R4)1 BNEZ R5, -7 SD R4,(R6)0
Questo codice invece impiega 2+5*n+1 colpi di clock
90
Esercizio 3. Scrivere un codice Assembler in cui dati due vettori, di dimensione n, a partire da due indirizzi di memoria, scrivi un ulteriore vettore i cui elementi sono dati dalla somma degli elementi dei primi due vettori: X = V+W.
Soluzione:
R1 = V[0], R2 = n, R3 = X[0], R4 = W[0]
R5<- R2 R5<- R5*8
R5 <- R5-8
R6 <- R5+R4
R8<- M[R6]
R6 <- R5+R1
R7<- M[R6]
R8<- R8+R7
R6 <- R5+R3
M[R6] <- R8
F
R5 ==0 V END
91
Un codice ottimizzato potrebbe essere: 1. 2. 3. 4. 5. 6. 7. 8. 9. 10.
DSLL R5,(R2)3 DADDI R5,(R5)-8 DADD R6,R5,R4 LD R8,(R6)0 DADD R6,R5,R1 LD R7,(R6)0 DADD R6,R5,R3 DADD R8,R7,R8 BNEZ R5,-8 SD R8,(R6)0
Un ulteriore codice: 1. DADDI R4, (R0)0 2. DSLL R9,R5,3 3. ciclo: DADD R7,R2,R4 4. DADD R8,R3,R4 5. LD R12,(R7)0 6. LD R13,(R8)0 7. DADD R6,R1,R4 8. DADD R12,R12,R13 9. DADDI R4,R4,8 10. BNE R4,R9,ciclo 11. SD R12,(R6)0
92
Esercizio 4. Analizzare le fasi dell’istruzione COPY, così definita: COPY Rs1 ,Rs2 ,Imm
M[Rs1+Imm] ->M[Rs2]
IF IF/ID.IR < M[PC]; IF/ID.NPC, PC <- if((EX/MEM.opcode == branch) & EX/MEM.cond) EX/MEM.ALUoutput; Else PC+4; ID ID/EX.A <- Regs[IF/ID.IR[rs]]; ID/EX.B <- Regs[IF/ID.IR[rt]]; ID/EX.NPC <- IF/ID.NPC; ID/EX.IR<- IF/ID.IR; ID/EX.Imm <- sing-extend[IF/ID.IR[immediate field]]; EX EX/MEM.IR<- ID/EX.IR; EX/MEM.ALUoutput <- ID/EX.A + ID/EX.Imm; EX/MEM.B <- ID/EX.B; MEM1 MEM/WB.IR <- EX/MEM.IR; MEM/WB.LMD <- M[EX/MEM.ALUoutput]; MEM/WB.B <- EX/MEM.B MEM2 M[MEM/WB.B] <- MEM/WB.LMD;
93
94
CAPITOLO 10
IL COSTO DI UN CIRCUITO INTEGRATO Ora facciamo un breve cenno ad alcuni fattori che determinano il costo del circuito integrato. Alcuni di questi fattori usufruiscono di un abbattimento quando poi nella produzione si va a produrre un quantitativo piuttosto che un altro, viceversa altri fattori non subiscono questo abbattimento. 1. La progettazione Il primo fattore da considerare è quello legato alla progettazione, questo fortunatamente viene abbattuto quando si produce un quantitativo di pezzi maggiore rispetto a uno minore. Se si spende una certa somma per progettare un circuito elettronico, questa è dovuta allo stipendio per il numero di ingegneri che devono lavorare al progetto per un certo numero di settimane o di mesi , e una parte all’ammortamento delle macchine su cui questi ingegneri lavorano. L'ammortamento è il piano di restituzione graduale di un debito mediante il pagamento periodico di rate. Quello che viene da questa somma di costi, che è essenzialmente lo stipendio che si paga ai cervelli che lavorano su un sistema, va aggiunto il costo della struttura (ufficio, capannone). Una volta che il progetto è terminato, questi costi non aumentano se decido di produrre un chip o produrne dieci, per cui se il progetto costa 100.000 € è chiaro che vendendo 1.000 chip, da ciascuno di questi bisogna ricavare almeno 100 € per rifarsi almeno delle spese di progetto. Vendendo un solo chip, questo dovrà costare 100.000 €, altrimenti c’è un problema nel business plan. Il progetto, prima di arrivare alla produzione subisce delle verifiche. Si fanno delle simulazioni e dei test. Per esempio un modo di testare un circuito integrato, a livello funzionale, è quello di generare dei files di stimolo (degli ingressi) e vedere sul simulatore in virtù di quegli ingressi come si comporterebbe quel circuito integrato. Se il circuito integrato deve eseguire operazioni che sono relativamente piuttosto semplici, il test è anche veloce. Per rete combinatoria intendiamo un circuito con degli ingressi e delle uscite che sono funzioni degli ingressi. Applicando degli ingressi, questo oggetto deve produrre delle uscite che sono funzioni di questi ingressi. Però esistono circuiti un po’ più complicati che prendono il nome di macchine a stati, in cui per determinare l’uscita non basta conoscere l’ingresso, perché per dire quell’ingresso quale uscita produce bisogna conoscere non solo l’ingresso ma anche lo STATO precedente del circuito. Un banale esempio di macchina a stati è il contatore, il quale riceve un impulso e conta l’impulso. Quando nell’ingresso è presente l’impulso non si conosce l’uscita. La funzione che regola il valore dell’uscita è una funzione che ovviamente dipende dall’ingresso ma anche dallo stato del sistema e così come a ogni ingresso cambia l’uscita , c’è anche un'altra funzione che va a modificare lo stato. Quindi l’uscita all’istante t dipende dall’ingresso all’istante t e dallo stato in t-1; e lo stato nell’istante t dipenderà dall’ingresso t e dallo stato all’istante t-1. Quindi c’è una variabile in uscita che è quella osservabile e che interessa; ma per descrivere il funzionamento di questa macchina c’è anche un'altra variabile chi si chiama STATO, e che anch’essa cambia di volta in volta ed è lei insieme all’ingresso a determinare il valore di uscita. Quindi oggetti di questo genere sono molto più complessi da testare. Le cose possono essere ancora più complicate se parlo di sistemi che hanno la memoria più lunga, l’uscita dipende sia dallo stato all’istante precedente ma anche da stati di istanti ancora precedenti. Bisogna sapere cosa c’era prima e sapere anche cosa c’era prima ancora. Quindi le combinazioni possibili aumentano.
95
2. La produzione Nella produzione ci sono dei costi che dipendono anche dal tipo di cliente. Non tutti quelli che producono circuiti integrati effettivamente producono loro il circuito integrato. Ci sono nel mondo poche fonderie. Il circuito integrato si costruisce sul wafer costituito da germanio o silicio, possibilmente privo di impurità, opportunamente drogato.
In un crogiuolo ad alte temperature si fonde il silicio(o germanio) e si procede con la fase di drogaggio: un operazione chimica che aggiunge e toglie elettroni agli atomi di silicio e con questa operazione quella materia si riesce a comportare come un transistor piuttosto che come un condensatore ecc. Quello che interessa è avere un idea del perché un chip che contiene 1000 transistor costi di più di un chip che ne contiene 10, dove ovviamente questi numeri sono del tutto casuali perché in un chip ci sono migliaia di transistor. Questo wafer ha una forma circolare e non quadrata perché quando si prepara un wafer, il processo di fabbricazione è tale da generare una forma circolare perché questo materiale si espande radialmente. Allora al termine quando il processo di fabbricazione di questo materiale ha prodotto questa “fetta”, essa ha una forma circolare. Su questo materiale bisogna inserire i package (contenitore in cui sono racchiuse alcune tipologie di componenti elettronici: circuiti integrati ecc..), che possono avere una dimensione variabile e forma rettangolare o quadrata. Si disegna in questa geometria circolare un certo numero di pezzi i quali sono tali da essere replicati ma ovviamente con alcuni di questi che verranno buttati perché mancheranno delle parti. Se con una certa area di wafer si riescono a costruire N chip, non è detto che con un area di wafer doppia si riescano a inserire 2N chip. Questa proporzione si manterrebbe se la geometria non fosse tonda. Interviene un fattore tragico che spinge a pensare in fase di progetto come ridurre il numero di transistor e quindi l’area di un singolo chip. I chip sani che si ottengono dopo che sono stati inseriti sul wafer non è detto che siano pronti per essere venduti, perché questi oggetti possono essere difettosi. Le impurità presenti sul wafer determinano il fatto che quei chip costruirti su di un area impura del wafer vadano scartati. Il numero di chip da buttare dipende anche dall’area del singolo chip. Supponiamo che l’impurità abbia una probabilità di esistere, per esempio , minore dello 0,001 % in 1 cm 2 , a 96
significare che in 1 cm2 di questa materia ci sono delle impurità con questa frequenza. Questo significa che se c’è l’ 1% di impurità e ci sono 100 pezzi da 1 cm2 , significa che un pezzo mediamente verrà gettato. La resa di produzione è del 99 %. Se viene considerato un chip che non funziona, e viene venduta una motherboard con questo chip difettato, ritorna indietro tutta la motherboard! Il test ha un costo che vale per ogni pezzo , non per ogni pezzo che viene vendo, ma anche per quelli che vengono venduti va fatto. Ci sono dei pacchetti software la cui uscita è il codice per controllare il forno di drogaggio di questi oggetti. Attraverso il codice VHDL (linguaggio che serve a descrivere il funzionamento di un circuito elettronico) il software fornisce dei files per configurare il drogaggio del circuito elettronico stesso. Anche questo test non sarà esaustivo, però in media va ad esplorare la casistica più disparata degli stati in cui quel sistema può venirsi a trovare. A corredo di queste tecniche ci sono delle filosofie di FAULT TOLERANCE , proprietà di garantire a quel circuito una tolleranza ai guasti. Quindi per esempio si possono creare delle ridondanze in maniera tale che nel momento in cui in un certo settaggio di quel circuito non funziona bene perché per esempio c’è un impurità, e questa danneggia per esempio un sommatore, se quel sommatore è stato replicato nel package, si configura quel chip a usare l’altro sommatore e quindi a risolvere questo problema.
97
98
CAPITOLO 11
IL PROCESSORE FLOATING POINT
Tutto ciò che è stato mostrato finora è relativo al processore in virgola fissa, cioè a significare che gli operandi su cui esso opera sono operandi codificati in binario complemento a 2 e non in floating point. Le operazioni binarie in complemento a 2 hanno una latenza che è più o meno riconducibile alla latenza per l’accesso ad una memoria. Ora faremo riferimento al processore che operi in hardware su dati a virgola mobile, subroutine che eseguano algoritmi per il di calcolo fra dati in virgola mobile.
non utilizza
1. Operazioni Floating Point Nello standard IEEE 754 i numeri reali in virgola mobile(floating point) vengono rappresentati nel seguente modo: considerando una rappresentazione a 32 bit(32bit singola precisione invece di 64 bit doppia precisione)
1 bit per il segno (1 bit per la doppia precisione) 8 bit per l’esponente (11 bit) 23 bit per la mantissa (52 bit)
Aumentando i bit per la mantissa aumenta la precisione con cui rappresento un numero, aumentando i bit per l’esponente aumento la quantità di numeri che posso rappresentare. Per il bit del segno, il valore 0 indica un numero positivo, il valore 1 indica un numero negativo. La mantissa(per gli informatici) è il numero dopo la virgola che si ottiene spostando questa a destra dell’ultima cifra diversa da 0, e l’esponente è pari al numero di volte che ho spostato la virgola, se verso sinistra allora è positivo altrimenti negativo. Inoltre il valore dell’esponente va sommato al bias il cui valore è pari a 127 per la rappresentazione a 32 bit mentre 1023 per quella a 64 bit. L’esponente inoltre che può essere con segno o senza va scritto in complemento a 2 (CA2: devo complementare il numero, cioè mettere a 1 tutti gli zeri e a 0 tutti gli 1, e poi sommo 1). Es.: 16,625 = 10000,101 La mantissa vale: 1,0000101 = 0000101 Mentre l’esponente: 4(ho spostato la virgola di 4 posizioni verso sinistra) 4+127(bias) = 131 che in binario vale: 10000011 Il bit di segno vale 0 poiché il numero è positivo
99
Es.: 11011,11101 Questa volta ho a disposizione ad esempio 9 bit per la mantissa questa vale: 10111110 E come esponente -4 => -4+127 = 124 che in binario 1111011
1.1. Addizione Nella somma di floating point di due numeri bisogna che i due numeri abbiano lo stesso esponente, in particolare bisogna portare l’esponente più piccolo a quello del più grande, facendo questa operazione è possibile che mi venga zero(quando un numero è troppo più grande di un altro la somma con questo mi da un contributo trascurabile). Quindi scelgo l’esponente maggiore poi prendo la mantissa del numero con esponente minore e devo spostare la virgola: fare uno shift della mantissa. Lo shift(scorrimento) è lo spostamento dei valori dei bit di una posizione a sinistra o a destra in un registro o in una posizione di memoria. L’operazione fa scorrere i bit uno dopo l’altro verso destra(>>) o verso sinistra(<<). Di solito si fanno scorrere i bit inserendo uno zero(a seconda delle posizioni) da destra o da sinistra(a seconda di come sto shiftando), se devo fare uno shift di due posizioni si fanno entrare due zeri e così via. Lo shift rotatorio prevede che ciò che esce da una parte entra dall’altra. Es.: 111001011 << 2 100101100 10110101 << 2 (shift rotatorio) 01101011 Se il numero è senza segno(binario puro) e faccio uno shift verso destra (>>) quello che entra è uno zero, se il numero è in CA2 entra un bit uguale al primo bit che vedo prima dello shift. Es.: 101001101 >> 1 (numero in CA2) 110100110
011001001 >> 2 (numero in CA2) 000110010
100
Dal punto di vista aritmetico l’operazione di shift corrisponde a una moltiplicazione(shift verso sinistra <<) x2^n (n numero di posizioni che ho shiftato) o a una divisione(shift verso destra >>) /2^n. Il risultato della somma ha l’esponente vincolato all’esponente del numero maggiore. Prima di sommare le mantisse devo normalizzare la mantissa all’esponente del numero minore. Faccio la differenza tra gli esponenti (esp. maggiore meno esp. minore) che ipotizziamo sia un valore m, e shifto verso destra di m posti la mantissa più piccola, ora sommo le mantisse. L’addizione quindi consta di questi passaggi:
Differenza tra gli esponenti Shift sulla mantissa con esponente minore Somma delle mantisse
1.2. Moltiplicazione Nella moltiplicazione di due numeri rappresentati in floating point, il nuovo numero ottenuto ha la mantissa pari al prodotto delle mantisse e come esponente la somma degli esponenti. Bisogna stare attenti a normalizzare la mantissa perché dalla moltiplicazione delle mantisse posso avere un risultato che necessita di un numero di bit più grande di quanti ne posso mettere nel campo mantissa, e devo inserire allora i più significativi(sposto la virgola) e i restanti li perdo. Ora nell’esponente dovrò considerare oltre alla somma degli esponenti anche il numero di volte in cui ho spostato la virgola per inserire nella mantissa il nuovo numero ottenuto dalla moltiplicazione. Invece il segno del nuovo numero ottenuto dalla moltiplicazione è dato dall’operazione di XOR dei due bit di segno. 0 XOR 0 = 0 (+ x + = +) 1 XOR 1 = 0(- x - = +) 1 XOR 0 = 1(- x + = -) 0 XOR 1 = 1(+ x - = -)
2. Architettura Processore Floating Point Dopo un breve cenno sul formato floating point e su come avvengono le operazioni su dati floating point analizziamo l’architettura di questo processore. Come detto sopra per il calcolo della somma tra dati in virgola mobile , bisogna fondamentalmente la somma delle mantisse, solo dopo aver confrontato gli esponenti, e aver eseguito un eventuale shift della mantissa del numero meno significativo verso destra per adeguare gli esponenti. Dopo la somma quindi si ottiene un numero floating point con la mantissa pari alla una somma delle mantisse dei due addendi e l’esponente diventa l’esponente di quello più significativo a meno di un riaggiusto se nella somma delle mantisse si genera un carry.
101
Queste sono operazioni si possono realizzare con sommatori, comparatori, moltiplicatori in virgola fissa trattando i campi del dato floating point come numeri in virgola fissa, però questo richiederebbe un po’ di tempo. Un'altra soluzione è quella di realizzare in hardware operazioni direttamente tra dati floating point, e realizzando quindi su un circuito un moltiplicatore floating point oppure un sommatore floating point. Però anche in questo caso la latenza di questi oggetti, del sommatore o del divisore o del moltiplicatore floating point , sarebbe maggiore della latenza per esempio di un sommatore a virgola fissa oppure di un unità che esegue lo shift di un registro oppure di un unità che opera l’OR tra due registri e via dicendo. Cioè pur avendo realizzato in hardware dei moduli che eseguono il calcolo di operandi in virgola mobile, il tempo necessario a processare questi dati è più ampio del tempo necessario a fare altro tipo di operazioni. Di fronte a questo, il progettista può decidere se allungare il colpo di clock in maniera tale da considerare il colpo di clock sufficientemente ampio da permettere di eseguire l’operazione più lenta appena introdotta. Quindi nel momento in cui bisogna eseguire una divisione floating point, la fase EXE richiede un po’ di tempo maggiore rispetto a quello assegnato al colpo di clock finora. Si può decidere di allungare il colpo di clock. Supponendo che una divisione floating point duri 20 colpi di clock in più rispetto all’operazione di somma in virgola fissa, si dimensiona il colpo di clock a questo valore, la frequenza del clock va 20 volte più lenta, ciascuna fase viene eseguita 20 volte più lentamente. Questo comporta che qual’ora nella fase EXE c’è da eseguire un’operazione fra interi, questa fase si vedrà correre 20 volte più lentamente. Si può però fare in modo di unire più fasi, facendo una under pipeline. Questo semplifica di parecchio l’unità di controllo perché essa dovrà gestire la contemporanea esecuzione di soltanto 3 istruzioni, avendo supposto che due fasi vengano unite in una. Se la pipeline è diventata a 3 stadi, nel processore ci sono solo 3 istruzioni. Quindi dilatando il colpo di clock in maniera tale da contemplare il tempo necessario alla operazione più lenta, che adesso è diventata per esempio la divisione tra due numeri floating point, viene semplificata di molto l’unità di controllo. In questo modo le prestazioni sono 20 volte più basse. Attenzione le prestazioni sono 20 volte più basse anche qualora nel programma non capiterà mai una divisione floating point.
Allora più che dilatare il colpo di clock , una soluzione più attenta alle prestazioni, è quella di considerare una fase EXE un multi ciclo. Il colpo di clock resta inalterato, perché è opportuno che il colpo di clock sia dimensionato sul tempo utile per eseguire le fasi con operazione a virgola fissa. Questo colpo di clock, come detto prima, è troppo piccolo per eseguire per esempio una moltiplicazione floating point. Ebbene 102
l’istruzione che dovrà eseguire una moltiplicazione floating point avrà una fase EXE che durerà un certo numero di cicli. Quindi, alla fine, l’idea non è che decidendo di mantenere il colpo di clock basso come periodo , comunque le operazioni floating point verranno effettuate più velocemente, bensì mantenere un clock come quello considerato fino ad oggi che tuttavia impieghi più battute affinché una fase EXE più lenta venga eseguita, visto che il problema è nato dal fatto che per fare l’operazione floating point si necessita di più tempo. Questa situazione non è semplice da gestire. Significa ora che quando nella pipeline entra un istruzione che è una divisione fra due dati floating point, quell’istruzione esegue le fasi ID e ID e poi comincia la fase di EXE. Nel caso di una divisione floating point, questa fase terminerà dopo 20 colpi di clock, e poi verrà eseguita la fase di MEM e poi la fase di write back. Al colpo di clock successivo parte un istruzione che deve eseguire lo shift del registro R6 di 4 posti, per esempio; questa istruzione eseguirà l’IF , ID , EXE , MEM e WRITE BACK . Cioè questa istruzione sarà terminata mentre la prima istruzione, divisione floating point, starà ancora eseguendo il terzo ciclo di EXE. Si viene a creare un meccanismo che bisogna essere capaci di controllare. Perché nel consentire questa durata diversa delle varie istruzioni , ci si immette nel dover gestire la così detta Terminazione delle istruzioni fuori posto , cioè le istruzioni non termineranno nell’ordine logico previsto dal programma, ma ci sarà un istruzione, che nella logica del programma dovrebbe terminare prima di un'altra, ma che effettivamente, necessitando di maggiore tempo, terminerà successivamente (conflitti di dato). Quando sono stati analizzati i conflitti di dato, considerando istruzioni di uguale durata, tutte eseguivano la fase di write back, quindi andavano a scrivere il loro risultato nel banco dei registri, prima delle istruzioni che venivano dopo di loro. Quindi nel caso ci sia un istruzione che scriva il valore in R1 e la successiva anch’essa scriva un valore in R1, non nascevano problemi in quanto l’ultima istruzione esegue la fase di WB solo dopo la fase di WB della prima, e quindi il valore di R1 al termine delle due istruzioni è quello aggiornato dall’ultima istruzione. Ma con la situazione descritta prima delle terminazioni fuori ordine delle istruzioni , si verificano dei problemi nel momento in cui una divisione deve produrre un risultato, ad esempio F2 (per i floating point si utilizzano i registri F) e un istruzione magari più veloce, la somma ad esempio, produce F2 anch’essa. Essendo la somma più veloce termina prima e quindi andrebbe a scrivere F2 prima della divisione che dal punto di vista logico dovrebbe scriverlo per prima. Successivamente, terminata la divisione verrà scritto il valore di F2 della divisione, e da quel momento in poi in F2 non c’è il risultato dell’istruzione ultima dal punto di vista logico del programma. Esiste una casistica di conflitti di dato che ora diventa un po’ più complessa.: -
RAR (Read After Read) RAW (Read After Write) WAR (Write After Read) WAW (Write After Write)
Il conflitto RAR, che in realtà non è un conflitto di dato, si genera quando un istruzione legge un registro, R1 per esempio, e un'altra istruzione legge anch’essa R1, e la seconda istruzione è più veloce della prima e in sostanza legge il valore di R1 per prima. Se questo succede non crea problemi, per cui questo in realtà non è un conflitto. Questa situazione non è neanche possibile che si verifichi, perché una istruzione legge il registro nella fase di ID, per cui effettivamente nessuna istruzione che deve leggere dopo che la precedente abbia letto andrà a leggere prima un registro. Invece nascono seri problemi quando una istruzione deve leggere un dato dopo che un istruzione precedente lo abbia scritto, RAW. Data un’istruzione che scrive in un registro, R1 per esempio, e un istruzione successiva che deve leggere R1 per calcolare per esempio R2, naturalmente quest’ultima 103
istruzione deve leggere R1 dopo che la prima lo abbia scritto altrimenti legge un valore errato di R1. Allora un conflitto di dato si ha di tipo RAW nel momento in cui l’istruzione che deve leggere un operando dopo che un istruzione precedente lo abbia calcolato o comunque determinato perché lo va a prelevare dalla memoria, ebbene quell’istruzione invece di leggerlo dopo lo legge prima. Questa come visto nei capitoli precedenti si risolve, pensando al processore a virgola fissa, attraverso la cortocircuitazione dell’ALU o stallando il processore in caso di accesso a memoria, cioè se questo R1 è destinazione di una LOAD. WAR è un conflitto che si genera nel momento in cui un deve scrivere in un registro dopo che un istruzione precedente lo abbia letto lo stesso registro. Quindi se un istruzione scrive R1 prima che R1 sia stato letto dalla precedente, e questa quindi legge R1 modificato. Questo problema in realtà non esiste per come è stato strutturato il processore: la prima istruzione leggerà R1 nella fase di ID e la seconda scriverà R1 nella fase di WB. Ebbene la fase di write back è parecchio successiva alla fase di ID di un’istruzione precedente, in realtà quando l’istruzioni precedenti sta leggendo gli operandi , questa al più presto sta eseguendo il fetch e quindi non si verificherà mai che un istruzione scriva un registro prima che la precedente abbia letto quel registro. Quindi questo conflitto non esiste per come è strutturata la pipeline. Infine c’è il conflitto di tipo WAW: due istruzioni scrivono lo stesso registro e naturalmente una istruzione che è quella schedulata dopo scrive il registro prima di quella precedente. Al termine delle due istruzioni quindi il valore del registro è quello scritto dalla prima istruzione, la più lenta, invece che dell’ultima, come vuole la logica del programma. Nel processore a virgola fissa questo tipo di conflitto non esiste, perché un istruzione scrive nella sua fase di write back mentre le precedenti istruzioni sono già terminate e avranno già eseguito la loro fase di WB e quindi scritto nel banco dei registri. Per cui nel processore a virgola fissa l’unico tipo di conflitto che potrebbe generarsi è il conflitto di tipo RAW, risolto con la soluzione che illustrata nelle lezioni precedenti. Con processori a virgola mobile avendo deciso di usare un clock non cadenzato sulla latenza dell’unità funzionale più lenta, perché altrimenti verrebbero rallentate tutte le altre fasi, ma si continua a cadenzare sull’unità che era servita come definizione del clock fino a questo momento, per esempio la memoria, e si introduce per le fasi più lente un meccanismo di multi ciclo si generano questi conflitti. Allungare queste istruzioni , in maniera non regolare, fa si che le fasi di write back di queste sono venute a generarsi, dal punto di vista temporale, oltre le fasi di write back di istruzioni successive, che magari non erano state dilatate. Questi conflitti ora possono esistere e bisogna preoccuparsi di risolverli. In definitiva con un processore floating point, se pure alcune operazioni sono più lente di quelle che viste sino ad ora , si continua a mantenere il clock di un certo tipo e questo porterà però a dilazionare con un meccanismo di multi ciclo l’esecuzione di alcune operazioni più lente. Un ulteriore considerazione che va fatta, quando abbiamo detto che alcune operazioni saranno più lente, è quella di non raggruppare le unità di calcolo floating point in un unico modulo. L’ALU è un modulo che raggruppa al suo interno sommatore, comparatore, divisore e questo non crea problemi. Quando vengono eseguite le istruzioni ad aritmetica fissa, ad esempio la somma tra due registri, non si impegna solo il sommatore, ma si impegna l’ALU intera, visto che l’ALU raggruppa tutte le unità funzionali ad aritmetica fissa. Inoltre al colpo di clock successivo, la prossima istruzione potrà utilizzare l’ALU che sarà disponibile visto che la prima avrà già terminato e quindi sarà stata liberata.
104
Non è possibile ragionare in questi termini anche con l’unità floating point decidendo ad esempio di utilizzare ALU2 monolitica, ovvero l’ALU che contiene il moltiplicatore floating point, il sommatore floating point , divisore floating point in un unico modulo gestita come l’ALU ad aritmetica intera. Questo comporterebbe che appena un istruzione, ad esempio di moltiplicazione floating point, esegue la fase di EXE, impegna ALU2 per 15 cicli; e quando al prossimo colpo di clock, nel programma, c’è una divisione floating, questa vorrà utilizzare ALU2 che è ancora impegnata per l’operazione di moltiplicazione. Quindi l’istruzione di divisione floating point non solo dovrà fare la divisione per 20 colpi di clock ma intanto dovrà attendere N colpi di clock affinché la moltiplicazione precedente liberi ALU2. Allora ovviamente non conviene considerare l’unità floating point organizzata in un unico modulo bensì in diversi moduli separati funzionalmente, in maniera tale che se si sta eseguendo una moltiplicazione e l’istruzione successiva è una somma floating point, si può eseguire liberamente insieme a prodotto anche la somma. Quindi l’altra cosa da tenere a mente per un processore floating point è che le unità di calcolo sono mantenute indipendenti. Ci si potrebbe chiedere perché sono state raggruppate le unità dell’ALU se si potevamo tenere indipendenti anche quelle? In realtà raggruppandole la situazione è più facile da gestire, perché non ci si preoccupa di instradare A e B verso il sommatore, o verso lo shift, o verso il divisore, ma si inviano A e B in un unico ingresso. E’ comodo avere uniti i vari moduli logici che eseguono il calcolo su aritmetica intera perché si semplifica tutto il progetto dell’interfaccia fra il modulo e gli altri moduli adiacenti. Si ha in questo modo solo un uscita che viene inviata eventualmente in MEM o in WB, non si hanno uscite. Quando invece si tengono distinti i moduli in realtà si stanno gestendo dei canali di accesso distinti . E’ opportuno anche replicare alcuni dei moduli perché quando si sta eseguendo un operazione che è la stessa operazione dell’istruzione partita prima, se al precedente non ha terminato, questa operazione verrà stallata. Ad esempio se ci sono due operazioni che effettuano la divisione floating point, la seconda istruzione dovrà comunque attendere che la prima lasci il modulo divisore floating point prima di procedere la sua fase di EXE, avendo supposto che la divisione floating point duri ad esempio venti colpi di clock. Allora si può pensare di replicare alcuni di questi moduli e quali di questi moduli conviene realmente replicare. E’ un discorso come al solito di costi e di prestazioni. Quali moduli andrebbero replicati? Quelli lenti, perché per esempio il sommatore che impiega un numero di colpi di clock abbastanza basso si potrebbe decidere di non replicarlo perché porterebbe ad affermare che un’altra istruzione che vuole anch’essa usare il sommatore dovrà aspettare un numero relativamente basso di colpi di clock, ad esempio due colpi di clock. Oppure porterebbe a dire che magari il compilatore scheduli le istruzioni in maniera tale da non mettere subito dopo un operazione di somma floating point un'altra operazione di somma floating point, inserisce due istruzioni che non utilizzino il sommatore; in maniera tale che quando parte la seconda somma floating point , il sommatore floating point è già libero. I moduli che avrebbe senso replicare in realtà sono i moltiplicatori e i divisori che sono relativamente lenti per cui creerebbero delle attese e un progettista è tentato a replicare. Per quanto riguarda la divisione però l’hardware del divisore è abbastanza complesso, replicando un divisore ci si accorge che nel processore si va a sacrificare una parte di logica non banale. Il vantaggio si ha qualora ci siano due divisioni floating point, ad esempio, schedulate prima di 20 colpi di clock tra di loro, l’ultima invece di aspettare può subito andare sull’altro divisore. Ci si accorge però che nei programmi di calcolo che normalmente vengono eseguiti, le divisioni sono poco frequenti. Tutta una classe di applicazioni scientifiche ha poco a che fare con la divisione , le operazioni scientifiche che vengono utilizzate il più delle volte sono i prodotti e le somme. Le divisioni intervengono per fare una normalizzazione , ma gran parte degli algoritmi scientifici di elaborazione del segnale, statistiche, non hanno molto a che fare con la divisione. La divisione quindi è
105
molto infrequente. Per cui ci si preoccupa, non tanto di migliorare l’efficienza della gestione delle divisioni, soprattutto perché quell’hardware del divisore è parecchio impegnativo, ma quanto quella dei moltiplicatori. Quello che in realtà avviene è che le unità che vengono replicate sono quelle dei moltiplicatori perché rispetto al sommatore che è abbastanza veloce il moltiplicatore non è veloce come un sommatore ma è abbastanza frequente. Allora anche se porterebbe maggiore beneficio replicare un divisore, perché trovare un divisore bloccato comporta un rallentamento non indifferente, si replica il moltiplicatore perché costa di meno che replicare un divisore. Quindi in generale i processori hanno più moltiplicatori e questi saranno anche pipelinizzati. Un’altro modo di procedere sarebbe quello di pipelinizzare questi moduli: riuscire a disegnare il moltiplicatore, che necessita di 10 colpi di clock ad esempio, non in maniera rigida come visto prima, ma in maniera pipeline , dove mentre il prodotto avanza in questo moltiplicatore è possibile inserire altri operandi prima che la moltiplicazione iniziale abbia prodotto il suo risultato. Un po’ come visto nella pipeline del processore, come gli operandi hanno liberato una parte del modulo moltiplicatore floating point, già altri due operandi possono entrare. Quindi in questo modo si possono disegnare degli oggetti di in pipeline evitando quindi di bloccare la latenza per tutta la durata dell’operazione, facendo in modo che l’operazione successiva può essere avviata anche prima che l’operazione precedente sia terminata. L’aggiunta più immediata che andava fatta all’inizio del discorso è quella di un banco di registri specificatamente dedicato ai numeri floating point, registri F. Dal punto di vista elettronico, i registri floating point non hanno differenze rispetto a quelli a virgola fissa. Allora sorge la domanda: perché si usano due banchi di registri e non un unico banco dove inserire registri a virgola fissa e registri a virgola mobile? Immaginando di avere un processore che abbia trenta registri a virgola fissa e trenta registri a virgola mobile; questo processore esegue un programma che usa solo dati a virgola mobile, e se si trova magari a saturare questi trenta registri, è costretto a fare operazioni di SWAP, copiare il valore di un registro in memoria per poi utilizzare quel registro. Con un unico banco utilizzando anche i registri a virgola fissa si evitava lo swap. Allora perché non c’è un unico banco con quanti registri si riescono a gestire? Il problema è dovuto al fatto che ci sono dei collegamenti agli elementi in virgola mobile e dei collegamenti agli elementi in virgola fissa, questi con l’ALU in sostanza. Quindi con N registri , bisogna collegare questi N registri a tutti i moduli floating point e a tutti i moduli a virgola intera, si complica la situazione. E’ quindi più comodo avere una parte di registri che è collegata a ai moduli floating point e un'altra parte che è collegata all’ALU. Inoltre i risultati delle operazioni andranno ai rispettivi registri, quindi i moduli sfloating point saranno collegati ai registri F, perché non con l’unità floating point con due operandi sorgenti floating point il risultato non sarà mai un risultato a virgola fissa. Quindi è come se esistessero due realtà parallele: i dati a virgola mobile che vivranno nel loro mondo, fatti di registri a virgola mobile e da unità di calcolo a virgola mobile e i dati a virgola fissa che vivranno nel loro mondo fatti di registro a virgola intera e ALU. E quindi ci sarà un ulteriore unità che sarà il banco dei registri per i registri F da distinguere con il banco dei registri R. I registri che sono collegati in ingresso e in uscita a queste unità, non sono collegati in maniera banale, perché questi banchi dei registri saranno collegati a queste unità funzionali attraverso stazioni di prenotazione. Terminata la fase di ID, di un istruzione che esegue un operazione di moltiplicazione floating point, come già visto non è detto che ci si può immettere subito nel moltiplicatore, perché potrebbe essere impegnato. Mentre al termine della fase di ID di un istruzione ad aritmetica intera i latch A e B venivano subito fatti entrare nell’ALU direttamente senza alcun problema, visto che l’ALU era sicuramente libera, per accedere all’unità di calcolo in virgola mobile si necessita di un meccanismo che controllerà, supervisionerà
106
questo accesso. Per cui esiste una logica che farà si che gli operandi provenienti dai registri di tipo F accedano ai moduli floating point e questa logica non è banale.
Questo schema esplicita il fatto che la fase somma dura ad esempio 4 colpi di clock, la fase di moltiplicazione 7 colpi di clock e la fase di divisione 20 colpi di clock, inoltre è stato introdotto un meccanismo pipeline. Per tanto questo meccanismo pipeline agevolerà il compito di evitare conflitti strutturali su queste unità. Con il meccanismo pipeline si può iniziare una moltiplicazione, e al colpo di clock successivo iniziarne un’altra, perché questa sarà passata in un altro stadio e via dicendo. Non è stato ancora risolto il problema dovuto alla gestione dei conflitti WAW.
107
Il grafico valuta gli stalli prodotti da alcuni benchmarks SPEC89. E’ un set di benchmark studiati appositamente per valutare le prestazioni floating point e vedere per un determinato processore quanti stalli vengono causati a causa delle somme, sottrazioni , conversioni, confronti , prodotti, divisioni. Come è abbastanza lecito aspettarsi , quello che fa scoppiare il panico sono gli stalli dovuti alle divisioni, sia perché sono le unità più lente e sia perché sono quelle che mai vengono replicate all’interno del processore. Il grafico successivo mostra il numero di stalli effettivi per la pipeline in virgola mobile nell’esecuzione dei benchmarks precedenti.
Affinché l’esecuzione floating point avvenga correttamente e affinchè vengano effettuati una serie di controlli utili a risolvere i conflitti sia di tipo WAW che le terminazioni fuori ciclo, la pipeline è strutturata in maniera un po’ diversa. Fondamentalmente il fetch non avviene in un colpo di clock ma viene diviso così come anche l’accesso alla memoria viene diviso in più fasi. La fase EXE è strutturata con un unico colpo di clock però c’è il riciclo nel caso di una fase EXE multi ciclo qualora l’istruzione è floating point.
108
CAPITOLO 12
GERARCHIA DI MEMORIA 1. Panoramica sulle memorie La memoria viene gestita in realtà con una gerarchia di dispositivi di memorizzazione. Consideriamo il modo di lavorare di un semplice impiegato di ufficio. L’impiegato ha una sua scrivania sulla quale sono disposti i documenti e gli strumenti che sta utilizzando in un certo momento. Se ha bisogno di qualcos’altro lo può prendere dai cassetti della scrivania, piegandosi semplicemente. Potrebbe però aver bisogno di alcuni libri che sono posti nella libreria, l’impiegato deve quindi alzarsi dalla scrivania per andare a prelevare il libro necessario dalla libreria. E’ chiaro che, se ha bisogno di utilizzare con una certa frequenza questo libro appena preso dalla libreria, lo lascia sulla scrivania in modo tale da non alzarsi ogni volta che gli serve. La libreria, però, potrebbe non contenere tutti i libri che necessitano all’impiegato poiché questa ha anche una sua capienza limitata. L’impiegato quindi ha anche a disposizione una biblioteca posta in una stanza e, ogni qualvolta si presenta la necessita di utilizzare un libro non presente in libreria, lo preleva dalla biblioteca che è situata un po’ più lontana. Quindi l’impiegato deve alzarsi e cambiare stanza. Può capitare ancora che ci sia la necessità di utilizzare un documento che si trova in archivio, posto addirittura nel sottoscala. Il calcolatore lavora allo stesso modo dell’impiegato. I dati che il calcolatore utilizza in maniera immediata sono caricati nei registri. C’è una quantità maggiore di dati a cui accede più velocemente e che sono situati nella cache. Un'altra quantità ancora più ampia è posta nella memoria centrale e infine ci sono i dischi e le unità di backup. Questi livelli di memoria: registri, cache, memoria centrale, dischi e unità di backup sono i livelli della gerarchia di memoria o piramide di memoria.
La gerarchia di memoria è un meccanismo che struttura su dispositivi differenti, con caratteristiche differenti, la quantità di informazione richiesta dal processore. Questi dispositivi sono: -
registri posti dentro al processore e sono direttamente accedibili, ma sono in un numero ridotto 109
-
cache (cache interna e cache esterna, oppure cache di 1° o 2° livello) memoria centrale, con dimensione più ampia dischi unità di backup
La dimensione dal punto di vista delle informazione da memorizzare in un livello è più ampia della dimensione di un livello sovrastante. Più ci si allontana dal processore più aumenta la capacità, mentre più si ci si avvicina al processore più aumenta il costo riferito alla capacità, costo del byte. Un chip di RAM di 4GB costa 30€ ad esempio, mentre un Hard Disk di 1 TB costa 80€: il costo del byte è minore per l’hard disk. La velocità aumenta più si è vicini al processore. Questa piramide presenta dei notevoli vantaggi. Il fatto che nei livelli prossimi al processore la capacità è minore comporta una maggiore velocità di accesso a queste aree di memoria. I registri o la cache sono dentro al processore e si può dialogare con una certa frequenza. Quando la memoria è in un chip distinto dal processore, esempio la memoria centrale, comporta che i segnali devono viaggiare da un chip all’altro, e questo presenta problemi fisici, il che comporta a frequenze limite più basse con il quale posso transitare questi dati. Ci sono delle capacità parassite dovute a condensatori. Quando si passa da segnale alto a segnale basso, la commutazione non è immediata. Quando si alimenta un segnale a un certo valore si è caricata la capacità parassita e quando viene spento il segnale, questo non va subito a zero, ma bisogna attendere il tempo di scarica. La frequenza massima utilizzabile è data quindi dalla capacità parassita. Letta una tensione, si associa a un certo livello di tensione alto il bit 1 e a un certo valore di tensione basso il bit 0, considerando sempre una soglia. Il periodo con cui commuta il segnale deve essere ampio rispetto alla costante di tempo di scarica del circuito, poiché può capitare ad esempio che la scarica di un condensatore è così lenta che il segnale non giunge al livello zero poiché c’è già il picco dell’altro livello logico, e in conclusione si legge sempre il bit di valore alto. Quando si passa da un chip a un altro si aggiungono altre capacità parassite che diminuiscono le frequenze di comunicazioni. Dentro lo stesso chip, invece, si viaggia a velocità più sostenute. Quindi è importante notare quando si dice che un certo processore ha un frequenza di un certo numero di clock, e se si vuole aumentare la frequenza del clock, bisogna stare attenti che con la nuova frequenza data all’interno non accadano problemi, poiché come detto prima vengono letti erroneamente i livelli logici dei bit trasmessi. Per velocizzare il trasferimento si può pensare a parallelizzare i bit, attraverso piste parallele si inviano bit in parallelo. Questo funziona bene quando si è all’interno dello stesso chip. Quando bisogna invece dialogare con chip diversi bisogna inserire un certo numero di piedini dovute alle piste. Aumentare la piedinatura di un circuito elettronico crea un problema. Per un doppia word sarebbero necessari ad esempio 64 piedini per i 64 bit. Allora conviene inviare i bit serialmente inserendo quindi un ritardo. Non sempre è opportuna quindi allargare la punta della piramide. Inoltre con registri con dimensioni elevate comporterebbe maggiore informazione e quindi il tempo per il processore per cercare un dato che interessa aumenta.
110
2. Principi generali Affinché il livello funzioni, bisogna garantire dei principi generali che garantiscono il regolare funzionamento: -
-
Ogni livello è trasparente al processore. Il processore non sa che sotto di lui c’è una cache di primo livello o secondo e che poi c’è la memoria centrale e così via. Il processore sa che sotto di lui c’è una sola memoria. Effettivamente il processore da un solo indirizzo di memoria, non chiede il dato presente in memoria centrale o in cache, chiede un dato dalla memoria con un indirizzo. Per il processore, quindi, esistono i registri e la memoria. Per cui i livelli di memoria simulano per i livelli sottostanti il processore e per i livelli sovrastanti invece simulano la memoria. Il meccanismo è che quando il processore necessita di un dato questo lo chiede alla memoria. Questa richiesta è soddisfatta dall’unità di memoria più vicina, la cache. Il dato non transiterà mai dal disco direttamente al processore. Quindi il processore necessita di un dato che lo preleva dalla cache. Se la cache contiene il dato lo manda al processore, ma se nella cache non è presente il dato richiesto, visto che la cache ha una dimensione ridotta, verrà avviato un meccanismo di gestione della memoria, tipico del sistema operativo, che stallerà il processore, si tratta di stallo per mancanza di pagina, e nel frattempo trova quel dato dalle memorie sottostanti e lo restituisce al processore. Se il processore è multi task o multiutente invece di stallare esegue altri task. Un processore multi task esegue più processi in parallelo, cioè per alcuni colpi di clock esegue un certo processo, per altri colpi di clock esegue un altro processo. Quando un certo processo deve stopparsi, per input o output ad esempio, il processore nel frattempo elabora un altro processo. Si tratta di cambio di contesto, i registri utilizzati per il vecchio processo vengono memorizzati in modo tale che questi registri poi vengano utilizzati per il nuovo processo. Il sistema può anche essere multiutente, il processore può lavorare per una fetta di tempo (slice) per un certo utente, e per un'altra slices per un altro utente. I supercomputer hanno processori così potenti che sono usati per accontentare in contemporanea più utenti. Il meccanismo di cambio di contesto è più delicato in questi casi. Quando questi processi in gioco devono fare operazioni di input output o anche gestione di stalli per mancanza di pagina, invece di far stare il processore fermo per alcune frazioni di tempo, il processore lavora su un altro processo. Quando si preleva un istruzione, il processore da il PC alla memoria per ottenere l’istruzione da eseguire. Se la cache non ha l’istruzione di quell’indirizzo in memoria, il processore si stallerà per un certo tempo (ere geologiche per il processore) o eseguirà altri task, e la cache preleverà il dato, l’istruzione in questo caso, dalla memoria centrale, e se questa non ce l’ha dovrà andarlo a prendere dal disco rigido. Ai livelli intermedi ogni livello simulerà quindi per i livelli sottostanti il processore. La cache che chiede il dato alla memoria centrale, si comporta come il processore che chiede un dato e via dicendo finché non è stato trovato il dato. Una volta trovato il dato, per esempio nella memoria centrale, questa lo da alla cache, la quale “sveglia” il processore dallo stallo e consegna il dato. Tutto il meccanismo cerca di realizzare l’helldorado: memoria di ampia capacità e alta velocità. Questi due compromessi non possono coesistere ma si cerca una situazione intermedia. Si cerca di creare un meccanismo che si avvicini il più possibile a questa situazione. Perché altrimenti bastava avere un processore con dei registri e un disco rigido contente tutti i dati. Avere una sola memoria, il disco rigido per esempio, comporta che la lettura avvenga con il tempo necessario del disco rigido. Con la gerarchia di memoria, invece, il tempo è quello della cache. Nel punto precedente però si è detto che se la cache non ha il dato questo deve essere prelevato dalle memorie
111
sottostanti e quindi una perdita di tempo. Però questa perdita di tempo avverrà solo quando il dato verrà chiesto per la prima volta, perché una volta richiesto dalle memorie sottostanti, il dato verrà copiato in cache, e quindi la prossima volta in cui servirà verrà preso direttamente dalla cache velocemente. Per realizzare questa condizione ottimale si sfruttano due fenomeni che non sono dimostrabili matematicamente, ma sono comunque veri dal punto di vista logico: o Principio di località temporale: quando una cella di memoria viene utilizzata, è probabile che presto venga utilizzata di nuovo. Quando un processore chiede un dato all’indirizzo x, è probabile che il dato all’indirizzo x sarà richiesto di nuovo. Basti pensare che se l’indirizzo x, è l’indirizzo di una istruzione all’interno di una iterazione, fin quando non si esce dal loop, quell’istruzione servirà sempre. Oppure se c’è un programma che elabora dei dati, difficilmente il programma elaborerà un dato una sola volta. o Principio di località spaziale: quando una cella di memoria viene utilizzata, le celle adiacenti hanno un alta probabilità di essere utilizzate di li a proco. Basti pensare alle istruzioni di un programma che sono poste in memoria una dopo l’altra proprio per il meccanismo del PC che si auto incrementa; almeno che l’istruzione non sia un salto, sicuramente dopo aver prelevato un istruzione a un certo indirizzo, dopo verrà prelevata quella all’indirizzo successivo. Quindi prelevata l’istruzione a un certo indirizzo, la prossima a essere prelevata sarà sicuramente quella all’indirizzo successivo. Oppure si pensi a una matrice, o un vettore. Nell’ambito di una iterazione verrà processato l’elemento i-esimo, alla prossima iterazione verrà processato l’elemento i+1-esimo. Se in memoria gli elementi dei vettori sono caricati in maniera contigua, elaborato l’elemento all’indirizzo x, l’elemento successivo che verrà elaborato sarà all’indirizzo x+8, se le celle sono di 8 byte per esempio. Queste considerazioni suggeriscono l’organizzazione delle memoria in blocchi (o pagine). Un blocco è una porzione della memoria comprendente un certo numero di celle di memoria contigue. Quando al processore serve il dato all’indirizzo 1000, lo chiede alla memoria cache, e se questa non ha il dato all’indirizzo 1000, lo chiede alla memoria centrale, la quale a sua volta se non ha il dato lo chiede al disco e così via. Questa operazione di carica di un dato nella memoria, ha una certa latenza. Chiesto il dato a un indirizzo alla cache, questa se non ha il dato mette in stallo il processore, reperito il dato dopo un certo tempo lo si fornisce al processore. Questo tempo trascorso è dovuto al tempo in cui il livello n chiede al livello n+1 il dato, invia una richiesta, il livello al suo interno legge il dato e trasferisce in uscita il dato al livello che glielo ha chiesto, nell’ipotesi che questo ha il dato. Questo tempo non è proporzionale alla quantità di dati richiesti. Se invece di chiedere un dato se ne richiedono 50, il tempo necessario non è 50 volte maggiore. In realtà il tempo non è proporzionale alla quantità di dati, solo un certo tempo lo è. Quindi, chiesto il dato x al livello sottostante, il livello sottostante invece di dare solo il dato x, da anche tutto il blocco contente il dato x, perché per il principio di località spaziale, servirà di li a poco il dato x+1 e quindi dandolo adesso successivamente non dovrà chiederlo. Riprendiamo il discorso sul tempo considerando cosa succede in un hard disk. Il tempo di lettura di 1 byte dal disco rigido è identico al tempo di lettura di 100 byte. Il disco rigido è composto da un disco diviso in piste, e il disco è ancora diviso in settori. La parte del sistema operativo gestore dei dischi, associa a un indirizzo il settore e la pista dove il dato è situato. Per prendere il dato quindi bisogna leggere dalla pista n del settore m. La lettura avviene con una testina, costituita da una coppia di magneti, che rileva un campo magnetico che passa sotto di lei (bit 0 spin in un senso, bit 1 spin nell’altro senso). La testina si muove lungo un braccio, che si posiziona sulla pista che contiene il dato. Il disco girando sposta i settori sotto la testina. Il settore cercato, quindi, passa sotto la testina e il dato viene letto. Il tempo di lettura del settore è dato dalla velocità di rotazione 112
del disco e dalla dimensione angolare del settore. Ad esempio, un disco diviso in 10 settori, e con una velocità di 7200 rpm, cioè a significare che in un minuto si fanno 7200 giri, in un secondo si fanno 120 giri, e siccome un settore è 1/10 di giro, in un secondo passano 1200 settori sotto la testina, oppure lo stesso settore passa 120 volte sotto la testina. Un settore viene letto quindi in circa 1/1000 di secondo. Il problema è solo arrivare con la testina sulla pista, tempo di trascinamento, in più bisogna aggiungere il tempo necessaria a far arrivare il settore. Da quel momento in poi passa 1/1000 di secondo per leggere tutto il settore. E’ chiaro che conviene leggere un grosso numero di dati piuttosto che un solo dato, perché qualora servirà il secondo byte di quel settore, nel frattempo la testina si sarà mossa e bisognerà riportare la testina sulla pista e sul settore giusto e quindi perdere nuovamente tempo. Come si vede il tempo non dipende tutto dalla dimensione dei dati, solo una certa parte del tempo. Conviene quindi pagare poco più tempo e inviare più byte. Nella deframmentazione del disco si scrivono i frammenti di file tutti nello stesso settore, e magari nella stessa pista in maniera contigua, in modo che nella lettura di un file la testina non deve spostarsi più di tanto per leggere un file. Perché i file vengono inserirti in maniera disordinata? Perché durante la scrittura magari non si è trovato uno spazio contiguo a contenere quei dati, poiché prima c’era qualcosa che poi è stato cancellato, e allora i dati sono stati frammentati e scritti in settori e piste diverse.
Bisogna ora gestire quattro problemi: 1. Allocazione del blocco: dove è possibile mettere un blocco in un livello superiore quando questi lo carica dal livello inferiore? Quando un livello di memoria chiede un blocco che contiene il dato necessario dal livello inferiore, dove lo posizionerà? 2. Identificazione del blocco: come è possibile trovare un blocco nel livello di memoria? Posizionato il blocco, quando si chiederà un dato di quel blocco, il blocco dove si trova? 3. Sostituzione del blocco: quale blocco deve essere sostituito in caso di fallimento di accesso per fare posto a un blocco referenziato? Supponiamo che un certo livello di memoria prenda un blocco dal livello sottostante. Se questa memoria, quella che chiede il blocco, è piena, dove va messo questo nuovo blocco? Bisogna sostituire un blocco per fare spazio e inserire questo, ma quale blocco va sostituito? 4. Scrittura del blocco: cosa succede nel caso di modifica del contenuto di un blocco? Quando viene aggiornata una variabile, e quindi un blocco viene modificato, cioè una locazione all’interno del blocco viene modificato, cosa succede? Se questa modifica avviene in un blocco della cache, alla copia di quel blocco in memoria centrale cosa succede? Il dato in memoria centrale viene aggiornato o no, cioè resta scaduto?
113
114
CAPITOLO 13 MEMORIA CACHE
I vari livelli della piramide si scambiano dei blocchi. Quando un livello non ha un dato, preleva dal livello sottostante un blocco che lo contiene, con la filosofia per cui dato che bisogna prendere quello che serve, è opportuna anche prendere qualcosa che forse servirà. Ora vediamo le 4 domande. La risposta di queste 4 domande viene data a seguito di una data strategia nel momento in cui io copio un dato nel livello superiore. Queste strategie sono diverse per ogni livello di cui parleremo. Tipicamente quello che faremo nei livelli più alti è diverso da quello che faccio nei livelli più bassi. Avendo parlato del processore, ci interessiamo per quello che riguarda il livello delle MEMORIE CACHE. 1. Cenni storici sulle memorie cache Il termine cache indica qualcosa come se fosse un cassetto. Nel 1965 si comincia a parlare di memorie cache in un articolo di M. Wilkes, in cui si ipotizza questo modello di memoria e si gettano le basi su tutto il sistema piramidale. Wilkes tra l’altro è anche lo scienziato che ha inventato il controllo del processore microprogrammato. L’unità di controllo può essere fatta con due approcci: Unita’ di controllo cablata e micro programmata. L’ultima parte dall’idea di descrivere quello che avviene all’interno del processore in ogni fase come se questo macchinario è a sua volta un processore. Quindi tutto quello che deve avvenire passo per passo avviene in un programma che prende il nome di microprogramma. Nell’abstract di questo articolo si legge: “ Si discute sull’utilizzo di una memoria veloce di 32 K parole, supporto di una memoria centrale più lenta, diciamo di 1M parole, in modo che in pratica il tempo di accesso effettivo sia più vicino a quello della memoria veloce che a quello della memoria lenta” La memoria cache è stata realizzata 3 anni dopo. Grazie a una tecnologia dei diodi tunnel. Le memorie che prima venivano realizzate con nuclei di ferrite possono avere un livello fatto con tecnologia più veloce. All’università di Cambridge realizzarono l’IBM 360.
Il termine cache è spesso abbreviato con il simbolo “$”. 2. Strategie di allocazione dei blocchi Quando si fa riferimento a una istruzione o ad un dato x non presente nella cache è necessario “caricarlo” dai livelli sottostanti . Questo è un discorso valido per ogni livello. Il problema è dove allocare il blocco contenente x? Ci sono tre strategie diverse per risolvere questo problema. Supponiamo di avere una
115
memoria sottostante alla cache di 32 blocchi, e che ogni blocco sia costituito da 64 byte, e di avere una cache con soli 8 blocchi. Parleremo di linee, cioè le aree della cache dove caricare i blocchi. 2.1. Memoria Completamente Associativa Il blocco può essere caricato in qualsiasi linea della cache . Si chiama associativa perché un blocco è associato in maniera completa a tutta la memoria. Non c’è un problema di scelta. Con una cache di questo genere nasce il problema durante la lettura di un dato, in cui non sapendo su quale linea è stato collocato un blocco, si perderà tempo nel cercarlo. 2.2. Indirizzamento Diretto Il blocco può essere associato ad un'unica linea della cache. Questo implica che se la linea a cui è associato un blocco è occupata, il nuovo blocco sovrascrive quello che precedentemente vi risiedeva. E se successivamente serve il dato nel blocco appena sovrascritto bisogna prelevarlo scriverlo al posto di quello caricato ultimamente, i due blocchi si sovrascriveranno a vicenda. Con una gestione di questo tipo la lettura di un blocco è semplice. Il blocco quando è stato caricato potrebbe essere stato copiato solo in una determinata linea. La ricerca sarà molto più veloce. Il problema quindi è dovuto solo alla scrittura e quindi alla conseguente sovrascrittura dei blocchi. La corrispondenza che c’è tra un blocco e la linea nella cache è data dalla relazione:
Quindi i blocchi 16 e 24 andrebbero a stare sulla stessa linea e quindi a sovrascriversi. 2.3. Set Associativa Rappresenta una via di mezzo tra le due strategie appena analizzate. L’idea è questa: poiché l’idea di mettere un blocco su una linea a caso è conveniente a discapito dell’associazione diretta tra un blocco e una linea, però nel cercare un blocco non bisogna guardare tutta la memoria cache, allora si divide la memoria in dei set, ogni set contiene qualche linea, ad esempio 4 set da due linee, e si associa al blocco un set. Quando bisogna caricare un dato della memoria cache lo si va a cercare nelle linee di un determinato set, quello associato all’indirizzo di quel blocco. Il blocco e il set sono associati tramite la relazione:
Quando si carica un blocco nella cache, lo si carica in una linea a caso del determinato set associato a quel blocco. Si vede se è presente una linea libera fra quelle di questo set e lo inserisce li. Se non ci sono linee libere, allora si dovrò sovrascrivere un blocco. Quindi nella ricerca di un dato blocco, non si dovrà cercare in tutte le linee il blocco che contiene quel dato, ma si dovranno guardare i blocchi nelle linee di un set preciso. Una cache set associativa che ha tanti set quante sono le linee, è una memoria cache a indirizzamento diretto; una cache set associativa che ha un solo set è una memoria cache completamente associativa. 3. Strategia di ricerca e identificazione del blocco Come ricercare e identificare la linea della cache che contiene un’istruzione o un dato x? Quando il processore chiede un dato alla cache, questa deve capire se questo dato ce l’ha o meno. E’ chiaro che per
116
vedere se questo dato ce l’ha deve ricondursi a quello che ho fatto quando ha preso il dato dalla memoria centrale e l’ha copiato in cache, per cui dovrà sapere se si tratta di una memoria cache set associativa, associativa diretta o completamente associativa, per sapere appunto su quale linea cercare il blocco con il dato. Bisogna trovare un sistema per capire come si fa a determinare quale blocco c’è in quella linea. Questa domanda implica anche: come si fa a sapere se il blocco contenente x è presente in cache? Si utilizzano le etichette che contengono alcune informazioni. Ad ogni linea della cache è associata un’etichetta che permette di sapere fra l’altro quale blocco è contenuto in esso. Come è fata l’etichetta? Per ora ciò che occorre sapere è che una delle informazioni presenti nell’etichetta è il numero del blocco contenuto in quella linea stessa. Quindi dato un indirizzo, togliendo i bit meno significativi che sono quelli relativi all’indirizzo interno al blocco, se la cache è completamente associativi tutti questi bit identificano l’etichetta e quindi la linea; se la cache è a indirizzamento diretto un certo numero di bit meno significativi individuano la linea e i restanti più significativi l’etichetta; se la cache è set associativa alcuni bit meno significativi individuano il set e i restanti la linea in quel set. Consideriamo l’indirizzo x = (001110…)2 I puntini indicano i bit che identificano il dato nel blocco. Se il blocco contiene solo 4 byte , al posto dei puntini ci sono 2 bit. Questi servono per la ricerca del dato nel blocco Per una memoria completamente associativa, l’etichetta è tutto 001110. Questi bit individuano il blocco all’interno della cache. Per una memoria a indirizzamento diretto a 8 linee, i 3 bit meno significativi individuano la linea, quindi 110 identificano la linea in cui è contenuto il blocco. L’etichetta, quindi il numero del blocco è dato dai restanti bit 001. Per una memoria set associativa, con 2 set e 4 linee, i due bit meno significativi 10 individuano il set, mentre i restanti bit individuano l’etichetta, quindi la linea in sostanza il blocco all’interno del set. Questo significa che dovrò cercare nelle etichette. Mentre prima risultava attraente questa soluzione , perché ti dava la possibilità di avere 8 chance, quando mi preoccupo di cercare un dato la completamente associativa mi implica la necessità di cercare in tutte queste etichette se per caso io ho a che fare con un etichetta uguale a questa. 4. Sostituzione di un blocco Consideriamo il caso in cui la cache carica dalla memoria sottostante un blocco, però questa è satura se stiamo parlando di una memoria completamente associativa, oppure l’unica linea disponibile per quel blocco è occupata se ci stiamo riferendo a una cache a indirizzamento diretto, o tutte le linee del set associato a quel blocco sono piene nel caso di una cache set associativa, quale blocco deve essere sostituito dal nuovo? Questa domanda ne richiede anche un’altra prima: come si fa a sapere se una linea è libera o meno? Per sapere se un certo blocco di cache è libero o meno si usa un bit aggiuntivo nell’etichetta: VALIDITY BIT. Il validity bit vale 1 se il blocco contenuto nella linea è valido, se invece la linea è vuota vale 0, intendendo
117
che questa linea non è valida e quindi è vuota. Quindi l’etichetta oltre a contenere il numero del blocco ha anche un bit per indicare se quello che c’è scritto sulla linea è valido o meno. Ritorniamo alla sostituzione di un blocco. Se la cache è a indirizzamento diretto, il blocco da sostituire è solo uno, non bisogna scegliere tra diversi blocchi. Un blocco deve andare in quella unica linea che nel caso contenga un altro blocco, questo dovrà essere sostituito. Per una cache completamente associativa o set associativa invece si procede attraverso delle strategie di sostituzione: -
-
RANDOM: viene scelto il blocco a caso LRU (Least Recently Used): viene sovrascritto il blocco meno recentemente usato. Se è vero che vale il principio di località spaziale e temporale è preferibile sostituire un blocco che non viene utilizzato da diverso tempo piuttosto che sostituire un blocco che probabilmente dovrà essere riutilizzato di li a poco. Ci vogliono delle risorse hardware per realizzare questo. Una possibile realizzazione potrebbe essere quella di inserire un ulteriore campo nell’etichetta, un campo contatore che ad ogni accesso alla cache che non vede protagonista quel blocco incrementi il valore del suo campo contatore, qualora venisse utilizzato invece si resetti il contatore. FIFO: è una semplificazione dell’LRU. Tiene traccia dei blocchi vengono utilizzati di volta in volta. Questo meccanismo è fatto in maniera molto semplice: nella memoria cache c’è uno shift register, una cella collegata ad un'altra e via dicendo, in cui un flusso avviene come in una pipeline, questo shift register ha una sua lunghezza, quando si accede a un blocco, faccio entrare a uno shift register, l’identificativo di quella linea. Il primo ad essere entrato è il primo a uscire.
5. Scrittura e lettura di un blocco Le scritture sono meno frequenti delle letture per cui prima di occuparci delle scritture dobbiamo soffermarci sulle letture che sono il tipo di accesso più frequente. Il fatto stesso che letture sono l’operazione più frequente comporta una gestione del meccanismo di lettura il più veloce possibile: vengono letti in parallelo tutte le parole di ogni blocco, e contemporaneamente avviene la lettura dell’etichetta, si verifica se il blocco nella linea coincide con il blocco cercato. In questo modo si risparmia tempo, perché se l'etichetta coincide, il blocco è stato già letto, altrimenti se non coincide non si è fatto nulla di dannoso. In questo modo si ottimizza il processo perché contemporaneamente si identifica la linea, si legge la parola che si trova in quel blocco a quell'indirizzo e durante la lettura della parola avviene il controllo dell'etichetta per verificare che quello che si sta leggendo coincide con quello che si sta effettivamente cercando. Quindi la lettura di un blocco dalla cache avviene in contemporanea alla lettura e al confronto della sua etichetta, in questo modo la lettura inizia non appena è disponibile l’indirizzo del blocco. Questo meccanismo non è attuabile nel caso di una scrittura. La scrittura di un blocco non può cominciare prima che sia terminata la verifica della sua etichetta. Considerando il procedimento prima descritto, avviene la scrittura del dato e la verifica dell’etichetta in contemporanea, e se l'etichetta è diversa da quella desiderata, è stato scritto un dato all'indirizzo sbagliato, facendo in questo caso qualcosa di dannoso. Quindi è necessario prima identificare l’etichetta corretta e poi procedere con la scrittura. Per fortuna questo ritardo dovuto alle scritture non è molto frequente visto che le scritture sono più rare delle letture.
118
Quindi bisogna valutare l'etichetta prima di scrivere. Se l'etichetta non coincide bisogna caricare il blocco dalla memoria sottostante. A prescindere dal fatto di vedere se il blocco è presente o meno, bisogna considerare anche un altro aspetto: quando si aggiorna un dato nella memoria cache, considerando che quello che sta nella cache proviene dai livelli di memoria sottostanti, ci si imbatte in una situazione anomala perché il dato avrà un valore nella cache e un altro valore nei livelli sottostanti, poiché ad esempio in memoria centrale non è stato aggiornato il dato. In alcune circostanze questo potrebbe non essere un problema, perché quando il processore avrà bisogno del dato comunque lo andrà a prendere dalla cache, dove il dato è effettivamente aggiornato. I problemi sorgono quando il processore ha aggiornato il dato nella cache ma ad un certo punto, richiede un nuovo blocco e questo blocco va a scriversi nella memoria cache sovrascrivendo il dato precedente. A questo punto l'aggiornamento che il processore aveva fatto nella cache è stato completamente perso, e la prossima volta che il processore richiederà quel dato dalla cache otterrà un dato scaduto e non più quello aggiornato. A questo punto bisogna gestire questo problema. Quindi prima di sovrascrivere un dato bisogna verificare se questo è stato modificato all’interno della cache, e nel caso sia stato modificato non bisogna scriverci subito sopra, ma va scritto prima sulla memoria centrale, sovrascrivendo la sua vecchia versione in memoria centrale, e poi sovrascritto in cache. Bisogna innanzitutto capire se un blocco in cache è stato scritto o meno dal processore. Ci aiuta in questo l'etichetta che contiene il bit di modifica che a seconda dell’architettura del processore prende il nome di change bit, clean bit o dirty bit. I bit di modifica change bit e dirty bit con il valore 1 indicano che quel blocco è stato “sporcato” e quindi modificato, viceversa con 0 indicano che quel blocco non è stato toccato; per il clean bit, invece, con il bit 0 si indica che il blocco è stato modificato. Ci sono però dei sistemi che lavorano con più processori in parallelo. Consideriamo il caso in cui un processore copia un blocco nella sua memoria cache da una memoria centrale comune a tutti i processori, e modifica questo dato nella sua cache. Se un secondo processore preleva successivamente lo stesso dato dalla memoria centrale dopo che il primo ha modificato quel dato nella sua cache, il secondo prenderà un dato “scaduto”. Quindi se fino ad ora ci siamo preoccupati che un processore abbia nella sua memoria cache dei dati coerenti attraverso il clean bit, ora bisogna gestire un sistema che oltre a scrivere in cache scrive anche in memoria centrale ad ogni aggiornamento, in modo da avere una coerenza di dati nella memoria centrale. Per risolvere questi problemi ci sono due politiche si scrittura: Write back e Write through. La modalità write back scrive in memoria centrale il dato aggiornato dalla cache solo quando si sta per sovrascrivere quel dato con un altro blocco. Nel momento della sostituzione di un blocco nella cache, se questo ha il dirty bit settato al valore 1, ad indicare che un dato in quel blocco è stato modificato, allora questo viene copiato in memoria centrale e poi sovrascritto. Write through invece attraverso la memoria cache scrive anche in memoria centrale. Quando si aggiorna un dato nella cache, questo viene contemporaneamente sia aggiornato in cache e sia nella memoria centrale, a differenza della write back che necessita, prima di scrivere in memoria centrale, che il blocco venga effettivamente sovrascritto in cache. La seconda alternativa serve in sistemi multiprocessore o in sistemi che gestiscono l'input/output sulla memoria e devono prevedere la possibilità di accedere ai dati della memoria sempre aggiornati. Ci sono 119
però altre complicazione ad esempio se la CPU1 prende il dato x e la CPU2 prende anch’essa il dato x; se CPU1 varia x, nella cache di CPU2 c'è il vecchio x e non quello aggiornato.[cit. “Fatti mandare dalla mamaaaaaaaa a prendere il latteeeeeeeee”(latte scaduto)]. La CPU, anche se la logica è di write through, non scrive in memoria centrale, sarà sempre la cache a scrivere nella memoria centrale mantenendola sempre aggiornata. Una write back dal punto di vista del traffico in memoria centrale è molto più efficiente, perchè il dato viene aggiornato solo prima di venire sovrascritto, facendo 50 modifiche nella cache, solo una arriverà alla memoria centrale a differenza della write trough. Altro aspetto è che la write back implica la scrittura in memoria di un blocco, mentre nella write through viene scritto solo il dato e non tutto il blocco, senza l'etichetta praticamente. Quest'ultimo procedimento ovviamente potrebbe non essere conveniente, però si possono strutturare le memorie non con un solo change bit per blocco, ma con tanti change bit quante sono le parole del blocco, così che quando bisogna sovrascrivere un blocco in memoria centrale si effettua la copia solo delle parole del blocco il cui change bit è uguale a 1. Con questo approccio abbatto il traffico in memoria però pago la gestione di questa logica, adesso infatti i bit da controllare sono molti di più. Nella write back la velocità di scrittura è quella della cache e le scritture multiple richiedono comunque una sola scrittura nella memoria sottostante. Questo significa che a parità di banda di memoria centrale, con una write back si possono avere più processori collegati alla memoria centrale, però come detto prima la write back trova problemi in logiche multiprocessore, però si può ovviare il problema strutturando un algoritmo parallelo in modo tale che gli stessi processori operano sullo stesso processo ma dividendosi rigorosamente i dati sui quali lavorano. In questo modo se un processore modifica un determinato dato, gli altri processori non hanno il problema di acquisire dati scaduti, poiché non operano su quel dato, ma su altri dati, un esempio è il filtraggio di un immagine durante il quale l'immagine viene divisa in tante parti ognuna di queste viene affidata ad un processore. Write through invece ha come vantaggio che avendo fatto in linea l'aggiornamento della memoria centrale, non bisogna preoccuparsi di aggiornare la memoria centrale quando si prende un blocco che si deve sovrascrivere a quello in cache,tanto sia che sia stato modificato sia che non sia stato modificato, comunque è stato riscritto nella memoria centrale, questo ovviamente è un vantaggio per i sistemi multiprocessore o per i sistemi I/O sui dati mentre questi sono in elaborazione. Ora vediamo però come migliorare la scrittura per quanto riguarda il tempo che ci vuole per eseguirla, ad esempio per una write through è importantissimo ridurre il tempo di scrittura. Quindi non sarà il processore a scrivere in memoria centrale, il processore scrive in cache e una circuiteria della cache si preoccupa di scrivere in memoria centrale, così la scrittura arriva alla velocità della cache e una parte della cache gestirà i rapporti con la memoria centrale. Si utilizza un meccanismo che prevede l’utilizzo di un buffer. Il buffer di scrittura è una memoria di tipo FIFO, in cui quando il processore scrive sulla cache scrive anche in questo buffer, perché non è detto che durante la scrittura sulla cache, questa può trasferire il dato immediatamente sulla memoria centrale. Ad esempio se nella cache scriviamo tanti dati consecutivi, la cache non può scrivere i dati nella memoria centrale alla stessa velocità, con la stessa frequenza, anche perché il processore scrive un dato a colpo di clock che è troppo veloce per la memoria cache. Quindi si utilizza il buffer per immagazzinare temporaneamente le modifiche e poi quando la cache ha tempo svuota il buffer in memoria centrale. La memoria centrale però darà priorità alla cache che vuole leggere dei dati, 120
non è che per svuotare il buffer si fermano le letture, tranne in casi di emergenza, ad esempio se il buffer è pieno e si rischia di perdere i dati successivi che dovrebbero essere scritti nel buffer. Il buffer è semplicemente una coda che ha oltre allo spazio necessario a contenere i dati, ha dello spazio dedicato alle etichette, perché bisogna sapere dove deve andare il dato che sta nel Buffer, e quindi servono gli indirizzi. Nel buffer succede che quando dalla cache arriva un nuovo dato da accodare, e questo è una copia più aggiornata dello stesso dato che è ancora nel buffer, e in questo ci aiuta l'etichetta, stesso dato stessa etichetta, viene rimosso dal buffer quello meno aggiornato, perché non sarebbe opportuno scrivere in memoria una versione di x che verrà cambiata poco dopo.
121
122
CAPITOLO 14
PRESTAZIONI DELLA CACHE In questo capitolo studieremo i fallimenti che si hanno per vari tipi di cache sia come dimensione, sia come grado di associatività, 1 via, 2 vie e così via, considereremo cache set associative a 2 vie etc. Data una dimensione ad esempio di 1kB, il miss rate totale, cioè numero di fallimenti totale, diminuisce aumentando il numero di vie, cioè il grado di associatività che è dato dal numero di blocchi all'interno di un set. Fissata la dimensione e raggruppati i blocchi in un certo numero di set, ad esempio raggruppandoli ad indirizzamento diretto, il che vuol dire 1 via, c’é 19% di miss rate, aumentando i blocchi la percentuale scende. Quindi aumentando le vie, diminuisce il miss rate. Il miss rate diminuisce anche aumentando la dimensione della cache a parità di vie. E' interessante capire da cosa sono costituiti questi miss rate. Si classificano le cause che portano ai fallimenti in tre categorie: classificazione “delle tre C”. Ci sono miss rate “obbligatori” (compulsory), “di capacità”(capacity) e “di conflitto”(conflict). I fallimenti obbligatori si riferiscono alla richiesta di un dato non ancora presente in memoria, e sono indipendenti dalla dimensione della cache e dal numero di vie. La dimensione della cache, come anche le vie, non influiscono su questo tipo di fallimento. I fallimenti di capacità, si ottengono quando la cache non può contenere tutti i blocchi necessari durante l’esecuzione di un programma, si parla di mancanza di capacità. Il numero di fallimenti diminuisce con l’aumentare della dimensione della cache e aumenta con l’aumentare dell’associatività. Gli errori di conflitto si hanno quando bisogna necessariamente sostituire dei blocchi nella cache perché l'indirizzamento punta a blocchi già occupati, nonostante nella cache ci sia spazio libero. Questi fallimenti diminuiscono all'aumentare delle vie. Ricapitolando: i fallimenti obbligatori dipendono solo dal programma; quelli di capacità dipendono dal programma e dalla dimensione della cache; quelli su cui c’è più “libertà” sono quelli per conflitto poiché all'aumentare dell'associatività si riduce il numero dei fallimenti, non è necessario arrivare all'associatività assoluta, in genere basta arrivare a 8 vie. 1. Analisi delle prestazioni della cache Le cache di sole istruzioni e soli dati sono praticamente necessari perché su due circuiti diversi si sistemano dati e istruzioni, che sono di tipo diverso, permettendo di accedervi contemporaneamente. Dividendo anche le cache in questo modo, si permette di progettare queste memorie in modo ottimizzato per l'accesso alle istruzioni e l'accesso ai dati. Perché l'accesso alle istruzioni è leggermente diverso dall'accesso ai dati, dal punto di vista statistico. Posso per esempio pensare di dare un grado di associatività maggiore ad una cache di dati, dove è un po' meno valido il principio di località spaziale e temporale, a differenza della memoria delle istruzioni dove questo principio è più affidabile. Siccome una cache con grado di associatività maggiore è più costosa di una con associatività minore, posso pensare di progettarle in modo diverso. Anche perché queste cache sono integrate nel processore. Attualmente i processori hanno 2 livelli di cache 123
interni, 2 punte della piramide. Strutturando le 2 memorie separatamente, a seconda della dimensione, si ha un miss rate più basso! Ovviamente il miss rate sulla cache delle istruzioni è più basso di quello dei dati. Adesso facciamo alcune considerazioni sulle prestazioni. Il modello di valutazione di una prestazione di cache è legato a tutto quello che avviene nel sistema, non interessa avere un sistema in cui la cache fallisce allo 0%, quello che più interessa è il tempo in cui viene eseguito un processo. A parità di condizioni, il tempo CPU è fortemente legato, non solo al tempo in cui la CPU esegue un istruzione ma anche al tempo di accesso/scrittura/lettura ecc.
I cicli di CPU sono quei colpi di clock che contiamo quando abbiamo le istruzioni, contando gli stalli legati alla gestione dei dati(load/store ecc.) Nel diagramma non abbiamo mai messo i fallimenti legati alla memoria, perchè se il dato non è in memoria il colpo di clock slitta, perchè alcuni colpi di clock andranno a vuoto per permettere il caricamento dalla memoria centrale alla cache. Questi cicli si possono quantificare con la percentuale di miss rate, che va moltiplicata per le letture del programma, di solito si ha il miss rate per le scritture e il miss rate per le letture. Il miss rate delle scritture è solo il miss rate scrittura dati, perché di solito è più alto di quello delle letture. Considera che il missrate di lettura si divide in miss rate lettura dati e miss rate lettura istruzioni (N.B. Per il missrate scrittura, manca ovviamente il missrate scrittura istruzioni), il miss rate sui dati è più importante del miss rate sulle istruzioni. Supponiamo ora di avere un miss rate di 3/100, ad indicare che se il programma accede in memoria 100 volte, si ottengono 3 fallimenti! Quanti cicli durano quei 3 fallimenti? Vanno moltiplicati per le penalizzazioni di fallimento di lettura/scrittura.
La penalizzazione è il tempo sprecato ogni volta che si ottiene un fallimento di memoria. Quanto dura questa penalità di fallimento? Dipende, perchè bisogna capire che se la cache non trova il dato, lo va a prendere dalla memoria centrale, il tempo perso a fare questa operazione dipende dalla memoria centrale a questo punto. Il problema è che questa penalità non è facile da calcolare, perchè il dato potrebbe anche non essere nella memoria centrale, in questo caso il tempo di penalità diventa diverso, la formula che permette di calcolare il tempo di penalità è quindi riconducibile a questo ragionamento. MissRate dell'hard disk*(colpi di clock hard disk -memoria centrale) + Missrate memoria centrale*(colpi di clock memoriacentrale-cache). Quanto vale il numero di colpi di clock dall'hard disk alla memoria centrale? Non è facile da calcolare perchè il tempo di accesso all'hard disk non è noto, dipende da dove si trova la testina, su quale pista si trova il dato.
124
2. Cache dal punto di vista tecnologico Vediamo ora dal punto di vista tecnologico come è fatta una memoria. Abbiamo un esempio di memoria dinamica e una statica. 2.1. Memoria statica SRAM C’è una linea sulla quale si legge un bit (BL), e una sulla quale si legge lo stesso bit negato (BL negato),e infine c’è la linea di parola(word line, WL) che porta il valore in uscita. La cella statica ha un vantaggio, fin quando c’è alimentazione è in grado di mantenere le informazioni per un tempo teoricamente infinto. Questo tipo di cella è utilizzato per le memoria cache, poiché fornisce elevate velocità anche se occupa più spazio. La sua realizzazione richiede 6 transistor per un singolo bit. 2.2. Memoria dinamica DRAM In quella dinamica al secondo capo c’è un condensatore, se il bit è 0 il condensatore è scarico, altrimenti è carico. Questa cella però non è autoalimentata perché il condensatore si scarica, da cui il nome “dinamica” perché nel tempo varia il suo valore. Il condensatore che pian piano si scarica comporta che l’informazione verrà mantenuta solo per un certo periodo, il periodo di scarica del condensatore. Affinché la cella dinamica sia utile è necessario che possa fare un'operazione di refresh. Si legge tutto quello che sta scritto sulla riga, se c’è 0 non si esegue nulla, se c’è il valore 1, questo viene riscritto nella riga azzerando il tempo di scarica del condensatore(refresh). Allora si può organizzare un meccanismo in cui, letta una riga la si riscrive, letta l'altra la si riscrive ecc. Si utilizza la cella dinamica perché dal punto di vista dei componenti richiede solo un condensatore ed un transistor per bit, a differenza di quella statica che ne richiede 6 per bit. Quindi il sistema è realizzato con solo un condensatore ed un transistor, però il refresh rallenta il circuito. Quando si effettua il refresh la memoria non può essere letta/scritta. Tipicamente, il dispositivo statico occupa più spazio ma è più veloce, viene utilizzato per realizzare una memoria cache, mentre se c’è la necessità di più capienza, si punta su dispositivi di tipo dinamico.
3. Dimensionamento della memoria dal punto di vista del tempo Adesso cerchiamo di dimensionare una memoria cache dal punto di vista del tempo di accesso e quindi di gestire il progetto in termini di dimensioni dei blocchi e numero di blocchi. E' intuitivo pensare che più grande è il blocco maggiore è la penalizzazione di fallimento, perché se voglio 125
sapere quanto ci vuole per prendere il blocco e copiarlo dalla memoria centrale alla cache, una volta che ho indirizzato la memoria centrale devo perdere più tempo quanto più grande è la dimensione del blocco, quindi un tempo crescente che non passa da 0, perché un minimo lo si deve pagare in termini di tempo anche se la dimensione del blocco è piccolissima. Vediamo ora il missrate come si comporta in funzione della dimensione del blocco. Aumentando la dimensione del blocco il miss rate diminuisce bruscamente. Perché aumentando la dimensione del blocco è possibile caricare più cose nella cache e quindi fallire meno volte. Sembrerebbe quindi che aumentando la dimensione del blocco il miss rate diminuisca. Però aumentando ancora la dimensione del blocco, il numero di blocchi che è possibile caricare nella cache si riduce, la cache sta ospitando blocchi troppo grossi, e quindi caricando l'altro blocco si va a sovrascrivere un blocco che scritto prima, in seguito il blocco sovrascritto che servirà non essendo più in cache genererà un fallimento. La dimensione del blocco ad esempio potrebbe cadere intorno ai 17.3 byte, ma non si può fare un blocco di questa dimensione, perché innanzitutto la dimensione deve essere di byte interi e poi questi byte devono anche essere un multiplo di parole. Se ad esempio ci sono parole di 4 byte bisogna scegliere la dimensione del blocco di 16 byte o di 20 byte, ma è consigliabile anche che i byte siano multipli di potenze di due per quanto riguarda la suddivisione dell’indirizzo nelle parti che individuano etichetta, set ecc. Compromesso dimensione blocco non troppo piccola per non caricare di volta in volta un byte ma neanche troppo grande per evitare che dopo la carica di due blocchi la memoria diventi già satura. -
Aumentando l’associatività diminuiscono i conflitti però aumenta il costo perché aumenta il numero dei blocchi indirizzabili e quindi aumenta il numero delle etichette. Le etichette diventato più lunghe, e aumenta il numero di comparatori, bisogna cercare tra le possibili etichette il contenuto uguale a quello desiderato. La ricerca va fatta in simultaneo e quindi tutti i blocchi vanno letti contemporaneamente. La politica di sostituzione è più costosa. Se la cache è molto grande non ci sono grandi benefici.
-
Maggiore dimensione di blocchi, aumenta il tempo di trasferimento. Però in una memoria a dimensione stabilita il numero dei blocchi diventa di meno e il numero delle etichette diminuisce e diminuisce anche la lunghezza dell’etichetta.
-
Politica LRU: l’hardware ha un costo maggiore, diminuisce il miss rate. La politica LRU ha senso per cache a basso livello di associatività.
-
Buffer di scrittura: maggior costo hardware (il buffer è hardware) e diminuisce lo stallo di scrittura. Aumento il buffer di scrittura la cache si occupa di scrivere in memoria centrale e gli stalli diminuiscono.
126
CAPITOLO 15
STRUTTURA ELETTRONICA DELLA CACHE
Analizziamo il processore Motorola 68040, in cui cache dati e la cache istruzioni sono ovviamente separate ed entrambe hanno la stessa dimensione di 4KB. La cache è set associative a 4 vie, e ogni blocco comprende 4 parole da 4 byte. Un blocco ha dimensione pari a 4parole *4byte = 16byte. In un set ci sono 4 blocchi(4 vie) per un totale di 64byte per set. Ci sono dunque 4KB/64byte = 64 set. Dal punto di vista della gestione delle etichette esiste un bit di validità per ogni blocco. Quando si scrive un blocco in un set, si analizza il bit di validità, se questo è zero il blocco è vuoto. Se tutti e 4 i blocchi hanno bit di validità 0 sono tutti e 4 liberi. Il change bit invece è uno per ogni parola del blocco. Quando si lavora in write back (questa memoria lavora sia in write back che in write through) e bisogna sovrascrivere un blocco, prima di farlo bisogna verificare se il bit di modifica è pari a 1 o 0. Se vale 1 allora si trascrive nella memoria centrale il valore della cache, altrimenti si sovrascrive il blocco nella cache senza aggiornare nella memoria il valore, visto che la cache e la memoria centrale contengono lo stesso dato, esiste una coerenza tra la cache e la memoria centrale. Per semplificare l’algoritmo di scrittura quindi si può sovrascrivere un blocco che non è stato modificato: se c’è un set pieno e ci sono 3 blocchi modificati e uno no, si può decidere di sovrascrivere quello non modificato così non bisogna aggiornare la memoria centrale. Il sistema operativo configura la cache a lavorare sia in modalità write back e sia in write through. La memoria si progetta a partire dall’indirizzo. Un indirizzo è costituito da 32 bit. Questo non dipende dalla cache, se dipendesse dalla cache sarebbero necessari 12 bit, 4KB = 212. Il processore vede però una memoria di 32 bit, indirizza 4GB = 232. Per il processore esiste una sola memoria di 4GB. Il processore dunque invia questo indirizzo di 32 bit, che può essere un istruzione o un dato. Il blocco è di 4 parole di 4 byte, per un totale di 16 byte. Quindi per indirizzare una parola all’interno del blocco sono necessari 4 bit (24 = 16). Questi sono i 4 bit meno significativo dell’indirizzo. Di questi 4 bit gli ultimi due sono uguali a zero visto che le parole sono di 4 byte (multipli di 4). Dei rimanenti bit, gli n meno significativi servono per indicare il set. Poiché ci sono 64 set, sono necessari 6 bit (log264=6). I restanti 22 bit indicano l’etichetta.
La cache riceve dal processore l’indirizzo, che spacchetta a partire dal bit meno significativo: i 4 bit meno significativi indirizzano la parola nel blocco. Ma quale blocco? Quello che si trova nel set indirizzato dagli
127
altri 6 bit meno significativi. Ma quale blocco nel set, visto che ce ne sono 4? Quello con l’etichetta uguale al campo etichetta dell’indirizzo. Bisogna però verificare che il blocco abbia il bit di validità uguale a 1. 1. Analisi elettronica della cache Il processore invia l’indirizzo alla porta ingressi della cache il quale viene copiato in un buffer. Gli ultimi 4 bit dell’indirizzo sono inviati alle porte indirizzi di tutti i blocchi. In parallelo tutti i blocchi di tutti i set ottengono questo indirizzo sulla loro porta indirizzi. Se si sta effettuando un operazione di lettura, alla porta di controllo della memoria arriva il segnale di lettura (R/W), tutti i blocchi cercano la parola dato l’indirizzo che hanno ricevuto alla loro porta indirizzi. Quindi tutti questi blocchi in simultaneo si attivano per decodificare e indirizzare la parola a partire dall’indirizzo dato. Mentre i blocchi decodificano, avviene l’individuazione del set a partire dai restanti 6 bit del campo set dell’indirizzo. Ora solo le etichette dei 4 blocchi di quel set appena individuato verranno mandati nei comparatori. Ci sono 4 comparatori, ognuno per ogni etichetta, che ricevono ad una porta l’etichetta di un blocco, all’altra porta i 22 bit dell’indirizzo che identificano l’etichetta. L’uscita del comparatore va in ingresso alla porta AND insieme al bit di validità del blocco. Solo se la porta AND restituisce 1 allora abbiamo determinato il blocco. In questo caso quindi solo una porta AND determinerà un valore pari a 1, e questo verrà retro azionato ad attivare la lettura del blocco del set associato, e quindi di tutti i blocchi che si erano predisposti ad inviare la parola, solo uno, questo identificato, la invierà al processore. Se dall’uscita delle porte AND non arriva nessun valore 1 si è determinato il page-fault della cache. In quest’ultimo caso si stalla il processore e si cerca il blocco dalla memoria sottostante. Lo stesso vale nella fase di scrittura. Il dato arriva sulla porta dati, però soltanto un blocco è abilitato a ricevere il dato da scrivere. Una memoria un po’ diversa è quella del Power-PC, ha una cache di 16KB, le parole nel blocco sono 8, e il numero del set è raddoppiato. L’associatività resta a 4 via e anche la dimensione delle parole resta la stessa. Cambia la filosofia di gestione del validity bit e change bit: ci sono 2 bit aggregati per blocco, questi due sono una parola di stato(macchina a stato). E’ un sistema con 4 possibili configurazioni, 2 stati rappresentato il change bit e il validity bit, gli altri 2 stati per gestire situazioni di condivisione dati con altre cache dedicati ad altri processori. Questa memoria non usa un bit per ogni parola perché essendo il blocco grande il doppio rispetto all’altra memoria mettere 8 bit di modifica diventa più fastidioso. Con questa memoria cambia la formattazione dell’indirizzo: i 5 bit meno significativi vengono utilizzati per indirizzare la parola nel blocco(essendo un blocco di 8 parole da 4byte,23*22 = 25e gli ultimi due bit di questi valgono zero) . Dei restanti, i meno significativi indirizzano il set, essendoci 128 set, sono necessari 7 bit. L’etichetta è composta da 20 bit. Vediamo cosa succede dal punto di vista elettronico nella cache quando il processore invia un indirizzo alla cache. Bisogna innanzitutto individuare il set, e comparare le etichette dei blocchi di quel set con il valore che la cache ha ricevuto dal processore, che lei ha staccato dall’indirizzo per la ricerca della presenza o meno del blocco in cache. Avendo, ad esempio, blocchi di 16 byte e 32 set (25 set), l’indirizzo è composto da 4 di bit per indirizzare la parola nel blocco, 5 bit per indirizzare i set e i restanti 23 per l’etichetta.
128
Tutte le etichette di tutti i blocchi di tutti i set devono essere collegati ai quattro comparatori. Le quattro etichette di un set devono essere tutte collegate ad un comparatore diverso. Quindi ad un comparatore sono collegate tante etichette quanti sono i set, tutte etichette di set diversi. Quindi possiamo supporre che al primo comparatore siano collegati tutti i blocchi 0 di tutti i set, all’altro comparatore tutti i blocchi 1, all’altro comparatore tutti i blocchi 3 e all’ultimo comparatore tutti i blocchi 4. Ad ogni comparatore dunque sono collegati 32 etichette, ma solo una di questa potrà entrare nel comparatore. In particolare tutte le etichette arrivano in un multiplexer posto davanti all’ingresso del comparatore, e solo una di queste etichette andrà in ingresso effettivamente al comparatore. Questo mux necessita di un ulteriore ingresso, il selettore che indica quale dei 32 ingressi dovrà uscire. Il selettore è quindi composto da log232 = 5 bit, proprio quanti sono i bit dell’indirizzo necessari a indirizzare il set. Quindi il selettore è rappresentato proprio dal campo set dell’indirizzo, e indica quale etichetta inviare nel comparatore. All’altro ingresso del comparatore c’è l’etichetta dell’indirizzo che va in parallelo all’ingresso di tutti i comparatori. L’uscita del comparatore va in una porta AND con il bit di validità del blocco collegato all’ingresso del comparatore. Posso pensare che al Mux arrivi l’etichetta completa, cioè quella composta dai 23bit che indicano il blocco più 1 bit, il bit di validità.
129
La parte dell’etichetta composta da 23 bit va nel comparatore, mentre il bit di validità va in ingresso alla porta AND con il risultato del comparatore. Al termine si ottengono 4 segnali, si possono avere solo due tipi di configurazioni: o tutti 0 oppure tre 0 e un solo 1. Questi quattro segnali possono andare in input ad una porta OR. Se in uscita di questa OR c’è 1, significa che c’è la pagina, altrimenti si genera page-fault.
Se viene generato page-fault, la cache lo spedisce al processore, e il sistema operativo attiva il gestore della memoria per caricare la pagina in cache dalla memoria centrale. Nel frattempo i 4 bit dell’indirizzo, che indirizzano la parola nel blocco, vanno alla porta indirizzi di tutti i blocchi, che erano già pronti a leggere o scrivere quella parola. Di tutti questi blocchi solamente uno restituirà la parola in uscita, tutti gli altri avranno lavorato invano, speculativamente. Ogni blocco oltre alla porta indirizzi avrà una porta di enable(o chip select), il quale se vale 1 dice al blocco di lavorare se vale zero di non fare nulla. L’ingresso di enable è gestito dai 4 bit che si ottengono in uscita alle 4 porte AND. Questi 4 bit entrano in un demultiplexer alla cui porta di uscita ci saranno 32*4 linee dove scorrono i bit, che saranno mandati a tutti i set. Solo il set indicato dal selettore avrà la configurazione di bit pari a quella in ingresso al demux, le altre configurazioni sugli altri set invece saranno nulle. Ogni linea di queste 4 ,che arrivano su ogni set, sono collegate in ordine ai blocchi, cioè la prima linea al blocco 1, la seconda al blocco 2 e così via. Saranno collegati precisamente alle porte enable di ogni blocco. Quindi di tutti i blocchi solo 1, del particolare set, avrà ricevuto 1 sulla porta enable, e sarà quello che dovrà lavorare.
130
Nel disegno non è rappresentato, ma tutti i blocchi sono collegati in parallelo alla porta di output della cache(collegamento three-state). Solo quello che ha ricevuto questo segnale enable scriverà la parola che ha decodificato, e quindi questo sarà l’uscita della porta di output. In fase di scrittura avviene il contrario: sulla porta dati di input c’è il dato e i blocchi congelano questo dato e lo scriveranno solo se arriva l’enable. Facciamo l’esempio in cui il la parola si trova nel blocco 1 del set 2 e questo blocco ha bit di validità 1: la configurazione in uscita alle porte AND è 0100, questa configurazione viene retro azionata, giunge al de multiplexer che regolato sempre dai bit del campo set dell’indirizzo imposterà la pista di 4 bit che arriva a questo set con la configurazione dei bit dell’ingresso mentre tutti gli altri bit a zero. Poiché questi bit di uscita sono ordinatamente collegati alle porte enable dei blocchi, riceverà 1 solo il blocco 1 del set 2.
131
2. Struttura delle memorie Le celle di memoria sono aggregate in una matrice. Bisogna conoscere quindi la parole, 32 bit per esempio. Questi 32 bit indirizzano è la parola, ciò vuol dire che in ingresso e in uscita da questa memoria ci sono 4 byte. La memoria per semplicità supponiamo sia di 4KB(212), questo significa 1024 parole da 4 byte. Le memorie sono organizzate in matrici quadrate: 1024 parole, 210, che suddividiamo in 25 righe e 25 colonne. Quindi la memoria è costituita da 1024 caselle di 4byte, e organizzate in 32 righe e 32 colonne. La memoria riceve un indirizzo di 12bit, visto che per indirizzare una memoria di 4KB (212) sono necessari 12 bit. Se questa memoria è indirizzata alla parola, gli ultimi n bit sono nulli, con n = log2(parola). Nel nostro caso gli ultimi due bit dell’indirizzo sono zero. I restanti 10 bit vengono divisi in 2 parti da 5 bit: 5 bit indirizzano una riga e 5 bit indirizzano una colonna. Un decodificatore è un circuito logico che riceve n bit in ingresso, restituisce 2n uscite. Con 5 bit si indica un numero e l’uscita del decodificatore individua solo la linea, che può essere una riga o una colonna, e questa si illumina. Decodificata sia la riga e la colonna si è determinata la cella.(il comando di un sottomarino nucleare, per spedire il razzo atomico, server la chiave del comandante e la chiave del vice, se non mettono entrambi la chiave il missile non parte!) Per mettere in moto una cella bisogna decodificare 5 bit per la riga e 5 bit per la colonna. Queste decodifiche possono essere fatte simultaneamente, e quindi la latenza per decodificare la riga è pari alla latenza per decodificare la colonna. Un decodificatore di 5 bit è più veloce di un decodificatore a 8 bit ed è anche più semplice da realizzare visto che ha solo 25 uscite. Se la matrice fosse stata rettangolare, 28x22,la latenza è comandata dal decodificatore più lento, quindi 28. Visto che sono sempre 4KB di memoria, ha più senso rendere la matrice quadrata.
132
Esercizi 1) Data una memoria cache set associativa di 4MB e spazio di indirizzamento 4GB. La memoria è a 8 vie, il blocco è di 8 parole da 8 byte. Definire come è composto l’indirizzo. Svolgimento: spazio di indirizzamento 4GB = 232 , n = log2 4GB = 32 bit. L’indirizzo è composto da 32bit. …
… 32bit
Di questi 32 bit, x bit meno significativi indirizzano la parola all’interno del blocco. In ogni blocco ci sono 8 parole da 8 byte, per un totale di 8*8byte = 64byte per blocco. Per indirizzare il blocco sono necessari log264 = 6bit. x
y
z
0
0
0
Indirizzo della parola nel blocco
Di questi 6 bit una parte è pari a zero, la parte meno significativa: log28byte = 3. I restanti 32-6 = 26 bit vanno divisi in due parti, una parte per indirizzare il set, e un'altra parte per l’etichetta. I set sono indirizzati con i bit meno significativi. Bisogna conoscere il numero di set. Essendo la cache di 4MB e sapendo che ogni blocco è di 64byte, ci sono 4MB/64byte = 222/26=216 blocchi. Essendo la memoria a 8 vie, cioè ogni set è composto da 8 blocchi, n blocchi/8 = n. set : 2 16/23=213 set. Per indirizzare 213 set sono necessari 13 bit.
Etichetta(13 bit)
Indirizzo del set (13bit)
I restanti 32-13-6 = 13 bit sono per l’etichetta
2) Data una memoria cache ad indirizzamento diretto di 8 MB, con spazio di indirizzamento a 4GB con 128 blocchi. Definire come è composto l’indirizzo. Svolgimento: Indirizzo da 32bit, considerando uno spazio di indirizzamento di 4GB. …
… 32bit
133
Essendo a indirizzamento diretto(set associativa a 1 via), significa che ci sono 128 set con un solo blocco, ogni blocco(o set) ha dimensione 8MB/128 = 223/27=216 byte. Sono necessari 16 bit, i meno significativi, per indirizzare la parola nel blocco. Di questi 16bit non posso dire se gli ultimi bit sono nulli, perché non conosco la dimensione della parola. Dei restanti, essendo 128 set, sono necessari 7 bit, i meno significativi, per indirizzare i set (o blocchi). I restanti 32-16-6 ) 9 bit sono destinati all’etichetta.
etichetta (9bit)
indirizzo del blocco (7bit)
indirizzo parola nel blocco (16bit)
3) Data una cache completamente associativa da 1MB, 1024 blocchi e spazio di indirizzamento 4GB. Svolgimento: L’indirizzo è sempre di 32bit poiché lo spazio di indirizzamento è di 4GB. Ogni blocco ha dimensione pari a 1MB/1024 = 220/210 = 210 byte, sono dunque necessari 10bit per indirizzare la parola nel blocco. Essendo la memoria completamente associativa, i restanti bit, 32-10=22bit identificano tutti l’etichetta.
etichetta (22bit)
indirizzo parola nel blocco(10bit)
134
CAPITOLO 16
DIVERSE ARCHITETTURE DI MEMORIE Vediamo qualcosa dal punto di vista dell'architettura che può migliorare le prestazioni delle memorie. Nel precedente capitolo abbiamo già visto il meccanismo di indirizzamento all'interno di una memoria a semiconduttore: data la memoria di n locazioni, con parole da 4 byte per esempio, abbiamo un indirizzo che è composto da log2 n bit, che hanno la particolarità di avere gli x BIT meno significativi uguali a 0, dove x è pari a log2 N, con N pari al numero di byte della parola indirizzata, 4 in questo caso. Adesso vediamo come avviene l'indirizzamento. Dagli n bit, tolti gli x bit che non sono significativi dal punto di vista dell'indirizzo, poiché tutti 0, diciamo che m=n-x bit di indirizzo sono effettivi. Presi m/2 ed m/2 si può impostare una matrice quadrata di 2(m/2) righe e 2(m/2) colonne, e quindi si ha un meccanismo per indirizzare una riga e una colonna per identificare una parola fatta da 2x byte.
Una volta presa la parola questa verrà instradata su una porta che conterrà x byte, o viceversa se è una scrittura la porta riceve il dato e va a collocarsi nella parola localizzata. Dal punto di vista delle prestazioni, in un certo tempo T, verranno fuori x byte(oppure riceverà in scrittura altrettanti x byte). Questo è il tempo necessario per fare lo step 1, decodifica dell'indirizzo e abilitazione delle porte che devono ricevere o spedire il dato. Quindi gli m/2 bit e m/2 bit che provengono dalla porta indirizzo della memoria, sono codificati con un codificatore che ha m/2 bit in ingresso e 2^m/2 linee in uscita per abilitare una fra le 2m/2 righe, gli altri m/2 bit sempre con il meccanismo del codificatore abilitano una colonna. Il tempo di decodifica dipende dal numero di bit che si decodificano, più sono gli ingressi e più è complicata è la decodifica. Le operazioni di decodifica della riga e delle colonne, sono simultanee, hanno la stessa latenza, visto che sono m/2 ed m/2, stiamo codificando due oggetti identici. Questo significa che a al tempo di decodifica bisogna aggiungere il tempo necessario affinché il segnale di abilitazione si propaghi lungo la riga e lungo la colonna. Verrà sollecitata l'allocazione di memoria che avrà una sua latenza di lettura o scrittura (che sommariamente sono valori di tensione) e questi valori letti vanno spediti in uscita. La somma di questi tempi: decodifica, invio, abilitazione, lettura e trasferimento, da il tempo in cui questa memoria può essere letta o scritta. In questo tempo otteniamo la lettura o la scrittura di x byte. Dal punto di vista della velocità si parla della velocità a banda, o meglio il rapporto tra quantità di dati che vengono letti (o scritti) e il tempo impiegato per leggerli (o scriverli), quindi il rapporto tra i dati da operare, e il tempo con cui avviene l’operazione:
135
Se in un certo tempo vengono letti x byte, si ha una certa banda. Se nello stesso tempo se ne leggono il doppio, si ha una banda doppia, si effettua l’operazione due volte più velocemente. Possiamo riformulare meglio dicendo che la banda è pari al rapporto fra la dimensione dei dati da operare e il tempo necessario a operare su questi dati: Significa che se si vuole aumentare la banda, o si aumentano i dati letti nello stesso periodo o si diminuisce il tempo per leggere la stessa quantità di dati. Come è possibile aumentare la banda? 1. Aumentare la banda aumentando i dati letti o scritti: banchi paralleli Supponiamo di avere realizzato un chip, dove si ha un certo traffico in un determinato tempo t, ad esempio 1µs e con x pari a 1 un byte, quindi la banda sarà b = x/t = 1byte/1µs, 1MB/s. Ora, supponiamo di voler quadruplicare questa banda. Analizziamo l'indirizzo del byte, ovviamente, se sia sta indirizzando il singolo byte, l'indirizzo è tutto significativo, non si hanno bit meno significativi nulli. Quindi tutti i bit dell'indirizzo sono tutti utili. Come si possono aumentare i dati letti in un periodo? Il chip che progettato prima, cioè quello che ricevuto l’indirizzo tira fuori un byte al tempo t, viene copiato per tante volte quanto si vuole replicare la banda, ad esempio quattro volte se si vuole quadruplicare la banda, ottenendo così il banco parallelo. Avendo preso 4 moduli uguali, la memoria è aumentata di un fattore 4, quindi se prima la memoria era grande 1MB, per esempio, quindi con un indirizzo di 20bit (2 20=1MB), adesso, avendone copiati 4 si ha una memoria di 4MB, con un indirizzo che è diventato di 22 bit (2 22=4MB). Quadruplicare la banda significa che in un accesso, in un tempo t si deve tirare fuori non più una parole di 1 byte, ma una parola di 4 byte. Quindi in ingresso a questo nuovo chip andranno 22 bit, essendo ora la parola di 4 byte, riguardo l'indirizzo i 2 bit meno significativi saranno sicuramente nulli. I 20 bit dell’indirizzo, ottenuti dai 22bit meno i 2 bit nulli, vengono inviati in parallelo a tutti i 4 blocchi. Quindi tornando a quanto detto prima, ognuno di quei quattro blocchi vuole un indirizzo di 20 bit e tira fuori un byte. Quei 20 bit che vengono inviati a ognuno di quei quattro moduli indirizza una parola di 1 byte che viene mandata fuori. Sommando tutte le parole ottenute dai quattro moduli si ottiene una parola formata da, 4*8bit = 32bit, 4byte.
136
I 20 bit in ingresso a ognuno dei 4 moduli, supponiamo indichino riga 5 e colonna 8, indirizzeranno 1 byte in ognuno dei 4 moduli. Quindi ogni modulo tirerà fuori la parola situata alla riga 5 e colonna 8. Le parole estratte non sono le stesse, perché il contenuto dei quattro moduli è differente, sono indipendenti, mentre la posizione della parola in ogni blocco è la stessa. Alla fine ho costruito una memoria che tira fuori 4 byte in un tempo t. t era il tempo relativo ad un modulo necessario a decifrare l’indirizzo, abilitare la cella e trasferire la cella. Questo tempo per ogni blocco è rimasto uguale e ogni tempo è uguale per ogni blocco. Poiché questi quattro moduli lavorano in simultaneo. Al termine sono stati prodotti il quadruplo dei dati con lo stesso tempo t. La banda si è quadruplicata. 2. Aumentare la banda diminuendo il tempo: banchi interlacciati Data una certa memoria che in un t tira fuori un certo numero di dati, x, la banda dell'oggetto è x/t. Ora bisogna costruire un oggetto che ha una banda superiore andando a lavorare sulla diminuzione della latenza. Consideriamo il modulo visto al paragrafo precedente, i banchi paralleli: il modulo riceve 22 bit di cui gli ultimi 2 sono nulli e tira fuori 4 byte. Adesso bisogna costruire un oggetto a partire da questo che ha una banda ancora maggiore di quello creato in precedenza. Quindi se per esempio ci sono 4 oggetti, l'indirizzo di questo macro oggetto avrà 24 bit, poiché ci sono in questo caso 4MB*4moduli = 16MB di memoria complessiva, cioè il chip dall’esterno è visto come una memoria complessiva di 16MB, e sono necessari dunque 24 bit per indirizzare questa memoria, 224 = 16MB. La dimensione della parola resta la stessa, che resta sempre di 4 byte. Questo comporta che dei 24 bit gli ultimi 2 sono uguali a 0. Questo indirizzo inviato entra nell'oggetto, e ogni modulo che lo compone necessita di soli 22 bit, di cui gli ultimi due devono ancora essere nulli. Come vengono scelti questi 22 bit da inviare a questi moduli? 137
Dei 24 bit si compongono i 20bit più significativi con i due bit nulli. I bit meno significativi subito dopo la coppia di 0 in realtà servono ad indicare la parola. Ci sono delle previsioni che si possono fare sui prossimi byte che serviranno, ad esempio si possono ipotizzare che successivamente si dovrà prelevare la parola successiva a quella appena richiesta. Questa previsione quanto è probabile? Dipende dal livello di memoria in cui ci si trova. Se ad esempio ci si trova nella memoria centrale, si considera un blocco con tutti i suoi dati annessi, in questo caso siamo sicuri che se dalla memoria centrale si sta leggendo il primo dato di un blocco, si vuole sicuramente leggere il secondo dato del blocco. Quindi, con un blocco di 4 parole, e arriva un certo indirizzo per prelevare la prima parola del blocco, questo indirizzo avrà i 2 penultimi bit meno significativi nulli, e sicuramente, dopo la prima lettura, si riceverà un indirizzo per prelevare la seconda parola del blocco, e i 2 penultimi meno significativi nulli saranno 01 ecc. Poiché stiamo nel livello tra la cache e la memoria centrale, questi dialogano a blocchi, di conseguenza quando si chiede un dato, una parola, alla memoria centrale, questa invierà alla cache l’intero blocco. Ipotizzando che il blocco sia composto da 4 parole, quei due bit andranno proprio a identificare la parola.
I 22 bit, indirizzeranno un determinato modulo, quel modulo in un tempo t tirerà fuori 4 byte, se si necessitano di altri 4 byte, ci vorrà un altro tempo t. A questo punto, la memoria sembra sia andata veloce come la memoria precedente, ma a me serve anche tirare fuori le altre parole del blocco. Allora si invia anche agli altri moduli l’indirizzo di 22bit e la prima memoria tira fuori la prima parola, la seconda memoria tirerà fuori la seconda parola e così via, al termine del tempo t tutte le memorie avranno estratto le parole. A questo punto, il grosso del lavoro, dal punto di vista della latenza, è stato già fatto. Le 4 parole lette contemporaneamente, saranno congelate e si tirerà fuori la prima parola, dovuta all’indirizzo composta dalla coppia dei due penultimi bit meno significativi pari a 00 nel caso della parola 1. Al secondo colpo di clock, quando verrà richiesta la seconda parola, quindi quei due penultimi bit meno significativi saranno 01 e ora verrà fatta uscire la seconda parola che era stata congelata precedentemente. Quindi questi due bit vengono utilizzati per controllare un selettore, perché visto che al primo colpo di clock è stata chiesta una parola, ma nel frattempo però anche le altre componenti hanno tirato fuori le loro parole, questo decide quale delle parole andrà in uscita. Quindi per prelevare queste parole è necessario un tempo T, poi la prima parola uscirà con il tempo necessario ad attraversare il selettore. Al secondo colpo di clock il tempo per ottenere la seconda parola, sarà solo quello necessario per attraversare il selettore, visto che è stata prodotta al colpo di clock precedente. Alla termine si hanno 4 parole in un tempo t+4T, dove T è il tempo di attraversamento del selettore, e t è il solito tempo dovuto alla decodifica dell’indirizzo abilitazione della cella e uscita della cella. Nota bene: questo è un discorso che non è speculativo, non migliori le cose solo 138
nel caso in cui il processore vuole la parola successiva, se questa architettura la metto su una memoria che è consultata per blocchi, questo vantaggio si ottiene sempre.
Ritornando alla struttura delle memorie precedente, l’indirizzo spacchettato in 2 moduli, uno per le righe e uno per le colonne, l'incrocio di questi due segnali abilitano una riga e una colonna. La parola accetterà in ingresso il dato o lo trasferirà. Un oggetto del genere ha un problema: con una memoria di 1MB si utilizzano 20 piedini. In un sistema in genere si cerca di diminuire il numero di piedini. Infatti il pin ha una dimensione difficilmente migliorabile con la tecnologia. Come diminuire il numero di piedini per una memoria? Diminuendo il numero di piedini sulla porta dati, proporzionalmente si diminuisce la banda della memoria, se si serializza la parola(o se ne diminuisco le dimensioni), si ha una latenza che è il tempo per leggere la parola più i colpi per leggere un bit alla volta. Quindi conviene diminuire il numero della piedinatura nell'indirizzo, ad esempio potremmo dividere la dimensione dell'indirizzo, e utilizzare solo la parte realmente significativa. Supponiamo di lavorare con 10 piedini che ricevono 10 bit, questo comporta che l'indirizzo di 20 bit deve arrivare in 2 colpi di clock, in un colpi do clock i primi 10 bit e al secondo colpo di clock gli altri 10. In questo modo però sembra che sia necessario il doppio del tempo, in realtà pure mettendo la metà dei piedini, non si impiega il doppio del tempo. Con 10 bit alla volta, non ha senso avere un decodificatore, che riceve 10 bit e abilita una delle 10 linee, non bisogna lavorare in parallelo con righe e colonne. Allora è inutile un secondo decodificatore, arriveranno prima i bit della riga e si abilita la riga, poi arrivano i bit della colonna e si abilita la colonna, tutto questo usando un solo decodificatore.
139
Quindi dimezzando il numero di piedini decodificatore.
comporta un ulteriore risparmio, o meglio si risparmia un
Vediamo però un'altra soluzione. Supponiamo di aver interrogato una memoria per avere un certo byte, quindi forniremo le 2 parti dell'indirizzo alla memoria e arriverà il dato. Se siamo a livello di memoria centrale, il prossimo byte sarà quello successivo. Quindi il chip sa che subito dopo la parola che sta caricando ora, dovrà caricare la prossima, che si trova affianco a quella appena caricata, questo è un fatto certo, perché la memoria centrale lavora con i blocchi. Quando devo tirar fuori la parola seguente, i 20 bit di indirizzo di quella parola sono sulla stessa riga di quella precedente, per la prima parola quindi codifico riga e colonna, ma per la seconda la terza e la quarta, codifico solo la colonna, perché la riga è sempre quella. Posso pensare allora che quindi arrivano i primi 10 bit, i più significativi, che abilitano su un decodificatore una riga, questa riga è tutta trasferita su un buffer, quando arrivano gli altri 10 bit, questi sollecitano su quel buffer una determinata parola che va in uscita, al prossimo colpo di clock, non indirizzo la seconda parola con tutti i 20 bit, ma solo con altri 10 bit; in realtà posso evitare anche questo con una logica particolare che scatta di parola con un contatore. Quindi si è dimezzato il numero di piedini dell'indirizzo e fatto si che per il primo dato si abbia una latenza maggiore, ma per tutti quelli successivi, la latenza è sempre quella precedente se non migliore perché vado a leggere il buffer dove è stata trascritta la riga. Quindi, tanto più grande è il blocco, tanto è meno percettibile il dazio pagato per aver spezzato in due l'indirizzo da inviare.
140
CAPITOLO 17
POLITICHE DI GESTIONE DELLE MEMORIE Consideriamo le diverse politiche di gestione della memoria analizzando quello che avviene con un sistema composto da una CPU con la sua cache e la memoria centrale e l’interazione Input/Output o con più processori. 1. Interazione con i dispositivi di I/O Nell’immagine le linee tratteggiate indicano la connessione tra le periferiche di I/O e il sistema. I vari livelli su cui è possibile questa comunicazione sono: direttamente con la CPU, direttamente con la cache, direttamente con la memoria centrale, o a livello di bus tra CPU e cache o tra cache e memoria centrale.
Ci sono una serie di dispositivi di input/output connessi tra di loro su un bus di I/O collegati con un adattatore al resto del sistema. L’obbiettivo è capire dove attaccare l’adattatore di input/output. Se si collega direttamente alla CPU con un operazione di input questo da dei problemi per mantenere coerente tutto quello che c’è nelle memorie sottostanti, cache e memoria centrale. Se l’operazione non è sincrona, per sincrono si intende qualcosa che avviene in un determinato istante e quell’istante è sempre lo stesso. Un fenomeno, un evento avviene sempre in un determinato istante; ad esempio quando si chiede il valore di x in un programma e finché il valore di x non è stato dato il programma non va avanti nell’esecuzione, quell’evento è sincrono, cioè finché non è stato fornito il valore di x, a quella linea di codice il programma attende sempre che venga fornito il valore di x. Se invece c’è un sistema che monitora l’ambiente con dei sensori che ricevono quello che avviene, poi trasducono questo e inviano al sistema dei segnali opportuni, questo è un evento non sincrono. Si controlla un forno di cottura di pandori quando la temperatura arriva oltre una soglia si spegne la corrente che attraversa la resistenza del forno, allora non si 141
sa quando questo segnale arriva e si gestisce questo fenomeno. Un evento non sincrono è quando si invia alla stampante l’avvio di una stampa, quando terminano i fogli la stampante invia un segnale che avviene in maniera inaspettata. In questi casi la CPU dovrebbe perdere tempo a gestire ciò che avviene nell’I/O. Se invece avviene a livello di memoria centrale, caricato il dato x in cache, quando c’è un aggiornamento di x in memoria centrale, il processore vede in cache il dato non aggiornato. Questo vale anche per l’output, se il valore di x è in cache e il processore ha aggiornato il valore di x, in memoria centrale c’è il valore vecchio che viene anche inviato in output. Il dialogo in memoria centrale da problemi sia in operazioni di input e sia in operazioni di output, in cui la memoria centrale trasferisce in output valori del dato non aggiornati. Questo secondo problema non esiste se la cache è di tipo write through, poiché ogni volta che i dati vengono modificati dal processori nella cache questi sono aggiornati anche in memoria centrale, e in output c’è il valore corretto. Però se considerazione la situazione in cui nella cache c’è il dato x e con un operazione di input viene aggiornato x in memoria centrale, c’è il problema che la cache continua ad utilizzare il valore vecchio di x, quello non aggiornato, poiché era già presente in cache. Se invece si dialoga a livello di cache, i problemi descritti prima non ci sono. Con un operazioni di input si scrive nella cache, con l’operazione di output si legge dalla cache. Quando si scrive il dato x in cache, si modifica anche il change bit in quel blocco così da scrivere il valore aggiornato anche in memoria centrale. In questo modo però si ottiene un sistema coerente però c’è il problema che la cache dovrà dialogare con l’I/O quando il processore lo permetterà, e inoltre la cache è chiamata a far transitare al suo interno anche i blocchi dei dati su cui si fanno operazioni di input e output. Parte della cache è occupata da blocchi interessati dalle operazioni di input e output e il processore utilizza risorse minori, ha a disposizione meno linee della cache. Il processore che ha una cache di K KB di cui il 90% è occupato dalle operazione di input output. Si satura la cache con blocchi che il processore non ha mai referenziato. Per cui collegando l’I/O direttamente alla cache, è vero che si ha coerenza ma è come se la cache fosse molto più piccola e si paga sempre miss rate. Ritorniamo al sistema in cui I/O dialogano con la memoria centrale. La gestione dell’I/O è gestita dalla memoria centrale. Attraverso il meccanismo di write through come abbiamo già detto si presentano dei problemi durante le operazioni di input, perché se x era già in cache, e al successivo aggiornamento di x, il processore ha il valore scaduto. Bisogna gestire questo sistema attraverso due filosofie che partono da un principio, non è ammissibile che se è copiato x in cache e poi questo viene modificato in memoria centrale, non è ammissibile che in cache ci sia un valore non coerente, bisogna evitare che questo avvenga. Fondamentalmente bisogna far si che la cache che il valore di x già caricato al suo interno non ha più valore. Per far si che la cache si accorga che il valore non è più idoneo bisogna far si che la cache “curiosi” nella memoria centrale, “si faccia i fatti” della memoria centrale. Deve vedere se l’I/O sulla memoria centrale modifichi il valore di x. Quando l’input scrive in memoria centrale, quello che viene scritto deve essere visibile anche alla cache. L’adattatore dialoga con la memoria centrale anche su un canale “ascoltato” dalla memoria cache, in modo tale che se si sta avvenendo l’input di x in memoria centrale, la cache se ne
142
accorge dell’arrivo del nuovo valore di x. Questo meccanismo prende il nome di Snoopy cache, si dice anche che la cache deve sniffare i dati che arrivano dal canale di I/O. Si vede che in input c’è il valore della variabile x vede anche che in suo blocco c’è il valore di x e si accorge che il valore di x al suo interno non ha più significato perché arriva un nuovo valore dal canale di input. Come fa a capire che i bit sul canale di input sono i bit che codificano il canale di x? Quando si effettua un operazione di I/O c’è un canale dati e un canale indirizzo. In sostanza la cache prende l’indirizzo dal canale indirizzi ne estrae la parte che è presente nell’etichetta di un determinato blocco e fa la stessa operazione che farebbe quando la CPU comanda di scrivere un dato in un certo indirizzo. Quindi arrivato il dato dal bus dati, corrisponde come indirizzo a un dato che è stato già caricato, allora questa è una situazione critica che si risolve con due politiche di snoopy: 1. Accortosi che il dato è un aggiornamento del dato al suo interno, allora il dato al suo interno non vale più niente. La cache invalida il blocco, tutto il blocco anche se l’aggiornamento riguarda solo x, perché quel blocco non ha più senso. Se la cache è strutturata con un validity bit per ogni parola, allora si invalida solo quella word che contiene il valore di x. Questo al costo di avere tanti bit di validità. In questo se il processore chiede quel valore, essendo il validity bit settato a zero a indicare che quel blocco(o quella parola) non è valida, si copia il blocco dalla memoria centrale, con il valore aggiornato. 2. Il dato è presente in cache, arriva un aggiornamento, invece di invalidarlo, lo copi direttamente. Arriva il dato e la cache se lo prende. Potrebbe sembrare la stessa situazione di quando si dialoga direttamente con la cache, ma non è così. Ora si prende il dato soltanto quando questo è un aggiornamento di quello che è in cache. Questa politica sembra migliore della precedente, perché ora quando il processore chiede il dato x, questo è già presente in cache non bisogna copiarlo dalla memoria centrale. Questo meccanismo però blocca la cache per aggiornare un valore che è in cache, e nessuno dice che quel valore verrà utilizzato dal processore. Può darsi che quel dato al processore non serva più e si è perso tempo sulla cache, il processore si è stallato per un attimo. Invalidando il blocco e poi il processore non chiede più x è risultata un operazione intelligente invalidarlo. Quindi per realizzare questo meccanismo di Snoopy cache, il collegamento con l’I/O va a livello di bus tra cache e memoria centrale.
143
2. Interazione con più processori Un problema analogo sussiste in un sistema multiprocessore, un sistema con più processori, ciascuno di questi ha una cache interna, una cache esterna con un meccanismo di memorie distribuite o centrali.
Le CPU dialogano con le loro cache, e le cache dialogano o con le memorie distribuite o con la memoria centrale, che non ha problemi di scambiare dati, perché se questi processori si vogliono scambiare dati questi lo fanno scrivendo i dati nella memoria centrale. Per far si che questi non siano mondi paralleli e separati si deve pensare a un meccanismo di connessione tra le memorie distribuite. Con questo sistema c’è il vantaggio di avere l’azione privilegiata di ciascun processore con la sua memoria e dialoga con la banda della sua memoria destinata alla sola CPU, e lo stesso vale per tutte le CPU. Bisogna però prevedere che queste memorie si scambino tra loro queste informazioni. Questo modello si semplifica utilizzando una sola memoria centrale senza le memorie distribuite. Le cache dialogano con la memoria centrale. La memoria centrale che ha una certa banda, questa banda se la deve dividere sui più sistemi. Ogni cache non può prendersi tutta la banda altrimenti l’altra cache dovrà stallare per fare anch’essa delle operazioni. In ogni caso a prescindere questo sistema prevede dei dati scaduti se un dato x viene caricato in cache e viene modificato dal processore. Se la cache è write through il dato viene aggiornato anche nella memoria centrale. Se prima di questo aggiornamento il dato x è stato prelevato da un altro processore, quest’ultimo processore avrà un dato 144
scaduto. Anche in questo caso bisogna prevedere un meccanismo di memorie Snoopy con politiche simile a quelle viste precedentemente. La scrittura in memoria centrale deve essere ascoltata da tutte le memorie cache. Quando si scrive tutte le cache devono accorgersi di quello che avviene. Il discorso è più semplice con una memoria centrale non distribuita, con politiche analoghe a quelle viste prima. Per l’output non c’è problema, per le operazioni di input bisogna invece pensare a meccanismi di Snoopy cache, invalidando il dato in cache o copiando il dato direttamente in cache.
145
146
CAPITOLO 18
TECNICHE DI COMPILAZIONE EFFICIENTI Nell’esecuzione di un programma sorgono problemi quando un istruzione deve stallare poiché utilizza un dato che è destinazione di un'altra istruzione precedente, e questa non lo ha ancora prodotto. Basti pensare al processore floating point in cui una moltiplicazione o una divisione durano diversi colpi di clock. Schedulando le istruzioni in modo appropriato, come già visto, si riescono a eliminare degli stalli. Trovare però un certo numero di istruzioni che spostate, senza sconvolgere l’esecuzione del programma, rimpiazzino gli stalli non è sempre semplice da fare. Quello che succede è che se ci sono diversi stalli da coprire questa operazione potrebbe essere impossibile. Se ci sono cinque istruzioni e ci sono 12 stalli, non è possibile coprire tutti questi stalli. Allora si utilizzando tecniche di compilazione come lo srotolamento del loop o la pipeline da programma. 1. Srotolamento del loop Lo srotolamento del loop (loop unrolling) consiste nel replicare il corpo di un ciclo più volte modificando opportunamente il codice. Consideriamo il seguente programma strutturato in un loop, che esegue la moltiplicazione di due vettori C=A*B: 1. loop: 2. 3. 4. 5. 6. 7. 8. 9.
L.D F1,(R7)0 L.D F2,(R8)0 MUL.D F3,F1,F2 S.D F3,(R9)0 DADDI R7,(R7)8 DADDI R8,(R8)8 DADDI R9,(R9)8 DADDI R2,(R2)-1 BNEZ R2, loop
Si possono schedulare le istruzioni per evitare degli stalli però ci accorgiamo che la moltiplicazione floating point necessitando di 12 colpi di clock, ad esempio, per produrre il risultato comporta che non è possibile coprire il numero degli stalli con le istruzioni a disposizione. Quello che viene in mente è il cosi detto srotolamento del loop. si scrivono le istruzioni che compongono il loop per un certo numero di volte. Si replicano ad esempio per quattro volte. Il loop dovrà fare in questo caso N/4 iterazioni, visto che si è srotolato il loop per quattro volte. In questo modo si paga un numero eccessivo di istruzioni, quindi si paga in termini di memoria, visto che alcune istruzioni si scrivono per quattro volte.
147
L’esempio precedente considerando lo srotolamento di quattro iterazioni diventa: 1. loop: L.D F1,(R7)0 2. L.D F2,(R8)0 3. MUL.D F3,F1,F2 4. S.D //------------------------ primo srotolamento 5. L.D F4,(R7)8 6. L.D F5,(R8)8 7. MUL.D F6,F4,F5 8. S.D //------------------------ secondo srotolamento 9. L.D F7,(R7)16 10. L.D F8,(R8)16 11. MUL.D F9,F7,F8 12. S.D //------------------------ terzo srotolamento 13. L.D F10,(R7)24 14. L.D F11,(R8)24 15. MUL.D F12,F10,F11 16. S.D //------------------------ quarto srotolamento 17. DADDI R7,(R7)32 18. DADDI R8,(R8)32 19. DADDI R9,(R9)32 20. DADDI R2,(R2)-4 21. BNEZ R2, loop
F3,(R9)0
F6,(R9)8
F9,(R9)16
F12,(R9)24
Il loop è stato srotolato, replicando un corpo del loop per quattro volte. Non tutte le istruzioni sono stati replicate, quelle relative alla gestione del loop, decremento del contatore e branch, non sono state replicate. In questo modo ci sono più istruzioni da guardare nel loop e quindi da utilizzare per riempire gli stalli. Tipicamente si sistemano le istruzioni in modo omogeneo, mettendo tutte le operazioni dello stesso tipo di seguito, prima tutte le operazioni load, poi tutte le operazioni di calcolo e poi tutte quelle di memorizzazione, una di seguito all’altra. Questo modo di schedulare le istruzioni evita gli stalli.
1. loop: 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12.
L.D F1,(R7)0 L.D F4,(R7)8 L.D F7,(R7)16 L.D F10,(R7)24 L.D F2,(R8)0 L.D F5,(R8)8 L.D F8,(R8)16 L.D F11,(R8)24 MUL.D F3,F1,F2 MUL.D F6,F4,F5 MUL.D F9,F7,F8 MUL.D F12,F10,F11 148
13. 14. 15. 16. 17. 18. 19. 20. 21.
S.D F3,(R9)0 S.D F6,(R9)8 S.D F9,(R9)16 S.D F12,(R9)24 DADDI R7,(R7)32 DADDI R8,(R8)32 DADDI R2,(R2)-4 BNEZ R2,loop DADDI R9,(R9)32
Srotolando in maniera opportune il codice , si riescono a evitare gli stalli. Si paga un codice più lungo, quindi si spreca più memoria per scrivere questo codice, ma si paga anche un numero eccessivo di registri, si occupano più registri. Il costo di questa tecnica è riassunto in un numero di registri più grande, quattro volte il numero di registri e di una dimensione del codice, quasi quattro volte di più, quasi perché non tutte le istruzioni sono state replicate come abbiamo detto prima. Però il programma è più veloce visto che sono stati evitati gli stalli. 2. Pipeline da programma Un'altra tecnica di compilazione è la pipeline da programma. Supponiamo di avere il programma visto nel paragrafo precedente, C=A*B, e strutturiamo il loop con questa logica: Loop: -
carica A(i) carica B(i) C(i-1)<- A(i-1)*B(i-1) scrivi C(i-2)
Nella moltiplicazione si utilizzano i dati caricati all’iterazione precedente, e non si dovrà stallare per attendere che la load produca A(i) e B(i), poiché i dati sono stati già prodotti. La store scrive invece il risultato prodotto due iterazioni precedenti in modo tale che la moltiplicazione abbia prodotto il risultato. Gestire un loop in questo modo, evita di saturare i registri, evita di riempire la memoria con le istruzioni. Si ha il solo problema di gestire questi dati in maniera opportuna. Il nome di questa tecnica prende il nome di pipeline da programma poiché nel programma si viene a creare una sorta di pipeline, perché c’è uno stadio che riguarda i, uno stadio che riguarda i-1 e un altro stadio che riguarda i-2. Sorge un problema, al momento della prima iterazione la moltiplicazione non avrà A(i-1) e B(i-1) poiché stiamo alla prima iterazione, e così anche la store che necessita addirittura degli elementi di due iterazioni precedenti. Bisogna quindi predisporre un transitorio di riempimento, in cui si caricano i primi elementi su cui operare nel loop.
149
Transitorio di riempimento: -
carica A(0) carica B(0) carica A(1) carica B(1) C(0)<- A(0)*B(0)
a questo codice può seguire il loop strutturato precedentemente. Se il vettore va da 0 a N (vettore di N+1 elementi), il loop inizia con i=2, e termina con i=N. Al loop deve seguire un transitorio di svuotamento per operare sui restanti elementi caricati e a cui non è stata effettuata l’operazione di moltiplicazione o non sono stati ancora scritti in memoria. Transitorio di svuotamento: -
C(N)<-A(N)*B(N) scrivi C(N-1) scrivi C(N)
In conclusione il programma ha una struttura del genere: Transitorio di riempimento: -
carica A(0) carica B(0) carica A(1) carica B(1) C(0)<- A(0)*B(0)
Loop: -
carica A(i) carica B(i) C(i-1)<- A(i-1)*B(i-1) scrivi C(i-2)
Transitorio di svuotamento: -
C(N)<-A(N)*B(N) scrivi C(N-1) scrivi C(N)
Il codice del programma visto prima diventa: //transitorio di riempimento 1. DADDI R2,(R0)2 2. DADD R1,R0,R6 //R1<- N dimensione vettore 3. L.D F1,(R7)0 150
4. 5. 6. 7. 8. 9.
L.D F2,(R8)0 L.D F3,(R7)8 L.D F4,(R8)8 MUL.D F5,F1,F2 DADDI R7,(R7)16 DADDI R8,(R8)16 // loop 10. S.D F5,(R9)0 11. MUL.D F5,F3,F4 12. L.D F3,(R7)0 13. L.D F4,(R8),0 14. DADDI R7,(R7)8 15. DADDI R8,(R8)8 16. DADDI R2,(R2)1 17. BNEQ R2,R1, loop 18. DADDI R9,(R9)8 //transitorio di svuotamento 19. MUL.D F10,F3,F4 20. S.D F5,(R9)0 21. S.D F10,(R9)8 I transitori si bilanciano, ci sono 4 load(le load sono maggiori delle store), 2 moltiplicazioni, 2 store. Nel transitorio di svuotamento non ci si preoccupa delle load.
151
152
CAPITOLO 19
PARALLELISMO A LIVELLO DI ISTRUZIONI Cerchiamo di vedere un approccio a un tipo di architettura che cerca di risolvere il problema legato al fatto che nel calcolatore ci sono una serie di moduli che non è detto che siano sempre impegnati durante l’esecuzione di una istruzione. Pensiamo ad un processore floating point a cui, come sappiamo, alla tipica architettura del calcolatore, sono stati aggiunti una serie di moduli indipendenti per il calcolo floating point, e questi non è che detto che siano usati tutti in ogni istruzioni, poiché si possono avere istruzioni di accesso a memoria piuttosto che di uso dell’ALU e così via. Avere questi moduli nel sistema inutilizzati, suggerisce la possibilità di pensare a una struttura che effettivamente cerchi di usarli. 1. Processore VLIW Viene interessante questo approccio nel momento in cui c’è la capacità di accedere a memoria con una banda passante tale da prelevare in un colpo di clock un istruzione più lunga dei consueti 32 bit. Questo approccio si chiama VLIW, Very Long Istruction Word. L’istruzione è rappresentata da una parola molto lunga. È necessaria la capacita di accedere ad una memoria con una banda passante più elevata. Supponendo di avere una memoria istruzioni capace di essere interrogata su un canale che consente la lettura in un colpo di clock per esempio di 128 bit piuttosto di 32 bit, allora si può pensare di codificare un istruzione non più usando 32bit ma bensì 128 bit. È chiaro che riuscire a prelevare un istruzione di 128bit in un colpo di clock, ha senso se si riesce a eseguire simultaneamente queste istruzioni che compongono la VLIW di 128bit. Per semplicità consideriamo 4 istruzioni classiche compattate in una istruzione che le raggruppi tutte e 4. Poter accedere alla memoria istruzioni e prelevare in un colpo di clock tutte queste 4 istruzioni, consente al sistema, se questo è capace, di eseguire in simultanea queste 4 istruzioni. Questo è diverso dal concetto di pipeline dove le n istruzioni sono eseguite simultaneamente ma non parallelamente. Per esecuzione parallela si intende che ogni istruzione è eseguita simultaneamente alle altre. Avendole prelevate tutte insieme parte la loro vita nello stesso instante. 32
32
32
32
Per questo approccio si ha la necessità di un processore che abbia una banda di memoria istruzione maggiore della solita. Inoltre bisogna compattare quattro istruzioni classiche che possono essere eseguite in simultaneo senza recare problemi. Consideriamo una sequenza di istruzioni come questa: A=B+C D=A*B Queste due istruzioni non possono essere eseguite contemporaneamente. Per avere un approccio VLIW è necessario che non ci sono conflitti di dato. Quindi aver prelevato tutte insieme le istruzioni consente di poter pensare di eseguirle simultaneamente. Inoltre, non solo deve essere soddisfatta l’indipendenza dei dati, ma bisogna far si che le istruzioni che compongono la VLIW richiedano delle unità funzionali tutte diverse, bisogna evitare quindi i conflitti strutturali. Bisogna avere dei vincoli sui dati che non devono creare 153
dipendenze sui dati della stessa istruzione e dei vincoli strutturali , cioè i moduli richiesti da quelle istruzioni non devono essere gli stessi o se sono gli stessi bisogna aver provvisto il processore di una certa ridondanza(duplicare alcuni moduli). Per cui è chiaro che se non si vogliono pagare pesanti ridondanze, bisogna imporre dei vincoli sulle possibili operazioni che possono essere eseguite simultaneamente. Questo avviene attribuendo alle istruzioni che compongono la VLIW dei campi ben precisi e quindi in un modello in cui si suppone una VLIW di 160 bit pari a contenere 5 istruzione di base, si può supporre che le prime due siano istruzioni di accesso a memoria dati, poi ce ne siano due che richiedano l’uso di moduli floating point e poi ce ne sia una che richieda l’ALU oppure che possa gestire i moduli di branch per fare una sovrascrittura del PC. MEM1
MEM2
FP1
FP2
ALU (o branch)
In questo caso si è deciso di pagare una memoria dati che abbia una banda doppia rispetto al solito, visto che si sono due riferimenti a memoria; delle unità floating point che sono indipendenti, di conseguenza possiamo avere un istruzione che utilizza il sommatore e un'altra che utilizza il moltiplicatore, a meno che non si decida di inserire due moltiplicatore e quindi di eseguire nella VLIW due istruzioni di moltiplicazione. Non volendo raddoppiare l’ALU in questa istruzione di questo genere si ha la possibilità di avere una istruzione che effettivamente adoperi l’ALU . Le istruzioni che fanno riferimento a memoria, per il calcolo dell’indirizzo non possono utilizzare l’ALU poiché è eseguita in simultanea dall’istruzione ALU. Si dispone quindi di un hardware aggiuntivo per il calcolo dei due indirizzi per non replicare l’ALU 2 volte, e utilizzare 3 ALU. Fortunatamente gli indirizzi dove andare in memoria derivano sempre da un operazione che è una somma, un registro più un immediato e quindi ovviamente. Volendo avviare in parallelo queste istruzioni, poiché questi i primi due campi sono dedicati a moduli per accedere a memoria, è opportuno far si che il calcolo di questi indirizzi avvenga in maniera indipendente.
Che cosa succede se il compilatore non trova nella traduzione da linguaggio alto livello a livello macchina, un numero di istruzioni congruo per riempire i campi di una VLIW? In sostanza il compilatore dato il programma, ed essendo in VLIW, comincia a prendere 5 di queste istruzioni che compongono il programma e le va ad impaccare in una istruzione VLIW, poi ne prende altre 5 e le impacco in una istruzione VLIW e così via. Per fare questo, queste 5 istruzioni devono essere coerenti con il formato della macchina, devono rispettare le condizioni che abbiamo detto prima. E’ chiaro che trovare cinque istruzioni che soddisfino le condizioni imposte dalla VLIW non è detto che ci siano. Allora alcuni di questi campi della VLIW che rimangono scoperti verranno codificati con delle istruzioni che prendono il nome di NOP , not operation. Quindi se non ci sono cinque istruzioni da inserire nella VLIW, quelle che non ci sono vengono sostituite con una NOP. Però è chiaro che quando in un programma, per esempio di 10 istruzioni, e queste 10 compattandole in VLIW diventano non due VLIW, formate ognuna da 5 istruzioni, ma diventano 10 perché queste 10 sono tutte istruzioni ALU, cioè in ogni VLIW c’è solo un istruzione ALU e tutto il resto è NOP, ed essendoci 10 istruzioni ALU si realizzano 10 VLIW, allora si sta sprecando questa potenzialità. Si possono fare contemporaneamente 5 istruzioni però tra queste 4 non fanno nulla. 154
E’ evidente che questo approccio, VLIW, lavori molto con tecniche come quella dello srotolamento del loop, in maniera tale che avendolo srotolato il loop per un certo numero di volte si hanno a disposizione un certo numero di istruzioni da compattare in VLIW; al massimo non saranno tutti e 5 i campi del VLIW ad essere riempiti ma almeno 3 o 4 saranno riempiti. Per cui per immaginare di riempire efficacemente una VLIW bisogna spesso e volentieri partire dal codice srotolato. Quindi in questo esempio noi vediamo una scrittura srotolata di un codice che fa essenzialmente carica gli elementi di un vettore a cui somma uno scalare e poi aggiorna in memoria il nuovo valore dell’elemento.
In sostanza questo programma carica un vettore di double , fa la lettura degli elementi, e ad ogni elemento va a sommarci lo scalare F2 e poi aggiorna il vettore con il risultato. Il loop è stato srotolato 7 volte, per cui in una iterazione si caricano 7 elementi del vettore che vengono messi in 7 registri differenti, che vanno poi a essere sommati a F2 a produrre i risultati da aggiornare in memoria. Si gestisce il loop usando il puntatore R1 decrementandolo di 56 locazioni perché nello stesso loop R1 viene decrementato da 0 a 8 a 16 a 24 a 48 locazioni per cui il prossimo dato che si dovrà prelevare come primo della prossima iterazione deve essere spostato 56 locazioni rispetto al valore attuale di R1. Quindi questo decremento interesserà il registro R1 155
con l’immediato 56. L’istruzione di salto che finché R1 non è uguale a 0 salta al loop, quindi ritorna alla prima istruzione , e infine c’è l’ultima store , quella che interessa l’ultimo dato prodotto (F28). La store di F28 è stata posta dopo la bnez il solito trucco per non avere la penalità di salto. Quella bnez verrà codificata con un codice operativo che porta a termine l’istruzione schedulata dopo di lei sempre, sia se il salto si effettua e sia se non si effettua, va sempre portata a termine. Però spostare la store dopo la bnez, e quindi necessariamente dopo l’operazione di decremento di R1, fa si che l’indirizzo della store non sia -48 R1, come dovrebbe giustamente essere, ma con R1 spostato di 56 locazioni in avanti, si sottrae all’immediato 8 il valore di R1, cioè 56, ad ottenere l’indirizzo a -48. Ora bisogna prendere queste istruzioni e inserirle in un approccio VLIW, prendere 5 istruzioni e impaccarle in un'unica istruzione VLIW. Nella tabella seguente ogni riga rappresenta una singola istruzione VLIW. MEM1 L.D F0,0(R1) L.D F10,-16(R1) L.D F18,-32(R1) L.D F26,-48(R1) NOP S.D F4,0(R1) S.D F12,-16(R1) S.D F20,24(R1) S.D F28,8(R1)
MEM2 L.D F6,-8(R1) L.D F14,-24(R1) L.D F22,-40(R1) NOP NOP S.D F8,-8(R1) S.D F16,-24(R1) S.D F24,16(R1) NOP
FP1 NOP NOP ADD.D F4,F0,F2 ADD.D F12,F10,F2 ADD.D F20,F18,F2 ADD.D F28,F26,F2 NOP NOP NOP
FP2 NOP NOP ADD.D F8,F6,F2 ADD.D F16,F14,F2 ADD.D F24,F22,F2 NOP NOP NOP NOP
ALU(o branch) NOP NOP NOP NOP NOP NOP DADDIU R1,R1,#-56 NOP BNE R1,R2,loop
Ci sono 9 istruzioni VLIW, per 9 colpi di clock. In totale vengono eseguite 23 istruzioni in 9 colpi di clock, con una frequenza di emissione di 2.5 operazioni per colpi di clock. La prima istruzione utilizza due istruzioni di accesso a memoria e poi avrebbe spazio per caricare due istruzioni FP e un istruzione ALU. Dopo aver caricato F0 e F6, nelle altre due istruzioni floating point avrei potuto fare la somma di F0 e F2 in F4 e F6 e F2 in F8, ma avrei utilizzato i valori di F0 e F6 che c’erano fino a quel momento, non quelli appena caricati. Per utilizzare effettivamente F0 e F6 bisognerà aspettare che le due LOAD li abbia prodotti, cioè dopo la fase di MEM dell’istruzione LOAD, in LMD. Quindi l’uso effettivo di F0 e F6, attraverso la corto circuitazione dell’ALU con LMD, avviene dopo due colpi di clock. I campi della VLIW prive di istruzioni floating point o ALU saranno NOP. L’istruzione VLIW ha sempre 160bit, però i campi 3,4 e 5 saranno codificati ad indicare che non c’è nulla da fare. Nella seconda istruzione VLIW, ci sono due istruzioni che fanno accesso a memoria, mentre per le operazioni floating point c’è il problema di prima. Non è possibile ancora utilizzare F0 e F6, poiché non sono stati ancora prodotti. Allora anche qui si codificano queste operazioni con NOP. Nella terza essendo ancora possibile effettuare accessi a memoria, si piazzano nei primi due campi ancora istruzioni che fanno accesso a memoria. Ora nei campi floating point è possibile inserire le operazioni di addizione, poiché sia F0 e sia F6 sono usciti dalla memoria. La memoria ricordiamo essere a doppia banda. Nel campo di istruzione ALU non è possibile ancora effettuare nessuna operazione, perché effettuare qui la sottrazione del contatore o effettuare il branch sconvolgerebbe il programma. Alla quarta istruzione VLIW c’è l’ultima delle 7 LOAD da inserire nel primo campo di riferimento a memoria. Nel secondo campo si potrebbe pensare di inserire una STORE, la store di F4 o la store di F8, però F4 e F8 in 156
quel punto non sono pronti, perché si sta effettuando un operazione ADD floating point che richiede un certo numero di colpi di clock per produrre il risultato. Si possono eseguire le store a partire dalla sesta istruzione. Nella quinta istruzione seppure non si è potuto fare uso delle istruzioni di accesso a memoria, perché non è stato ancora prodotto nulla da poter scrivere con la store, comunque si possono eseguire i calcoli e quindi altre due ADD. Se il loop fosse stato srotolato per 10 volte invece che 7, in MEM1 e MEM2 ci sarebbero ancora 3 load da inserire al posto delle NOP. Attenzione che srotolare il loop per 10 volte comporta un maggiore utilizzo di registri. Completate le ADD si possono finalmente effettuare le STORE. Particolare attenzione bisogna fare sul fatto che dopo la BNEZ si pagherà uno stallo. Questa andrebbe messa come penultima istruzione, l’istruzione DSUB andrebbe posta all’istruzione 6, con opportune modifiche agli offset per il calcolo degli indirizzi nelle store. 2. Processore superscalare Un approccio più semplice è quello della macchina superscalare. La macchina superscalare parte dall’idea come la macchina VLIW che nel processore ci sono una serie di unità, e alcune di queste interessano solo alcune istruzioni. Nei processori floating point si è aggiunto dell’hardware per effettuare appunto le operazioni floating point, è chiaro che un istruzione e è interessata a fare la fase di EXE nell’ALU o è interessata a fare la fase di EXE in floating point. L’istruzione nel processore pipeline fluirà o nella parte floating point o nella parte di unità intera. Se si riesce ad avere un programma nel cui codice c’è un alternanza di istruzioni intere e istruzioni floating point e se si riesce a fare si che la memoria istruzioni in un colpo di clock legga due istruzioni alla volta, allora verranno prelevate in un colpo di clock simultaneamente due istruzioni: una intera e una floating point e queste due andranno ad interessare una la parte di unità intera e l’altra invece la parte sull’unità floating point. Si realizza una VLIW a due istruzioni.
Occorre un unità di controllo più sofisticata della macchina normale, e una memoria con una banda doppia, che permette di leggere due istruzioni in un colpo di clock. Al primo colpo di clock vengono prelevate in simultaneo due istruzioni: un istruzione ALU e una floating point visto che il codice del programma è stato schedulato in modo da avere un alternanza di istruzioni ad
157
aritmetica intera e istruzioni floating point. Queste due istruzioni vengono eseguite contemporaneamente una sull’unità intera e una sull’unità floating point, e così via. Questo meccanismo funziona nel momento in cui il compilatore ha tradotto il codice in modo che le istruzioni si alternano una intera e una floating point. Se nel codice questo non è possibile, perché non ci sono istruzioni ALU o sono meno delle istruzioni floating point, oppure perché sono bilanciate ma nascono conflitti di dato nello strutturarli in alternanza (cosa poco probabile perché istruzioni floating point e intere sono indipendenti tra loro a livello di registri) allora la macchina preleverà due istruzioni, le codificherà si accorgerà che sono entrambe istruzioni intere, ad esempio, non le potrà eseguire in simultaneo, una attenderà che l’altra termini.
158
CAPITOLO 20
PROCESSORE VETTORIALE Nel capitolo precedente abbiamo analizzato due tipi di processore, il processore VLIW e il processore superscalare. In questo capitolo focalizzeremo l’attenzione sul processore vettoriale. Consideriamo il calcolo vettoriale, il calcolo che riguarda i vettori. Il calcolo vettoriale che interessa il processore riguarda solo alcune delle operazioni sui vettori. Le operazioni vettoriali che interessano il processore vettoriale hanno questa caratteristica: -
Sono composte da operazioni scalari Sono tra di loro indipendenti
Consideriamo la somma di due vettori che produce un vettore i cui componenti sono dati dalla somma degli elementi dei primi due. La media di un vettore è un operazione che crea dipendenze, perché al primo elemento va sommato il secondo e così via per poi dividere per il numero degli elementi. Quando non c’è dipendenza tra le singole operazioni scalari che compongono la macro istruzione vettoriale si crea uno scenario interessante. Fare la somma di due vettori è interessante in quanto la somma di vettori di per se è una somma che contiene al suo interno 64 somme di scalari che non hanno dipendenze di dati, perché si effettua la somma del primo elemento del primo vettore con il primo elemento del secondo vettore, e questo non influisce sulla seconda somma. Con il processore vettoriale si ha la possibilità di codificare con una sola istruzione un calcolo notevole. Durante la somma di due vettori di 64 elementi, c’è la necessità di gestire in un loop di 64 iterazioni queste somme insieme anche alle istruzioni di gestione del loop, incremento dell’indice, istruzione salto e così via. Si può inventare un istruzione, la somma, però la somma di vettori, ad esempio, che sia la somma di 64 coppie di dati. Non è una VLIW di 64 campi, è una semplice istruzione somma, somma vettoriale. Con una singola istruzione da 32 bit si codifica tutto quello che era necessario fare con N istruzioni. add scalare: ADD.D add vettoriale: ADDV L’istruzione codifica le 64 istruzioni scalari. Queste 64 istruzioni però non devono avere conflitti di dato tra loro. Per cui ammettendo che ci sono somme floating point che nella fase EXE durino un certo numero di colpi di clock, essendo indipendenti, si possono strutturare le unità di calcolo in maniera pipeline. E l’istruzione non deve per forza terminare prima di avviare la seconda, ma nel moltiplicatore è possibile inserire altre coppie di elementi. E dopo che viene prodotto il primo risultato dal sommatore, ad esempio dopo un certo numero di colpi di clock, i successivi risultati escono dal sommatore una dopo ogni colpo di clock. Nasce l’esigenza di creare un architettura che ha senso se c’è da fare pensante calcolo vettoriale. Se esiste una classe di problemi che fa largo uso di calcolo vettoriale è preferibile utilizzare un processore vettoriale. Basti pensare all’elaborazione delle immagini, dove si effettuano operazioni che sono le stesse per ogni pixel dell’immagine. È preferibile lavorare sul vettore di pixel, se le operazioni sono indipendenti, cioè
159
quello che viene fatto sul primo pixel è indipendente da ciò che viene fatto sugli altri, e non c’è bisogno che venga prodotto il risultato prima che parti l’operazione sul secondo elemento. Per realizzare una struttura del genere è necessario quindi che i moduli di calcolo siano pipelineizzati in modo che quando entra una coppia di elementi a un certo colpo di clock, al successivo ne entri una seconda coppia, indipendente dal risultato dell’operazione della prima coppia. La potenza di calcolo non è stata raddoppiata, si sono resi pipeline i moduli di calcolo. Per la memoria è necessario che con un istruzione LOAD, load vettoriale, in memoria si legga il primo dato e successivamente i successivi. Non devono essere prelevati subito in un colpo di clock tutti gli elementi del vettore, il primo elemento deve essere prelevato dopo un certo numero di colpi di clock, latenza della memoria, e i successivi a seguire uno dopo ogni colpo di clock. La load vettoriale preleva i dati non in parallelo, ma uno dopo l’altro. Appena viene tirato fuori il primo elemento, dopo 3 colpi di clock ad esempio, al colpo di clock dopo deve essere prelevato l’altro elemento e così via. Questi elementi poi vengono sommati, per esempio, e come vengono prelevati dalla memoria vanno in ingresso al sommatore, che produrrà il risultato dopo 5 colpi di clock. Una volta prodotti i risultati questi devono essere scritti in memoria. Però la store non può cominciare se la load non ha terminato di estrarre gli elementi. Attraverso le istruzioni vettoriali si eliminano i loop. C’è una banda memoria istruzioni ottimizzata che preleva l’istruzione load vector, LV, e questa implica che bisogna leggere un certo numero di elementi dalla memoria. L’accesso a memoria dati, con la load vettoriale, prevede che sia fornito l’indirizzo del primo elemento del vettore dal quale si preleva il primo elemento dalla memoria a quell’indirizzo e poi in automatico vengono generati i successivi indirizzi adiacenti per prelevare gli elementi seguenti. Si può prevedere una memoria con banda quadrupla che richieda quattro indirizzi alla volta. C’è un modo efficiente di gestire le unità di calcolo e le unità di accesso a memoria dati, e un unità di controllo che deve supervisionare tutto. Le operazioni vettoriali lavorano su registri vettoriali, una macro struttura composta ad esempio da 64 registri, 64 locazioni. Bisogna cercare di dimensionare il numero di elementi dei registri vettoriali. Una volta fissato quel numero, si possono operare su vettori di quelle dimensioni o più piccoli. Questo numero non deve essere molto elevato, ma bisogna pensare come gestire operazioni vettoriali che interessano anche vettori di dimensioni maggiori. E’ logico pensare anche che un registro vettoriali da un elemento sia inutile, si parlerebbe di processori scalari in questo caso. Faremo uso a registri vettoriali di 64 elementi. Nel corso del capitolo faremo riferimento al processore Cray-1: un supercomputer sviluppato nel 1976 da un team di progettisti guidati da Seymour Cray per la Cray Research. Il codice assembly proposto non è riferito al MIPS quindi la sintassi potrebbe essere non chiara. 1. Architettura processore vettoriale cray-1 Perché un processore possa lavorare con vettori deve avere l’architettura del processore scalare, a cui vanno aggiunte una serie di strutture:
160
-
registri vettoriali istruzioni vettoriali compilabili ed eseguibili che si aggiungono al set di istruzioni controllo per accedere ai registri vettoriali e alle memoria unità funzionali altamente pipelineizzabili le memorie gestite con un sistema di banchi interlacciati, accedere agli elementi del vettore uno dietro l’altro con un throughtput di uno al colpo di clock non c’è gestione di cache e di memoria virtuale
Ci sono 64 registri vettoriali, due di questi accedono in input ai moduli floating point o a virgola intera e sono i due operandi dell’istruzione, poi c’è l’accesso di un risultato. Il vector mask , vettore di maschera, che ha 64 elementi è fatto da 64 flag che indicano quali degli elementi del vettore realmente devono essere processati, e a quali verrà applicata l’operazione. Un operazione con maschera tra V1 e V2 significa che non tutti gli elementi di V1 e V2 devono essere processati, ma soltanto quelli che hanno nella posizione corrispondente nel vettore maschera il bit di flag uguale a 1. Ad esempio consideriamo la divisione tra V1 e V2, e bisogna evitare di effettuare la divisione quando gli elementi di V2 sono zeri, allora si imposta un flag dove se l’elemento di V2 è uguale a zero il flag vale 0, 1 altrimenti e si effettua l’operazione mascherata. L’operazione viene comunque fatta, il fatto che il bit di maschera sia 1 o 0 serve a disabilitare o abilitare la scrittura del risultato. Quindi l’operazione con maschera non dura di meno di un non mascherata, la durata è la stessa.
161
Il registro vector lenght (VLR) contiene il numero degli elementi significativi del vettore, che non può superare i 64 elementi. Quindi con registri vettoriali da 64 elementi, si possono operare vettori da 30 elementi, impostando vector lenght a 30. Questo introduce un risparmio nella computazione, perché l’operazione si arresterà quando verrà processato il 30esimo dato del vettore. Ci sono 16 banchi di memoria da parole di 64bit. L’operazione di fetch riguarda quattro istruzioni in simultanea, vengono caricate le istruzioni da una memoria con banda di 320mln di parole/secondo e vengono caricate in quattro istruction register. Il processore ha un clock da 80 megahertz e la memoria tira fuori una parola ogni 50nanosecondi. 2. Modello di programmazione vettoriale Si strutturano le operazioni, piuttosto che in loop, in singole sequenze di istruzioni, ciascuna a codificare un operatività che riguarda tutti gli elementi del vettore, che vanno dall’indice 0 all’indice memorizzato nel registro VLR. Il codice si trova ad operare in questo modo: supponendo di voler fare la somma di due vettori, ADDV v3, v1, v2, dove ADDV è ADD vettoriale e gli operandi sono i registri vettoriali; vengono presi gli elementi di v1 e di v2 fino all’indice VLR, e si sommano a completare il vettore v3. Quello che c’è nelle altre celle, oltre l’indice VLR, non viene interessato. Queste somme inoltre vengono pipelinenizzate. Vediamo cosa succede nell’accesso a memoria in caso di Load o Store: la load carica dalla memoria un registro vettoriale, con stride. Nella macchina di cray c’è solo un istruzione load ed è con stride, non esiste LV e LVWS(load con stride), esiste solo load vector con stride. Lo stride indica la posizione dell’elemento successivo che deve essere caricato, il passo. Se non c’è bisogno di fare salti perché gli elementi da caricare sono uno di seguito all’altro, lo stride vale 0 (oppure 8 dipende dalla struttura). Nell’esempio LV v1,r1,r2, r2 rappresenta lo stride. In r1 c’è l’indirizzo del primo elemento da caricare, poi sommando r2 calcolo gli indirizzi degli altri elementi che vanno a copiarsi nelle altre celle del registro vettoriale.
162
Confrontiamo ora il codice relativo alla somma di due vettori con un processore scalare e un processore vettoriale:
Tutto il gruppo di istruzioni del codice scalare, in un codice vettoriale diventano cinque istruzioni vettoriali. Viene messo 64 in VLR, visto che i vettori sono di 64 elementi. Viene caricato in V1 il vettore il cui indirizzo è in r1, in v2 il vettore che in memoria inizia da r2, e si effettua la somma di questi due vettori in v3. Il vettore v3 viene memorizzato in memoria a partire dall’indirizzo r3. Non c’è un loop, queste istruzioni generano un eventuale stallo tra la load e la add. Perché la add deve aspettare che arrivi il primo elemento di v2, e la store deve aspettare che sia uscito il prim elemento di v3. Si può migliorare questa situazione dovuta alla memoria accedendo a v1 e v2 con una memoria con una banda passante di due dati o più. Ad esempio un accesso a memoria su tre linee, in modo da effettuare contemporaneamente una load una load e una store. Con un solo accesso a memoria invece è più efficiente immaginare il prelievo dei dati delle due load in maniera interscambiabile, cioè non prelevare prima tutto il vettore v1 e poi tutto il vettore v2, ma caricare il primo elemento di v1 poi lasciare da parte per un momento v1 e prendere il primo elemento di v2 e poi ritornare al secondo elemento di v1 e così via, in modo che arrivi sempre una coppia da elaborare al sommatore floating point. Perché funzioni bene il processore vettoriale è opportuno pensare a una memoria con una banda passante utile. Provvedere più corsie di accesso a memoria con il meccanismo delle corsie. 3. Vantaggi del processore vettoriale I vantaggi sono rappresentatoi: -
Compattezza del codice, molte meno istruzioni Le operazioni vettoriali del codice codificate in una sola istruzione devono essere indipendenti, non ci sono conflitti di dato. I dati sono indipendenti. Tutti i dati utilizzano la stessa unità funzionale, per esempio il sommatore, è possibile mettere due sommatori e quindi dividere le operazioni su due sommatori. 163
-
-
-
-
Accesso disgiunto ai registri. In simultanea si preleva da v1 e v2 i dati in maniera indipendente nel senso che si accede ai registri vettoriali indipendentemente da come si accede ad altri registri vettoriali. Ad esempio quando si esegue in simultanea ADDV v5,v1,v2 e MULV v6,v3,v4, l’accesso ai registri v1 e v2 sarà diverso dall’accesso ai registri v3 e v4, visto che i primi vanno in un sommatore, mentre gli altri in un moltiplicatore, che sarà più lento, quindi anche i risultati saranno prodotti in maniera diversa. Se non ci sono conflitti fra le unità funzionali i banchi dei registri possono essere tutti a lavoro. Accesso ai registri con la stessa struttura con cui si ha avuto accesso a quei registri nell’istruzioni che li ha riguardati. Load con VLR settato a 30, le operazioni su quel dato opereranno con VLR 30 e le store si fermeranno dopo 30 scritture. Con stride posto a 1, il caricamento avviene in maniera contigua, si accede al dato vettoriale con la tecnica analoga a quella dei blocchi della memoria(dialogo memoria centrale-memoria cache). Con un certo valore di stride, l’accesso a memoria non è a blocchi ma ha un pattern costante. Se c’è una matrice caricata per righe e si vuole un vettore colonna di questa, le colonne distano di tanti byte quanto è larga una colonna. Sono sempre quelli. Concetto di corsie, lanes. Queste corsie sono dovute al fatto che si è aumentata la banda di memoria, oppure dovute al fatto che è stato replicato dell’hardware. Se ci sono 4 moltiplicatori, si ha un accesso a 4 lanes alla batteria di moltiplicatori, e quando si dovrà fare una moltiplicazione vettoriale, i impiegherà il quarto del tempo, perché verranno inviati 4 coppie di operandi in simultaneo.
Consideriamo l’operazione V3<- V1*V2, con il modulo moltiplicatore pipelineizzato. Questa operazione dura 6 colpi di clock. Il primo elemento di V3 verrà prodotto dopo 6 colpi di clock, ma gli altri elementi verranno prodotto uno dopo ogni colpo di clock, facendo entrate in ingresso al moltiplicatore una coppia di operandi ad ogni colpo di clock. Analogamente avviene l’accesso alla memoria, con 16 banchi di memoria. Per scrivere un registro vettoriale(store), basteranno 4 colpi di clock più la latenza di scrittura. Perché al primo colpo di clock si spediscono in parallelo 16 elementi che si scrivono, già al secondo vengono altri 16 dati, quindi dopo 4 colpi di clock viene scritto tutto il registro vettoriale. Considerando la latenza di scrittura occorrono in definitiva 12 colpi di clock. L’indirizzo è generato sommando lo stride alla base, quello ottenuto è l’indirizzo, lo stesso per tutti i banchi. Al prossimo colpo di clock, questo indirizzo viene retroazionato e diviene la nuova base, e viene sommato lo stride nuovamente.
164
Consideriamo l’esecuzione in pipeline con una sola lane dell’istruzione vettoriale ADDV C,A,B: Sono già entrati A[0] e B[0] a produrre C[0] che è già uscito dal sommatore, sono entrati anche A[1] e B[1] che producono C[1], e sono entrati anche A[2] e B[2] per produrre C[2], e stanno per entrare A[3] e B[3].
Ora vediamo con una pipeline a più lane l’istruzione vettoriale ADDV C,A,B:
Ci sono 4 sommatori. Nel primo sommatore vanno A[0] e B[0], nel secondo A[1] e B[1], nel terzo A[2] e B[2] e nel quarto A[3] e B[3]. Dopo tre colpi di clock sono stati prodotti C[0], C[1], C[2], C[3], e sono entrati A[4], B[4] nel primo sommatore, A[5] e B[5] nel secondo e così via. In sostanza invece di avere una coda di 64 coppie, ci sono 4 code di 16 coppie. La corsia su cui deve viaggiare ciascuno elemento è dato da: (numero corsia) = (numero dell’elemento) mod (numero dei sommatori) Quindi l’elemento A[37] andrà nella corsia 37 mod 4 = 1, corsia 1, A[37] andrà nella corsia 1. In sostanza c’è un sistema che struttura i registri non in un unico blocco, ma in tanti blocchi quanti sono le lane. Il registro V1 è raccolto sulle quattro lanes. Questi sono anche gli elementi così come vengono fuori dalle memorie. E ora si parla di banchi paralleli, e non interlacciati, con una banda passante il quadruplo di quella del singolo banco di memoria.
165
In un processore queste strutture sono organizzare geograficamente.
Gli elementi dei registri sono collocati sulle loro corsie. 4. Processori a registri vettoriali vs processori memory-memory Un’istruzione vettoriale di una macchina memory-memory codifica al suo interno sia l’accesso a memoria e sia l’uso dell’unità funzionale. Ad esempio nella somma vettoriale, vengono indicati gli indirizzi, e questa istruzione va in memoria a prendere i dati e poi li usa per fare i calcoli. La macchina a registri invece utilizza operazioni LOAD o STORE per accedere a memoria e caricare o scrivere i registri, e poi usa l’unità funzionale con questi. Consideriamo il seguente codice: 1. for (i=0; i
Quindi il codice per una macchina a registri diventa: 1. 2. 3. 4. 5. 6.
LV V1, A LV V2, B ADDV V3, V1, V2 SV V3, C SUBV V4, V1, V2 SV V4, D
Il punto di rottura(breakeven point) indica quando diventa efficiente o no lavorare con un processore vettoriale. Questo punto di rottura è rappresentato dalla dimensione del vettore; perché risulti conveniente lavorare con un processore vettoriale, visto che questo tipo di processore necessita di un architettura non indifferente, è indispensabile conoscere la dimensione minima del vettore per cui al di sotto del quale diventa inefficiente l’utilizzo del processore vettoriale. Supponendo di aver un processore vettoriale e di lavorare con vettori di dimensione 1, tutta questa architettura è poco utile, può creare solo problemi, sarebbe meglio utilizzare un processore memorymemory o processore scalare. Diventa interessante invece lavorare con un processore vettoriale quando si lavora con vettori di certe dimensioni, in modo che inizialmente si necessita di tempo per riempire le lanes però dopo il meccanismo è abbastanza veloce. Più basso è meglio è Con un processore vettoriale con punto di rottura di 20, ad esempio, ogni qualvolta si lavora con vettori di dimensione minore di 20, si sta lavorando male con il processore vettoriale. Più è basso e più è vantaggioso. Con processore memory-memory con vettori di dimensione minore di 100 elementi è vantaggioso lavorare con processori scalari, con vettori di dimensioni maggiori di 100 ha senso lavorare con processori memorymemory. Con vettori a registri, è necessario che il vettore abbia vettori con almeno due elementi, per avere un efficienza con questo processore. Effettivamente un vettore con dimensione minore di uno è uno scalare. Dopo l’uscita del processore vettoriale a registri Cray-1, tutti i processori successivi erano processori vettoriali con registri. Nel corso del capitolo non tratteremo processori memory-memory, ma processori vettoriali a registri. 5. Vettorializzazione del codice Nelle iterazioni c’è un blocco di codice scalare che si ripete. Nella vettorializzazzione si compattano le iterazioni in maniera da analizzare la stessa operazione che viene applicata ai dati diversi, e quella stessa operazione diventa un operazione vettoriale. Tutte le load che riguardano A[1], A[2], A[3] ecc.. diventano la load vettoriale di A, (LV A). Lo stesso vale per tutte le load che riguardano B[1], B[2] ecc.. diventano la load vettoriali di B (LV B), così come add A[1], B[1] add A[2],B[2] diventano le somme di A e B, ADDV A,B e così anche per STORE C.
167
6. Vector Stripmining Una volta stabilita la dimensione massina dei dei registri vettoriali, non è detto che quella dimensione sia ottimale per ogni problema. Può esserci un problema che richiede l’utilizzo di un vettore con dimensione superiore al numero di elementi del registro vettoriale. Bisogna immaginare un modo di operare quando il vettore del problema ha una dimensione superiore a quella del registro vettoriale. L’idea alla base è quella di dividere il vettore in più parti. Ad esempio trattiamo un vettore di 150 elementi in 3 pezzi due di 64 elementi e uno di 22 elementi. Non conviene dividere il vettore in 3 pezzi da 50 elementi, tutti in parti uguali. Questo non va bene perché dividere in parti uguali comporta una minor efficienza sulle singole operazioni. C’è un altro problema in cui non sempre è possibile dividere la dimensione del vettore in parti uguali. Quello che è importante notare è che la divisione del vettore sarà sempre dello stesso tipo: un certo numero di elementi ad ampiezza massima, 64 elementi, e un residuo, e il residuo è il primo ad essere processato, e poi verranno processati i restanti blocchi da 64. Questo perché supponendo di dover prima processare i pezzi da 64 e poi il residuo, ogni volta che si processa il pezzo da 64, bisogna chiedersi quello successivo di quanti elementi è composto, se è ancora uno da 64 elementi o è il residuo? Questo calcolo consiste in una perdita di tempo. Invece se prima si elabora il residuo e poi si imposta VLR a 64, non ci si preoccupa più visto che tutti gli altri sono da 64 elementi. In sostanza si divide la dimensione per 64, e si ottengono i pezzi completi, 150/64 = 2 blocchi completi, poi il resto determina il residuo, 150 mod 64 = 22.
168
Data la dimensione del vettore, 150 elementi, in un registro, R2 ad esempio. Dividendo per 64, cioè per 26, quindi shiftando verso destra il registro di 6 posizione si ottiene: (150)10 = (10010110)2 (150/64)10 = 10010110 = (10)2 = (2)10 numero di vettori completi di 64 elementi I restanti bit, quelli shiftati sono (150 mod 64)10: (010110)2 = (22)10 dimensione del residuo. Questo si può ottenere facendo l’AND di (150) 10 con (63) 10 (maschera) (10010110) AND (00111111) = 10110 Un esempio è il seguente:
RA contiene l’indirizzo del vettore A, RB contiene l’indirizzo del vettore B, RC contiene l’indirizzo del vettore C, N è la dimensione del vettore. Attraverso ANDI R1 si ottiene la dimensione del residuo. Impostando VLR pari a R1, cioè la dimensione del residuo, la load di RA in V1, carica i primi VLR elementi, cioè quelli del residuo, in V1. R2 invece contiene la dimensione in byte degli elementi, la dimensione in byte del residuo, e quindi RA si fa puntare poi al primo elemento del vettore completo subito dopo il residuo. Lo stesso vale per RB, dopo aver memorizzato in V2 i primi VLR elementi, sempre quelli del residuo. Una volta caricati V1 e V2 si effettua la somma vettoriale in V3, a partire da RC. Bisogna aggiornare, come RA e RB anche RC, a puntare all’elemento successivo su cui scrivere, perché poi bisognerà scrivere a partire da RC. Residuo R2 RA
Vettore di 64 elementi
Vettore di 64 elementi RB
Poi bisogna aggiornare N al valore di N meno il numero di elementi già processati, R1, che inizialmente vale il residuo, poi viene settato a 64. N è la condizione per terminare il loop. Inoltre viene settato anche VLR a 64 visto che da adesso in poi tutti i vettori saranno di 64 elementi. In VLR inizialmente viene messo il residuo e dopo invece 64, perché dopo saranno sempre vettori da 64 elementi senza dover chiedere nulla.
169
Come si nota dal codice che con il vettore più lungo di 64, è necessario l’utilizzo di un loop. Ogni iterazione del loop non riguarda un elemento, ogni iterazione riguarda ogni pezzo del vettore, ci sono quindi tante iterazioni per quanti sono i pezzi cui è stato diviso il vettore. 7. Parallelismo delle istruzioni vettoriali Consideriamo il seguente codice, su vettori da 32 elementi e su 8 lanes: 1. LV V1, R1 2. MULV V3,V1,V2 3. ADDV V5,V3,V4 La load accede alla memoria con 8 corsie. All’istante 0 vengono caricati 8 dati, all’istante 1 altri 8, dopo quattro colpi di clock è stato caricato tutto il vettore. L’istruzione di moltiplicazione parte, al colpo di clock 2, quando sono stati caricati già i primi 8 dati e possono quindi già essere usati in ingresso al moltiplicatore. Con 8 lanes, ci sono 8 moltiplicatori. Per la somma avviene lo stesso discorso. In sostanza in un colpo di clock vengono completate 24 operazioni (LV, MULV e ADDV per 8 elementi).
Nell’esempio non è evidenziata la latenza dei singoli moduli, ad esempio la latenza della memoria per tirare fuori i dati. Per effettuare queste operazioni in parallelo, cioè cominciare la moltiplicazione non appena sono stati tirati fuori dalla memoria i primi dati si effettua una corto circuitazione, che conduce i dati appena fuori dalla memoria verso il moltiplicatore, e i primi risultati prodotti dal moltiplicatore sono cortocircuitati nel sommatore. Quindi i dati in ingresso al moltiplicatore o sommatore non provengono direttamente dai registro vettoriali, ma vengono concatenati(chain) attraverso una corto circuitazione di questi nei moduli appositi.
170
Quindi l’esecuzione delle istruzioni in un processore privo del vantaggio del concatenamento, comporta che bisogna attendere che i risultati di un’istruzione sia stati tutti scritti nel registro e poi si può procedere con l’istruzione successiva. Bisogna prima fare tutta la load, poi tutte le moltiplicazioni e poi tutte le somme.
Con un processore con concatenamento invece le istruzioni vengono eseguite non appena l’istruzione precedente ha prodotto i primi risultati.
L’esecuzione pipelineizzata:
Fra due istruzioni che utilizzano la stessa unità funzionale viene a crearsi un tempo morto, dovuto al fatto che quando avviene un cambiamento degli operandi dovuto a un'altra istruzione che utilizza quel moduli, il registro destinazione deve essere modificato. Se c’è un moltiplicatore con un certo numero di stadi di pipeline che fa la moltiplicazione fra 2 registri vettoriali, colpo di clock dopo colpo di clock questo accetta in ingresso le coppie di operandi e scrive il risultato su un certo registro. Terminata questa operazione, arrivata l’ultima coppia di operandi, lui non può ricevere la coppia di operandi di un'altra istruzione, perché c’è una gestione sincronizzata che scrive il risultato in un registro.
171
Consideriamo l’esempio in cui si susseguono due operazioni identiche op1 e op2 op1 v3,v1,v2 uscita collegata a v3 op1 v6,v4,v5 uscita collegata a v6
Scritto V3[N] il modulo che effettua l’operazione deve cambiare il registro di destinazione dove scrivere il risultato e quindi non può subito operare su V6[0], ma bisogna attendere un certo tempo, tempo morto. La gestione dell’hardware avviene con una serie di tabelle, all’interno del processore, stazioni di prenotazione. L’unità di controllo prima di eseguire un’istruzione, ad esempio la moltiplicazione, verifica in questa tabella se i moltiplicatori sono disponibili. Se questi non sono liberi nella tabella è scritto il moltiplicatore è impegnato dall’istruzione x che opera con determinati registri. Allora mentre il moltiplicatore è impegnato per quell’istruzione, non è possibile imporre al moltiplicatore che per altri 2 colpi di clock di scrivere il risultato in un registro e per i restanti di scrivere in un altro registro. Bisogna aspettare che la prima istruzione venga scollegata, al termine allora l’unità funzionale potrà fare un’altra operazione. La dead time dura quanto la latenza dell’unità funzionale. Se l’unità funzionale è a 18 stadi, si attenderanno 18 colpi di clock. Quindi questo numero deve essere un buon numero. Più sono gli stadi maggiore è la frequenza con cui posso inserire gli elementi nell’unità funzionale, però maggiore è la latenza e la durata della dead time. Il dead time è critico con vettori corti. Perché si ha un operazione che è efficiente in termini di durata e poi c’è la dead time notevole. Se il vettore ha una coppia sola di operandi l’oggetto si tiene bloccato come se non è pipeline e dopo si può inserire l’atra coppia. La macchina di Cray invece gestisce bene la situazione eliminando la dead time consentendo lo smistamento del risultato anche durante l’input della prossima coppia di dati dell’istruzione successiva. Così anche vettori con pochi elementi sono gestiti in maniera efficiente.
172
8. Alcune tipiche operazioni con i vettori Consideriamo il seguente codice: 1. for(i=0; i
LV vB, rB LVI vA, rA, vB ADDV vA, vA, 1 SVI vA, rA, vB
Si carica in vB il vettore che parte da rB. All’istruzione due si effettua una load indicizzata, in cui si carica in vA gli elementi di A, ma solo gli elementi indicizzati dal vettore vB caricato prima. All’istruzione tre c’è la somma vettoriale con scalare, avviene l’incremento, e infine la store indicizzata dove si memorizzano solo gli elementi indicizzati da vB. Consideriamo un altro codice dove è presente una codinzione: 1. for (i=0; i0) then 3. A[i] = B[i]; Il codice si traduce nel seguente: 1. 2. 3. 4. 5.
CVM LV vA, rA SGTVS.D vA, F0 LV vA, rB SV vA, rA
Alcuni elementi di A, quelli negativi, saranno modificati da B. Si crea un operazione mascherata; la maschera sarà uguale a 1 per gli elementi in cui A è maggiore di zero e poi condizionato a quella maschera verrà fatta la copia dell’elemento i-esimo di B nell’elemento i-esimo di A. L’elemento di B va in A solo se il vettore maschera è uguale a 1. Dopo aver caricato in vA il vettore A, si setta il vettore maschera attraverso l’operazione, SGTVS.D vA, F0 (set greather than vector scalar). Se l’elemento i-esimo del vettore è più grande dello scalare F0 (F0 contiene 0 nei registri floating point), setta l’i-esimo elemento del vettore maschera altrimenti viene resettato. La load successiva avviene mascherata. Il vettore maschera contiene dei flag, quindi dei bit per indicare si o no. Quando c’è lo zero nella maschera l’istruzione è sostituita con una NOP. Quindi l’istruzione quattro, LV vA,rB, che fa la copia mascherata del vettore vB, indirizzato da rB, in vA, quando la maschera conterrà 1 copierà l’elemento di B in A, quando trova zero, è NOP in sostanza, quindi non farà la copia. L’operazione mascherata non termina prima, dura quanto l’istruzione normale: sempre 64 colpi di clock. 173
La load dell’indirizzo rB in vA leggerà la memoria e scriverà in A solo se il flag è uno altrimenti esegue la NOP e quindi rimane il vecchio valore. Con la store termina. Il bit di maschera abilita eventualmente la scrittura del dato. L’operazione viene comunque eseguita. 9. Riduzione a scalare dei vettori Abbiamo visto che se le operazioni sono indipendenti possono essere eseguiti in pipeline. Se ci sono invece relazioni vettoriali, come ad esempio nel prodotto scalare, in cui dopo aver fatto il prodotto degli elementi omologhi dei due vettori, questi risultati vanno sommati tra loro, la somma di tutti gli elementi di un vettore non è lo stesso che fare la somma di due elementi di due vettori, questa somma crea una dipendenza tra i dati. Si effettua in questo caso la riduzione a scalare. Questa tecnica si utilizza il più delle volte nel fare la somma degli elementi di un registro vettoriale. Consideriamo il prodotto scalare tra due vettori, V1 e V2, di N elementi. Si crea un nuovo vettore V3 = V1*V2. Ora gli elementi di V3 devono essere sommati tra di loro in un accumulatore. Si può sfruttare la potenza del processore vettoriale pensando di dividere il vettore V3 a metà in due vettori, V3’ e V3” e fare la somma tra questi due vettori come se fossero indipendenti, V4 = V3’+V3”. Impostando quindi VLR pari a N/2. Si produce quindi un vettore con N/2 elementi ottenuto di fatto da N/2 somme scalari. Il vettore ottenuto, V4, viene ancora diviso a metà in due vettori, V4’ e V4” e fare una nuova somma vettoriale, con elementi che questa volta sono di N/4, e via dicendo. La riduzione a scalare viene fatta suddividendo di volta in volta a metà i risultati di un primo step fino ad arrivare alla fine ad avere un elemento di due elementi, che spezzettato in due vettori di un elemento ciascuno, sommati forniscono il risultato finale. Questo spezzettamento del vettore è utile portarlo avanti fino al punto di breakeven point.
174
10. Set istruzioni del processore VMIPS Set di istruzioni del processore MIPS vettoriale.
Nelle istruzioni si nota la presenza della lettera S in alcune a indicare che uno degli operandi dell’istruzione è uno scalare, registro scalare, che è lo stesso per tutti gli elementi del vettore. L’operazione SUB non essendo commutativa c’è l’istruzione vettore meno scalare, e scalare meno vettore, questo vale anche per la DIV. Tutte queste istruzioni hanno come registro destinazione un registro vettoriale. Le operazioni di accesso a memoria, necessita l’indirizzo del primo elemento del vettore, poi i successivi indirizzi vengono calcolati da questo attraverso un unità dedicata. L’operazione dura 64 colpi di clock, quanto la dimensione del vettore, a meno di un aumento delle corsie. Ad esempio con 4 corsie sono necessari 16 colpi di clock. Tra queste operazione c’è anche quello con lo stride, LVWS (WS, with stride) o SVWS. Queste operazioni indicano che gli elementi da caricare non sono contigui, uno di seguito agli altri, ma bisogna caricare gli elementi che sono separati uno dall’altro di 100byte. Questo avviene quando si ha una matrice in memoria caricata per righe, e se serve caricare una colonna di questa matrice, gli elementi 175
della colonna distano tra loro un certo numero di byte. C’è uno spostamento regolare degli elementi in memoria. Infine ci sono le operazioni di accesso a memoria indicizzate, vanno a indicizzare in un altro vettore gli offset dove andare a memorizzare il vettore. Questo nasce dall’esigenza di caricare alcuni elementi di un vettore, gli indirizzi di questi elementi del vettore sono memorizzati in un vettore, vettore indice. Se gli spostamenti sono irregolari gli indirizzi degli elementi sono in un vettore indice. Questo approccio è utilizzato con le matrice sparse, matrici con molti zeri, e per trattare i numeri diversi da zero si tiene tracci della loro posizione. L’istruzione CVI crea il vettore indice. Le istruzioni di compare fanno il confronto fra due vettori, o fra il vettore e uno scalare. L’istruzione POP conta gli elementi del vettore pari a 1 nel registro maschera e il risultato va in R1. L’istruzione CVM setta a uno il registro maschera. E infine ci sono le istruzioni per lo spostamento da registri speciali a registri normali e viceversa. Un registro speciale è il registro VLR che indica al sistema quanti elementi del registro vettoriale ha effettivamente senso utilizzare. Il registro vettoriale ha 64 elementi, però se il problema specifico da affrontare lavora con vettori di 20 elementi, di questi 64 elementi ne servono solo 20. Quindi durante le operazioni settando VLR a 20, si lavora su un numero di elementi pari a 20, VLR. In VLR si scrive il valore che è la dimensione reale del vettore, è chiaro che non è possibile scrivere un numero maggiore di 64.
176
Esercizi Esercizio n.1 Dati due vettori A e B di N elementi double, con N<=64 e memorizzato nel registro R6. L’indirizzo del primo elemento di A è memorizzato nel registro R7; mentre B è il vettore riga di una matrice NxM, caricata in memoria per righe, dove il valore di M è nel registro R5. L’indirizzo del primo elemento di B, corrispondente all’elemento della matrice, è nel registro R8. Considerato inoltre lo scalare q nel registro F8, calcolare e memorizzare, attraverso un processore vettoriale, a partire dall’indirizzo nel registro R9 il vettore C=Aq +B. a. Supponendo che ci siano 1 lane, e che la latenza del moltiplicatore sia pari a 6 c.c. e la latenza del sommatori pari a 2 c.c. Effettuare l’analisi temporale delle istruzioni scritte prima. b. Ripetere l’analisi temporale considerando 4 lanes. c. Scrivere il codice per un processore scalare, effettuare l’analisi temporale e confrontare con quelle fatte ai punti a e b. Soluzione: 1. MTC1 VLR, R6 //VLR <- N 2. DSLL R5,R5,3 //R5<-M*8 3. LV V1, R7 //carico gli N elementi di A in V1 4. LVWS V2,(R7,R5) //carico gli B con stride(in byte) 5. MULVS.D V3,V1,F8 //V3 = A*q 6. ADDV.D V4,V3,V2 //V4 = A*q + B 7. SV V4,R9 a. Analisi temporale con 1 lane, considerando la latenza del moltiplicatore pari a 6 c.c. e quella del sommatore pari a 2 c.c. Inoltre consideriamo una banda tripla per lane, in modo tale da poter accedere alla memoria con tre istruzioni di tipo load o store contemporaneamente. Effettuiamo l’analisi temporale considerando vettori di 64 elementi, N = 64. MTC1 DSLL LV LVWS MULVS.D ADDV.D SV
1
2
3
4
5
IF
ID IF
EXE ID IF
MEM EXE ID IF
WB MEM EXE ID IF
6
7
8
…
…
…
…
…
…
…
69
70
71
…
…
78
…
30
WB EXE ID IF
ID IF
EXE (6 c.c.) stallo stallo EXE stallo
stallo ID
MEM (64 c.c.) WB MEM (64 c.c.) WB EXE succ. elementi estratti dalla memoria (63 c.c.) MEM WB stallo stallo EXE (2 c.c.) EXE succ. elementi prodotti dal moltiplicatore (63 c.c.) stallo stallo stallo stallo MEM (64 c.c.)
MEM
WB
Sono necessari 78 colpi di clock. La fase di EXE della MULVS.D e della ADDV.D sono più lunghe perché processano tutte gli altri dati che vengono prelevati dalla memoria, oppure prodotti dal moltiplicatore nel caso della ADDV.S . Essendo pipelineizzate queste producono il primo risultato dopo 6 c.c. per quanto riguarda il moltiplicatore e invece dopo 2 c.c. per il sommatore, però i risultati successivi sono prodotti uno ad ogni colpo di clock. Considerando 4 lanes invece, MTC1 DSLL LV LVWS MULVS.D ADDV.D SV
1
2
3
4
5
IF
ID IF
EXE ID IF
MEM EXE ID IF
WB MEM EXE ID IF
6
7
8
…
…
21
21
23
…
WB EXE ID IF
MEM (16 c.c.) MEM (16 c.c.) ID IF
stallo ID
EXE (6 c.c.) stallo stallo EXE stallo
stallo stallo
stallo stallo
WB WB EXE succ. elementi estratti dalla memoria ( 15 c.c.) MEM WB EXE (2 c.c.) EXE succ. elementi prodotti dal moltiplic. (15 c.c.) stallo stallo MEM (16 c.c.)
Sono necessari 30 colpi di clock, che non sono come vediamo un quarto dei c.c. con 1 lane. b. Considerando un processore scalare, il codice ottimizzato potrebbe essere:
177
MEM
WB
1. 2. 3. loop: 4. 5. 6. 7. 8. 9. 10. 11. 12.
DADDI R1, (R6)0 DSLL R5,R5,8 L.D F1,R7 L.D F2,R8 MUL.D F3,F1,F8 DADDI R7,(R7)8 DADD R8,R8,R5 ADD.D F4,F3,F2 S.V F4,R9 DADDI R1,(R1)-1 BNEZ R1, loop DADD R9,R9,8
//R1<- N //R5 <- M*8 //A[i] //B[i] //A[i]*q
//C[i] = A[i]*q+B[i]
Il diagramma temporale
DADDI DSLL L.D L.D MUL.D DADDI DADD ADD.D S.V DADDI BNEZ DADD
1
2
3
4
5
IF
ID IF
EXE ID IF
MEM EXE ID IF
WB MEM EXE ID IF
6
WB MEM EXE ID IF
7
8
WB MEM
WB
ID IF
EXE ID IF
9
10
EXE (6 c.c.) MEM WB EXE MEM ID stallo IF ID IF
11
WB stallo EXE ID IF
12
stallo stallo EXE ID IF
13
14
MEM
WB
EXE (2 c.c.) stallo stallo MEM WB EXE MEM ID EXE
15
16
MEM MEM
WB
WB MEM
WB
Sono necessari 14*N+2 c.c. Considerando N=64, sono necessari 898 c.c. Possiamo inoltre svolgere questo con la pipeline da programma: 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. 16. 17. 18. 19. 20.
DADD R10,R0,R6 DADDI R4,(R0)2 DSLL R5,R5,3 L.D F1,(R7)0 L.D F2,(R8)0 L.D F3,(R7)8 DADD R8,R8,R5 L.D F4,(R8)0 MUL.D F5,F1,F8 ADD.D F6,F1,F2 DADDI R7,(R7)16 DADD ---------------------------------------loop: S.D F6,(R9)0 MUL.D F5,F3,F8 ADD.D F6,F5,F4 L.D F3,(R7)0 L.D F4,(R8)0 DADDI R7,(R7)8 DADD R8,R8,R5 DADDI R9,(R9)8 178
R8,R8,R5
21. 22.
ADDI R10,(R10)-1 BNEQ R10,R4, ---------------------------------------S.D F6,(R9)0 MUL.D F5,F3,F8 ADD.D F6,F5,F4 S.D F6,(R9)8
23. 24. 25. 26.
loop
Esercizio n. 2 Ripetere l’esercizio 1, per un processore vettoriale, con il vettore B caricato in maniera adiacente, e considerando N>64. Soluzione: 1. 2. loop: 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14.
ANDI R1,R6,63 //R1<-N mod 64 MTC1 VLR,R1 //VLR <- R1 L.V V1,R7 //V1<- A[R5/8÷&A[0]] L.V V2,R8 //V1<- B[R5/8÷&A[0]] MULVS.D V3,V1,F8 //V3<-A*q DSLL R5,(R1)8 //R5= dim. byte del pezzo di vettore DADD R7,R7,R5 //aggiorno R7 al nuovo elemento di A DADD R8,R8,R5 //aggiorno R8 al nuovo elemento di B DSUB R6,R6,R1 //N<- N –(dim. Vettore processato) DADDI R1,(R0)64 //R1 <-64 ADDV.D V4,V3,V2 //V4<- Aq +B S.V V4,R9 BNEZ R6,loop DADD R9,R9,R5 //aggiorno R9 al nuovo elemento di C
Ci sono tante iterazioni per quanti pezzi è stato diviso il processore più l’iterazione dovuto alla computazione del resto, N/64 +1. a. Fissato N=1000, indicare quanto dura la computazione, considerando che ci sono 4 lanes e che la latenza del moltiplicatore è di 6 c.c., mentre quella del sommatore è 2 c.c. b. Ripetere la computazione con 1 lane. Soluzione a. N = 1000, con 4 lane ANDI MTC1 L.V L.V MULVS.D DSLL DADD DADD DSUB DADDI ADDV.V S.V BNEZ DADD
IF
ID IF
EXE ID IF
MEM EXE ID IF
WB MEM EXE ID IF
WB EXE ID IF
ID IF
EXE ID IF
MEM ( 10 c.c. / 16 c.c.) MEM (10 c.c. / 16 c.c.) EXE (6 c.c.) MEM WB EXE MEM WB ID EXE MEM WB IF ID EXE MEM IF ID EXE IF ID IF
179
WB WB EXE elementi successivi prelevati dalla memoria (9 c.c. / 15 c.c.) MEM
WB MEM WB EXE (2 c.c.) ID stallo IF ID IF
WB
EXE elementi successivi prodotti dalla moltiplicazione (9 c.c. / 15c.c.) MEM EXE MEM (10 c.c. / 16 c.c.) stallo EXE MEM WB ID stallo EXE MEM WB
WB
b. N = 1000, considerando 1 lane. Le fasi di MEM durano
c.c, quindi 40 c.c. per la prima
iterazione e 64 c.c. per le successive. ANDI MTC1 L.V L.V MULVS.D DSLL DADD DADD DSUB DADDI ADDV.V S.V BNEZ DADD
IF
ID IF
EXE ID IF
MEM EXE ID IF
WB MEM EXE ID IF
WB EXE ID IF
ID IF
EXE ID IF
MEM ( 40 c.c. / 64 c.c.) MEM (40 c.c. / 64 c.c.) EXE (6 c.c.) MEM WB EXE MEM WB ID EXE MEM WB IF ID EXE MEM IF ID EXE IF ID IF
WB WB EXE elementi successivi prelevati dalla memoria (39 c.c. / 63 c.c.) MEM
WB MEM WB EXE (2 c.c.) ID stallo IF ID IF
WB
EXE elementi successivi prodotti dalla moltiplicazione (39 c.c. / 63c.c.) EXE MEM (40 c.c. / 64 c.c.) stallo EXE MEM WB ID stallo EXE MEM WB
MEM
WB
Sono necessari N/64 = 16 +1 iterazioni. Per un totale di 18 c.c. ad iterazione, sono necessari 17*18+1 = 307c.c. Esercizio n.3 Considerando i dati dell’esercizio 1, dato il vettore A di N elementi caricato in memoria a partire dall’indirizzo memorizzato in R7. Il valore di N è nell’indirizzo R6. Considerato lo scalare q in F8, determinare il vettore C=A*q, sapendo che il vettore C è caricato in memoria a partire dall’indirizzo memorizzato in R9. Si effettui il calcolo su un processore scalare utilizzando la tecnica del VLIW, formata da: -
2 operazioni di accesso a memoria; 2 operazioni per il calcolo floating point 1 operazione di ALU o di branch
Inoltre l’architettura prevede: -
2 moltiplicatori Larghezza di banda tale da prelevare due dati simultaneamente
Soluzione: Un possibile svolgimento dell’algoritmo potrebbe essere il seguente: 1. DADD R1,R6,R0 //R1<- N, contatore 2. loop: L.D F1,(R7)0 //F1<- &A[i], i=N-R1 3. MUL.D F1,F1,F8 //F1<- A[i]*q 4. S.D F1,(R9)0 //M[R9]<-F1 5. DADDI R7,(R7)8 //incremento R7 alla nuova pos 6. DADDI R9,(R9)8 // incremento R8 alla nuova pos 7. DADDI R1,(R1)-1 //decremento R1 8. BNEZ R1,loop (-7) Un ottimizzazione del codice potrebbe essere: 1. DADD R1,R6,R0 2. loop: L.D F1,(R7)0 3. MUL.D F1,F1,F8 4. DADDI R7,(R7)8 5. DADDI R1,(R1)-1 6. S.D F1,(R9)0
//R1<- N, contatore //F1<- &A[i], i=N-R1 //F1<- A[i]*q //incremento R7 alla nuova pos //decremento R1
180
7. 8.
BNEZ R1,loop (-6) DADDI R9,(R9)8 // incremento R8 alla nuova pos
Per una durata pari a 1+11*N c.c. Per eseguire il VLIW procediamo con lo srotolamento del loop per 8 iterazioni, evitando in questo modo il più possibile gli stalli: 1. 2. loop: 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. 16. 17. 18. 19. 20. 21. 22. 23. 24. 25. 26. 27.
DADD R1,R6,R0 //R1<- N, contatore L.D F1,(R7)0 //F1<- &A[i], i=N-R1 L.D F2,(R7)8 //F1<- &A[i+1] L.D F3,(R7)16 //F1<- &A[i+2] L.D F4,(R7)24 //F1<- &A[i+3] L.D F5,(R7)32 //F1<- &A[i+4] L.D F6,(R7)40 //F1<- &A[i+5] L.D F7,(R7)48 //F1<- &A[i+6] L.D F9,(R7)56 //F1<- &A[i+7] MUL.D F1,F1,F8 //F1<- A[i]*q MUL.D F2,F2,F8 //F1<- A[i+1]*q MUL.D F3,F3,F8 //F1<- A[i+2]*q MUL.D F4,F4,F8 //F1<- A[i+3]*q MUL.D F5,F5,F8 //F1<- A[i+4]*q MUL.D F6,F6,F8 //F1<- A[i+5]*q MUL.D F7,F7,F8 //F1<- A[i+6]*q MUL.D F9,F9,F8 //F1<- A[i+7]*q S.D F1,(R9)0 S.D F2,(R9)8 S.D F3,(R9)16 S.D F4,(R9)24 S.D F5,(R9)32 S.D F6,(R9)40 S.D F7,(R9)48 S.D F9,(R9)56 DADDI R7,(R7)72 DADDI R1,(R1)-9 //decremento R1 per il
numero
di
volte
//in cui ho srotolato il loop
28. 29. C.C. 1 2 3 4 5 6 7 8 9 10 11 12
BNEZ R1,loop DADDI R9,(R9)72
MEM1
MEM2
L.D F1,(R7)0 L.D F3,(R7)16 L.D F5, (R7)32 L.D F7,(R7)48
L.D F2,(R7)8 L.D F4,(R7)24 L.D F6,(R7)40 L.D F9,(R7)56
FP1
FP2
ALU(o branch) DADD R1,R6,R0
S.D F1,(R9)0 S.D F3,(R9)16 S.D F5,(R9)32 S.D F7,(R9)48
MUL.D F1,F1,F8 MUL.D F3,F3,F8 MUL.D F5,F5,F8 MUL.D F7,F7,F8
S.D F2,(R9)8 S.D F4,(R9)24 S.D F6,(R9)40 S.D F9,(R9)56
MUL.D F2,F2,F8 MUL.D F4,F4,F8 MUL.D F6,F6,F8 MUL.D F9,F9,F8
DADDI R7,(R7)64 DADDI R1,(R1)-8
BNEZ R1, loop DADDI R9,(R9)64
181
Per un totale di 1+11*(N-8) c.c. In 12 colpi di clock vengono processate 29 istruzioni, con una frequenza pari a 2.41 istruzioni per colpo di clock.
Esecizio n.4 Considerando i dati dell’esercizio 1, dati due vettori A e B, di N elementi, scrivere il codice per eseguire C=A/B, evitando divisioni per 0; poi salvare C in memoria. Il processore ha una lane e la latenza del divisore è di 20 c.c.
Svolgimento: 1. 2. 3. 4. 5. 6. 7.
MTC1 VLR,R6 //VLR<- N DSLL R5,R5,3 //R5 è lo stride in byte per caricare gli elementi di B LV V1,R7 LVWS V2,R8,R5 //stride:byte di cui spostarsi SNEV.D V2,F0 //setto la maschera sugli elementi diversi da 0 DIVV.D V3,V1,V2 //effettuo la divisione mascherata: elementi di V2 diversi da S.V V3,R9
0
Un analisi temporale del programma è la seguente: MTC1 DSLL LV LVWS SNEV.D DIVV.D S.V
1
2
3
4
5
IF
ID IF
EXE ID IF
MEM EXE ID IF
WB MEM EXE ID IF
6
69
70
70
WB EXE ID IF
EXE ID IF
MEM ID
MEM(64 c.c.) WB MEM(64 c.c.) WB WB EXE (20 c.c.) EXE elementi successivi prelevati dalla memoria (63 c.c.) stallo (19 c.c.) EXE MEM(64 c.c.)
MEM
WB
Per un totale di 92 c.c. Esecizio n. 6 Dato il vettore A, con N=64 elementi, e la matrice H di NxM elementi, con M =64. Determina la matrice C=A*q + B, dove B è un vettore colonna di H. Le colonne di C sono calcolate attraverso le colonne di H: Cji =A*q+Bi. Per determinare la colonna i-esima di C, B punta alla colonna i-esima di H. Il valore di M è in R5, il valore di N è in R6. L’indirizzo del primo elemento di A è in R7, l’indirizzo del primo elemento della matrice H è in R8. L’indirizzo del primo elemento di C è in R9. La matrice H e la matrice C sono caricate in memoria per righe. Svolgimento: 1. 2. 3. 4. 5. loop: 6.
LV V1,R7 //V1<- A[0÷63] DADDI R2,(R0)64 //R2 contatore loop MULVS.D V1,V1,F8 //V1<-A*q SV V1,R7 //sovrascrivo in mem al posto di A, A*q LV V2,R8 //V2<- B: elementi della i-esima riga di H L.D F1,R7 //R1<- A[i]*q, lo utilizzo come scalare 182
7. 8. 9. 10. 11. 12. 13. LV DADD MULVS.D SV LV L.D DADDI ADDVS.D DADDI DADDI SV BNEZ DADDI
DADDI R7,(R7)8 //i++ ADDVS.D V2,V2,F1 //V2(j)<-V2(j)+ (A[i]*q) DADDI R8,(R8)512 //riga successiva DADDI R2,(R2)-1 SV V2,R9 BNEZ R2, loop DADDI R9,(R9)512 //riga successiva IF
ID IF
EXE ID IF
MEM (64 c.c.) EXE ID IF
MEM
WB
ID IF
stallo ID IF
EXE (6 c.c.) stallo stallo EXE ID EXE IF ID IF
WB elementi succ. prelevati dalla memoria (63 c.c.) MEM EXE MEM (64 c.c.) MEM (64 c.c.)
stallo
stallo
MEM EXE ID IF
WB MEM WB EXE (2 c.c.) ID EXE IF ID IF
Per un totale di 4+64*(13) c.c.
183
MEM EXE ID IF
WB WB
EXE succ. elementi (63 c.c.) WB MEM WB EXE MEM (64 c.c.) ID EXE MEM WB IF ID EXE MEM WB
MEM
WB
184
CAPITOLO 21
TASSONOMIA DI FLYNN 1. Classificazione dei processore secondo Flynn Flynn classifica i sistemi di calcolo a seconda della molteplicità del flusso di istruzioni e del flusso dei dati che possono gestire. Combina questi flussi secondo l’aspetto se sono singoli o multipli a creare una serie di possibili macchine di calcolo. -
-
-
-
SISD (Single Istruction Single Data), non un singola istruzione, ma singolo flusso di istruzioni, e singolo insieme di dati. Un insieme di dati sui quali si applica un unico processore. Uniprocessore (processore monotask), che dati dei dati e un programma esegue solo quel programma su quei dati. SIMD (Single Istruction Multiple Data) su un set multiplo di dati si esegue lo stesso flusso di istruzioni. La stessa applicazione su un set multiplo di dati, e questo può essere realizzabile con processori in parallelo. Ad esempio data una serie di immagini sulle quali bisogna effettuare un equalizzazione. Il flusso di istruzioni è lo stesso, l’equalizzazione, e su tutte queste immagini l’operazione da fare è la stessa. Si può pensare a più processori in parallelo sui quali caricare lo stesso codice e ciascun processore andrà a leggere il suo set di dati. MIMD (Multiple Istruction Multiple Data) più flussi di istruzioni su più set di dati. Su un processore viene fatta una certa operazione su determinati dati, su un altro processore invece vengono eseguite operazioni completamente diverse su dati ancora diversi. Questo sistema non richiede interconnessioni dato che ogni processore ha il suo flusso di dati e il suo codice da eseguire. Trova interesse nei clusters, un insieme di computer connessi tramite una rete telematica. Nelle workstation in rete a cui un utente si collega e comincia ad effettuare delle operazione con i suoi dati e intanto altri utenti eseguono altre operazioni con i loro dati differenti. MISD (Multiple Istruction Single Data) sugli stessi dati bisogna eseguire diversi algoritmi. Potrebbe avere senso pensare a un sistema che con un unico set di dati suddiviso fra i vari processi da fare su unità di calcolo differenti. Questo approccio non ha diffusione poiché non è semplice avere casi reali di questo genere e non è interessante dal punto di vista architetturale. (Questa architettura è a livello teorico privo di utilizzo pratico).
Dal punto di vista architetturale le macchine SIMD sono le più interessanti, elaborano un task su più dati in parallelo. Le schede video sono l’esempio più recente di macchine SIMD. La programmazione multithread simula un sistema MIMD. 2. Concetti base -
-
Parallel Computer: insieme di diversi elementi distinti, diverse CPU, che cooperano per risolvere lo stesso problema in maniera più veloce oppure risolvono un macroproblema comunicando tra di loro. Un architettura parallela prevede una definizione dell’architettura di comunicazione fra i vari nodi di questo sistema. Multiprocessori a memoria centralizzata: la memoria centralizzata risponde alla definizione della comunicazione. Non ci si preoccupa della comunicazione 185
fra i vari processore, la memoria essendo visibile a tutti i processori, lo scambio di informazioni avviene sulla memoria centrale. Questo comporta che la memoria centrale abbia una banda tale da non penalizzare un accesso simultaneo alla memoria di più processore. Nella realtà i sistemi a memoria centralizzata non hanno un grande numero di core. Esistono strutture ibride in cui gruppi di processori lavorano con la loro memoria centralizzata che ha una infrastruttura di comunicazione
-
con altri cluster. Memoria distribuita: un sistema che vede ad ogni processore un assegnazione di memoria che essendo privata non ha problemi di banda, poiché a quella memoria accede solo quel processore e bisogna far si che la comunicazione avvenga a livello di connessione tra questi moduli. Ciascun processore ha la sua memoria, e tutti questi moduli hanno un infrastruttura che li connetta.
Su un approccio SIMD è preferibile utilizzare un approccio con memoria distribuita. Il software va re ingegnerizzato dovuto al fatto che c’è maggiore banda di memoria che deve essere sfruttata.
186
CAPITOLO 22
INFRASTRUTTURE DI COMUNICAZIONE 1. Bus Il modo più semplice per mettere in comunicazione due nodi dal punto di vista dell’espandibilità e del costo è il bus (“omnibus”, per tutti). Il bus è un canale su cui tutti hanno diritto a stare. Il problema è che su questo canale c’è un traffico massimo dato dalla banda del canale. Se c’è un dispositivo che sta scrivendo sul bus, nessun’altro può scrivere. Però è possibile leggere in simultanea. Se due dispositivo voglio entrambi scrivere sul bus, c’è un dispositivo che funge da master del bus che da il permesso ai dispositivi di scrivere. Esistono delle priorità di accesso alla risorsa, e chi è più vicino al master si vedrà offrire per primo la possibilità di scrivere per primo, mentre chi è più lontano potrà scrivere solo se quelli prima di lui non stanno scrivendo.
C’è una espandibilità teoricamente infinita. Si possono attaccare sul bus infiniti dispositivi. Un dispositivo per essere collegato al bus è necessario che abbia una porta bidirezionale in lettura e scrittura. Però è possibile che molti dispositivi non potranno mai scrivere sul bus per il motivo delle priorità, quindi è come se non ci fossero. Il problema è legato quindi all’efficienza, poiché si occupa una banda vincolando tutti gli altri dispositivi. 2. Rete punto-punto Dato un certo numero di sistemi e questi sono tutti collegati tra di loro. Questa connessione dal punto di vista delle prestazioni è molto efficiente, ci sono un certo numero di connessioni ognuno con la sua banda. Se due nodi A e B dialogano con una banda elevata non crea alcun disturbo magari a C e D che stanno anche dialogando tra loro. Questa sembra una comunicazione ideale. Il problema è che questa comunicazione non è espandibile. Poiché quando si inserisce un nodo comporta che questo deve essere collegato con tutti gli altri. Ogni sistema ha un certo numero di porte che permette di connettersi agli altri sistemi, per cui si deve avere un numero di porte capace di sostenere tutte le connessioni con tutti i nodi. Questa interfaccia la deve avere ogni sistema. Quindi quando si progetta questo sistema bisogna stabilire il numero massimo di elementi che possono essere connessi. L’espandibilità è destramente ridotta. Deciso il numero di porte, si possono avere tante connessioni quante sono le porte. In un infrastruttura con N nodi, ogni nodo ha N-1 porte. Quante connessione ci sono? Il primo nodo viene collegati agli N-1 nodi. Il secondo nodo dovrà essere collegati agli N-1 nodi, però la connessione verso il 187
primo c’è già, quindi N-2. Il terzo dovrà collegarsi verso tutti tranne che con il primo e il secondo, N-3. Il penultimo dovrà collegarsi con l’ultimo, mentre l’ultimo non dovrà collegarsi più con nessuno visto che sono già tutti collegati. Ora le connessioni totali sono la somma di tutti questi
Quindi la rete ha
connessioni.
Con questo tipo di rete quando si invia un dato, questo arriva subito al destinatario, in un passaggio, “in un solo colpo”. 3. Rete ad anello L’anello(ring) prevede che ogni nodo sia connesso solo a una coppia di nodi. L’anello rispetto al bus prevede una situazione diversa dal bus in cui mentre un dispositivo “parla”, gli altri non possono farlo. Nell’anello le linee di connessione sono indipendenti, quindi nascono vantaggi dal punto di vista della comunicazione concorrente, nello stesso istante ci sono più comunicazioni. C’è un vantaggio dovuto al fatto che il sistema è più espandibile rispetto alla rete punto-punto. Ogni nodo ha sempre due porte. Il problema è di natura pratica, perché se l’anello è molto grande, succede che due nodi che vogliono parlare, la comunicazione prevede un certo numero di tratta da attraversare. Distinguiamo: -
Ring monodirezionale: la comunicazione avviene solo in un senso. Ring bidirezionale: la comunicazione avviene nei due versi, orario e antiorario.
Sul ring sia invia un dato con un indirizzo, numero del nodo destinatario. Questo viaggia sul canale, arriva a un certo nodo, legge dal numero che il dato non riguarda lui e lo invia in uscita. Questo si ripete fino a quando il dato non arriva al nodo destinatario corretto. Quindi c’è un certo numero di passaggi per arrivare al destinatario. Che dipende da dove si trova il nodo destinatario. Per un ring monodirezionale, in media supponendo le comunicazioni equiprobabili ci sono N/2. Se invece la frequenza di invio dati ha una preferenzialità su alcuni nodi, e questi sono anche vicini, allora la probabilità che duri 1 è alta e il valore medio è più basso N/2. Con un ring bidirezionale, nell’ipotesi più peggiore, il destinatario si trova a N/2, quindi in media ci sono N/4 passaggi. Per cui c’è un costo più basso, poiché con N nodi, sono necessari N linee di connessione, contro quelle della rete punto-punto. Però la comunicazione avviene in N/2 passaggi o N/4 se bidirezionale.
188
4. Rete a centro stella Meccanismo di routing, in cui ciascun nodo è collegato un nodo centrale, centro stella. Ogni elemento ha una porta di ingresso-uscita collegato al centro stella. In questo modo c’è una massima espandibilità. Il centro stella deve inviare un pacchetto che arriva da un nodo al nodo destinatario.
5. Reti ibride Il ring è poco costoso e abbastanza efficiente con pochi elementi, si pensa a un set di ring che vengono collegati con un meccanismo di centro stella. Con il vantaggio che gli elementi dello stesso ring non utilizzano il centro stella. Ci sono una serie di reti locali dove avviane gran parte del traffico, e poi queste sono collegati ad altre reti locali. Quello che avviene nella rete telefonica, con il prefisso: c’è uno stesso distretto e le telefonate bari su bari costavano messo delle chiamate bari roma, poiché dovevano utilizzare altre risorse per collegarsi con Roma. 6. Ipercubo Nel ring il costo è contenuto, la banda è interessante poiché sulle n linee viaggiano in parallelo i dati, però si paga la latenza. Sul ring in ogni tratta c’è un pacchetto che viaggia. I canali sono tutti pieni, tutti i dati sono in viaggio. La banda è molto elevata. Nel bus la banda è piena ma è legata solo a una comunicazione. Un sistema che cerca di creare un compromesso è la connettività a ipercubo. L’ipercubo contiene il numero di costi dal punto di vista di linee di connessione, mantenendo elevato il traffico sia dal punto di vista di banda che dal numero di tratte da percorrere durante una comunicazione. Si definisce dimensione dell’ipercubo, il numero di linee di ingresso/uscita, quindi linee bidirezionali, di ogni nodo e in sostanza definisce la popolazione dei nodi dell’ipercubo perché questa dimensione è legata al numero dei nodi. Un ipercubo di dimensione 3 indica che ogni nodo ha 3 linee di ingresso e uscita. Dimensione 0, indica che non c’è nessuna linea di ingresso/uscita, l’ipercubo ha un solo nodo che non comunica con nessuno. Dimensione 1, c’è una linea per ogni nodo, questo comporta che ci sono due soli nodi. L’ipercubo di dimensione 1 è composta da due nodi. Dimensione 2, ci sono due linee per ogni nodo. Questo comporterebbe 3 nodi, però si cerca di aumentare la dimensione e sono sufficienti infatti 4 nodi. Dimensione 3, con 3 linee per ogni nodo, comporta che ci siano 8 nodi.
189
Un ipercubo di dimensione k si ottiene connettendo due ipercubi di dimensione k-1, in particolare collegando tutti i nodi di questi due ipercubi di dimensione inferiore, ognuno al suo omologo. Data la dimensione D, ci sono N = 2D nodi. N = 2D nodi Alla dimensione come abbiamo detto prima è collegata la popolazione dell’ipercubo, N = 2D. Ogni nodo ha D linee, quindi ci sono connessioni
Dim Ring Punto-punto Ipercubo
Nodi N N 2D
D
Linee N N(N-1)/2 D2(D-1)
Latenza N/2 1 D
Consideriamo un ipercubo di dimensione D=10.
Ring Punto-punto Ipercubo
Dim / / 10
Nodi 1024 1024 1024
Linee 1024 0.5 mln 5120
Latenza 500 (250) 1 10
Costi/benefici 512000 0.5 mln 51200
Per costo si definisce il numero delle linee, mentre per benefici l’inverso della latenza, 6.1. Trasferimento dei dati all’interno dell’ipercubo Consideriamo un ipercubo di dimensione 4. In generale il trasferimento di dati avviene con un protocollo sottoforma di pacchetto. Il pacchetto è costituito da una sequenza di bit. La prima sequenza è chiamata header, contiene informazioni sul pacchetto, un’altra parte chiamata data, contiene l’informazione significativa, ed inoltre c’è eventualmente un'altra parte che indica la fine del pacchetto.
190
header
data
fine pacchetto
L’header contiene sostanzialmente l’indirizzo del nodo a cui trasferire il dato, nodo di destinazione. Diventa antipatico quando i bit necessari al dato necessario sono minori rispetto ai bit necessari all’header. Cioè per trasferire un dato di 1 bit è necessario utilizzare 20bit per l’header. In questo caso si parla di overhead di pacchetto. Si preferisce avere pacchetti con un piccolo overhead altrimenti si paga un inefficienza della rete. Bisogna assegnare a ogni nodo dell’ipercubo un indirizzo, da indicare nell’header. Si decide di dare un numero a ogni nodo. Essendo l’ipercubo di dimensione 4, ci sono 16 nodi. Per esprimere 16 nodi sono necessari 4 bit. Ogni nodo non può avere lo stesso nome di un altro nodo.
Tra due nodi collegati orizzontalmente varia solo il bit meno significativo: 0000 -> 0001 Tra due nodi collegati verticalmente varia solo il secondo bit meno significativo: 0001 -> 0011
Tra due nodi collegati in diagonale varia solo il secondo bit più significativo: 0010 -> 0110
Tra due nodi che si trovano su cubi differenti varia solo il primo bit più significativo
0101 -> 1101
191
Analizziamo il caso il caso di un pacchetto di dati che deve andare dal nodo 0000 al nodo 1111. Questo pacchetto nell’header avrà l’indirizzo 15: 1111 dato
Chiamiamo porta A la porta orizzontale, porta B la porta verticale, porta C la porta diagonale e porta D la porta con l’altro cubo. Definiamo il protocollo: 1) Il nodo di partenza, 0000, confronta il bit meno significativo dell’header, 1111 con il suo bit meno significativo, 0000 attraverso XOR. Se XOR da 1(i bit sono differenti) il pacchetto viene spedito sulla porta A, al nodo corrispondente in orizzontale, 0001. 2) Il nodo in orizzontale 0001 vede arrivare il pacchetto e confronta il secondo bit meno significativo dell’header, 1111, con il suo secondo bit meno significativo, 0001, attraverso XOR. Se l’XOR produce 1 invia il pacchetto sulla porta B, cioè al nodo corrispondente in verticale, 0011. 3) Il terzo nodo, 0011, confronta il terzo bit meno significativo dell’header,1111, con il suo terzo bit meno significativo, 0011, attraverso l’XOR. Se sono diversi invia il pacchetto sulla porta C, al nodo in diagonale, 0111. 4) Il nodo 0111 ricevuto il pacchetto verifica se il primo bit dell’header, 1111, coincide con il suo primo bit, 0111, attraverso l’XOR. Se questi sono differenti invia il pacchetto sulla porta D al nodo di destinazione 1111. 5) La trasmissione è terminata. Il pacchetto è giunto al nodo di destinazione Sono stati impiegati 4 colpi di clock, 4 passaggi. Latenza pari a 4, dimensione dell’ipercubo. Ora ipotizziamo che il nodo di destinazione è il nodo 0101, l’header questa volta contiene 0101. 1) Il nodo 0000, attraverso l’XOR verifica se il bit meno significativo dell’header, 0101, è diverso dal suo bit meno significativo 0000. L’XOR produce 1 e il nodo invia sulla porta B il pacchetto, al nodo orizzontale 0001. 2) Il nodo 0001 ora confronta il secondo bit meno significativo dell’header 0101 con il suo secondo bit meno significativo 0001. Questa volta l’XOR produce zero e lungo la linea B non invia nulla. 3) Sempre il nodo 0001 confronta il terzo bit meno significativo dell’header,0101 con il suo terzo bit meno significativo, 0001. L’XOR produce 1 e il bit viene inviato sulla linea C questa volta, al nodo 0101. 4) Il pacchetto è giunto a destinazione, ma il nodo non lo sa. Il nodo 0101 confronta il primo bit dell’header 0101 con il suo primo bit 0101. L’XOR restituisce 0 e il nodo non verrà inviato. 5) La trasmissione è terminata. Il pacchetto è giunto a destinazione. Le fasi sono sempre pari a D. In generale il protocollo è questo: le 4 fasi fungono così: ogni nodo ha un buffer di ingresso e uscita dove arrivano i pacchetti. A un certo colpo di clock il nodo che deve inviare mette il pacchetto nel buffer di uscita.
192
-
Primo colpo di clock tutti i nodi mettono in XOR il primo bit meno significativo con il loro primo bit meno significativo. Se l’XOR restituisce 1 inviano il pacchetto sul canale A(orizzontalmente), altrimenti non inviano nulla. Secondo colpo di clock: i nodi mettono in XOR il secondo bit meno significativo dell’header con il loro secondo bit meno significativo. Se l’XOR restituisce 1 inviano il pacchetto sul canale B(verticalmente), altrimenti non inviano nulla. Terzo colpo di clock: i nodi mettono in XOR il terzo bit meno significativo dell’header con il loro terzo bit meno significativo. Se l’XOR restituisce 1 inviano il pacchetto sul canale C(diagonalmente), altrimenti non inviano nulla. Quarto colpo di clock: i nodi mettono in XOR il primo bit dell’header con il loro primo bit. Se l’XOR restituisce 1 inviano il pacchetto sul canale D(altro cubo), altrimenti non inviano nulla, questo nodo è quello di destinazione. Il pacchetto è giunto a destinazione.
-
-
-
ipercubo
ring
punto-punto
Dim
nodi
linee
latenza
nodi
linee
latenza
nodi
linee
latenza
2 4 6 8 10
4 16 64 256 1024
4 32 192 1024 5120
2 4 6 8 10
4 16 64 256 1024
4 16 64 256 1024
2 8 32 128 512
4 16 64 256 1024
6 120 2016 32640 523776
1 1 1 1 1
E’ possibile anche definire un rapporto tra costo e prestazione Ipercubo 8 128 1152 8192 51200
ring 8 128 2048 32768 524288
punto-punto 6 120 2016 32640 523776
I nomi ai nodi dell’ipercubo sono assegnati tramite la codifica di Gray. La codifica di Gray rappresenta i numeri binari in maniera tale che fra un numero e il successivo varia solo 1 bit.
193
194
CAPITOLO 23
PREDIZIONE DINAMICA La previsione dinamica è una previsione che viene fatta durante l’esecuzione dell’istruzione. E’ come fare una scommessa basandosi su alcuni elementi. Fino ad ora abbiamo visto la previsione statica con la schedulazione delle istruzioni fatta dal compilatore. Prima dell’esecuzione di un programma il compilatore compila ad esempio l’istruzione di branch con un codice operativo che abortisca l’istruzione avviata dopo speculativamente se non si deve saltare, poiché ha previsto che il salto verrà fatto un gran numero di volte. Quindi si ottimizza la compilazione. La previsione dinamica invece consiste nel fare la scommessa durante l’esecuzione del programma. Il compito in questo caso è più agevole. Durante l’esecuzione di un programma il calcolatore fa delle previsioni sull’istruzione di salto, ad esempio, se saltare o meno, e se saltare dove bisogna saltare. Queste previsioni sono più attendibili di quelle fate staticamente. La previsione dinamica avviene basandosi sull’unica cosa di cui si conosce di un’istruzione quando la si preleva. L’obbiettivo è che al colpo di clock successivo al prelievo di un istruzione di salto, l’istruzione che viene prelevata dopo deve essere quella corretta. Fino ad ora siamo riusciti a spostare il calcolo dell’indirizzo e il calcolo del test del branch nella fase di ID, e questo comporta come già sappiano che il fetch dell’istruzione corretta avviene dopo due colpi di clock dopo il fetch dell’istruzione di salto. La prelevare l’istruzione corretta al colpo di clock successivo al fetch del branch. Nell’immagine è riportato prima quello che avviene esecuzione del programma, e sotto invece quello che avviene con la predizione dinamica.
Il meccanismo della previsione dinamica si basa sulla storia di quello che è successo fina a quel momento nel programma. Perché questo avvenga nell’unità di controllo del processore viene creata una struttura che consente di sapere che cosa è successo l’ultima volta che è stata prelevata l’istruzione di salto. Parleremo del prelievo di istruzioni di branch poiché se l’istruzione non è di branch è ovvio che l’indirizzo dell’istruzione dopo è PC+4. Quindi se durante il fetch del branch si riesce a capire che la prossima 195
istruzione non è quella PC+4 ma a quella in un certo indirizzo, si evita lo stallo. L’idea è quella di prelevare il branch e mentre si sta facendo PC+4, che avviene già durante il fetch, questo viene sovrascritto con un PC di salto: BTA Branch Target Address. Nell’unità di controllo è presente una tabella con diverse informazioni. Durante il prelievo dell’istruzione di branch attraverso il suo PC, questo indirizzo in PC punta a questa tabella. Le informazioni fornite dalla tabella sono: -
-
se l’istruzione a quell’indirizzo è un istruzione di salto, prima ancora che questa venga prelevata quindi sappiamo che questa è un istruzione di salto. L’unità di controllo però come fa a sapere se quell’istruzione a quel indirizzo dato è un istruzione di salto? L’unità di controllo saprà se quell’istruzione è di salto o meno solo se è stata già eseguita prima (dalla storia del programma). Infatti se stiamo in un loop, la prima volta che verrà eseguita l’istruzione branch del loop, l’unità di controllo non la conoscerà e andrà a scriverla nella tabella, in modo tale che ciclando, alla prossima iterazione quando andrà a prelevare di nuovo quell’istruzione sempre a quell’indirizzo, avendola già in tabella saprà che quella è un istruzione di salto. Nella tabella c’è anche scritto l’indirizzo di salto, dove si deve saltare. Nella tabella c’è anche scritto qualcosa che aiuta a fare una previsione se il salto va fatto o meno.
Vediamo in dettaglio quello che accade. Il PC punta a una riga della tabella, che quindi dovrebbe avere 232 righe, essendo il PC di 32bit. Ogni riga deve avere dei bit per indicare l’indirizzo dove saltare, quindi 32 bit; dei bit per indicare che l’istruzione è di salto, magari anche 1 bit; e dei bit designati ad indicare se il salto si farà o meno. Questa tabella è dentro l’unità di controllo. Nell’unità di controllo non ha senso mettere una tabella di queste dimensioni, di circa 16GB. Queste righe nella tabella sono di meno, ad esempio 512 righe. A questo punto per puntare a una di queste righe, dai 32bit di indirizzo iniziale, ne servono solo 9, i meno significativi in modo tale che due istruzioni successivi puntino sempre a righe diverse. Avendo scelto 9 bit c’è il problema che una riga della tabella possa essere puntata da due istruzioni che hanno i 9 bit meno significativi uguali, e quindi puntino entrambi alla stessa riga. Questo problema però non è tragico se si crea
196
un meccanismo reversibile, bisogna preparare un filo di Arianna per ritornare indietro. E’ anche opportuno che la tabella abbia una certa dimensione. Quindi quello che avviene è questo: si carica un istruzione a partire dall’indirizzo. Gli n bit meno significativi di questo indirizzo puntano a una riga della tabella, e si verifica se quella è un ‘istruzione di salto. Se non è un istruzione di salto non succede nulla. Come capire se questa è o non è un istruzione di salto? Si struttura la tabella in modo tale da contenere solo istruzioni di salto, oppure si struttura in modo da avere solo istruzioni che non sono di salto, e il campo dell’indirizzo dove salta è semplicemente PC +4, evitando di fare PC+4. Durante la fase di inizializzazione la tabella è vuota. Man mano che si eseguono le istruzioni si riempie la tabella. Si fa il fetch di una istruzione, e si punta alla riga della tabella; durante la fase di decode se questa istruzione è un branch si scrive nella tabella e si scrive anche l’indirizzo dove saltare. Questa tabella viene letta e poi aggiornata durante l’esecuzione dell’istruzione in modo che quando si accede di nuovo si legge il valore aggiornato. L’informazione che si scrive è un informazione che predice se il salto va eseguito o meno, e questa previsione viene eseguita dal predittore. 1. Predittore a un bit Per fare una previsione bisogna basarsi sulla storia del programma. Un meccanismo semplice è quello di utilizzare un bit che indica se l’ultima volta il salto è stato effettuato o meno. Se l’ultima volta il salto è stato effettuato questo bit varrà 1, 0 altrimenti. La predizione può basarsi su questo meccanismo, se il bit vale 1 allora si salta, se il bit vale 0 non bisogna saltare. Se questa è un previsione sull’istruzione di branch posta in un loop che itera N volte, questa previsione sarà corretta N-2 volte. La prima volta in cui si entra nel loop non avendo alcuna storia alle spalle, eseguendo per la prima volta l’istruzione di branch verrà predetto che non bisognerà saltare, e invece il salto verrà effettuato. Le restanti volte, venendo sempre da salti certi e quindi bit pari a 1, verrà predetto di saltare ed effettivamente si salterà. All’ultima iterazione invece verrà predetto di saltare, visto che la volta prima si è saltato, però questa volta il salto non dovrà farsi. Questo meccanismo è migliorabile con un predittore a 2bit. 2. Predittore a due bit Il predittore a due bit tiene traccia di quello che è successo non l’ultima volta, ma realizza una macchina a stati. Nelle reti combinatorie, dato un certo ingresso si ottiene una certa uscita funzione degli ingressi, y = f(x), dove x e y possono essere dei vettori, un insieme di diversi bit. Esistono situazioni che non possono essere descritte da questi tipo di sistema, poiché per conoscere l’uscita di un sistema non è possibile basarsi solo sugli ingressi ma bisogna basarsi su quello che si chiama stato del sistema. Un classico esempio è il contatore che viene usato per descrivere un sistema la cui uscita non è determinata in base al solo ingresso, ma anche dallo stato del sistema. Dato un colpo di clock, il contatore non sapendo lo stato non potrà dare un uscita. L’uscita di un contatore infatti è data da s+1, se l’ingresso è altro, dove s è lo stato in cui si trova il sistema prima del clock(evento da contare) che arriva. Il valore però non può andare fino all’infinito, allora si utilizza il modulo in modo che dopo un certo valore lo stato torni a zero. Nel realizzare questi sistemi il progetto parte dal diagramma degli stati. Ci si piazza su uno stato e quando arrivano gli ingressi si decide cosa fare. Supponiamo di avere solo due ingressi: con ingresso 0 ad esempio si passa un altro stato producendo una certa uscita; se invece l’ingresso è 1 si passa a un altro stato producendo un'altra uscita. 197
Esempio del diagramma degli stati del contatore modulo 8, quindi con 8 stati. Si chiamano gli stati con un nome, esempio il numero. Si associa il nome allo stato con tecniche che ottimizzano le unità di memoria che poi verranno usati per realizzare il sistema. Perché una macchina a stati necessita delle memorie(flipflop) per sapere il sistema in che stato si trova. Per ridurre l’uso di flip-flop ci sono delle tecniche, una tecnica è quella che gli stati vicini abbiano nomi vicini, utilizzando la codifica di Gray. Supponiamo di essere sullo stato 0, il contatore sul display segna 0. Quando arriva un colpo di clock, ingresso alto, valore 1, l’uscita vale 1 e poi lo stato passa a 1 (1/1, ingresso/uscita). Se invece l’ingresso è basso, l’uscita è zero e si ritorna sullo stato zero (0/0). Quindi se siamo nello stato 1 e arriva l’evento, si passa allo stato 2 producendo un uscita 2, se invece non arriva alcun evento, si resta sullo stato 1 producendo 1. E così via. Un contatore da zero a infinito necessitava di infiniti stati e infiniti flip-flop.
Un predittore a due bit si realizza con una macchina a quattro stati: saltos, salton, nsaltos, nsalton. Supponiamo di verificare se l’istruzione di salto deve saltare o no, e la storia del programma dice che le ultime due volte siamo saltati. Siamo nello stato saltos. In questo stato si predice di saltare. Se il salto viene effettivamente fatto, si resta in questo stato (S/S, predico di saltare/salto effettuato). Se invece non si salta (S/N, predico di saltare/non si salta), si passa allo stato salton. In questo stato si predice ancora di saltare. Se il salto viene effettivamente fatto(S/S) si ritorna allo stato saltos. Se invece il salto non viene effettuato, si passa allo stato nsalton. Nello stato salton si rimarrà solo per un colpo. Nello stato nsalton, preveniamo da una storia pari a 2 non salti. Non essendo saltati le ultime due volta, si predirà di non saltare. Se effettivamente non si effettua il salto (N/N), si rimane nello stato nsalton. Se invece predicendo di non saltare, e il salto si effettua si passa allo stato nsaltos. In questo stato, l’ultima c’è stato il salto, ma almeno due volte prima non abbiamo saltato, si continua a scommettere di non saltare. Se effettivamente non si salta (N/N), si ritorna allo stato nsalton, altrimenti se si salta(N/S) si passa allo stato saltos, visto che adesso sono già due le volte in cui abbiamo saltato.
198
Quindi nel loop, arrivati all’ultima iterazione, si prevede di saltare, in realtà il salto non si effettua. Si esce dal loop. Quando poi si ritorna al loop, incontrando quell’istruzione di branch, si predirà di saltare, poiché ci troviamo nello stato salton. Effettivamente quell’istruzione salta e la previsione è andata a buon fine. Ora la prima volta non si sbaglia. Si sbaglierà però l’ultima volta. In un loop con N iterazioni, N-1 previsioni saranno corrette.
199
200
CAPITOLO 24
ALGORITMO DI TOMASULO Le prestazioni possono essere aumentate a livello di parallelismo di istruzioni (ILP), esecuzione parallelo di più istruzioni, attraverso alcuni aspetti: -
Predizione di dinamica Speculazione per permettere l’esecuzione di istruzioni prima di risolvere eventuali dipendenze di dati. Avviare in maniera speculativa istruzioni prima ancora di capire se quelle istruzioni devono essere eseguite, sempre se poi è possibile abortire nel caso in cui queste non devono essere eseguite.
Per avere la possibilità di speculare bisogna pensare a un sistema in cui l’esecuzione di un istruzione che parte speculativamente, venga abortita nel caso in cui non debba essere eseguita. Si predispone di un meccanismo che avvii istruzioni senza essere sicuri che queste debbano essere avviate, e che attraverso una label permetti loro poi di scrivere effettivamente nei registri. Queste istruzioni potranno scrivere nei registri solo nel momento in cui gli verrà permesso farlo attraverso un commit. Facciamo l’esempio di istruzioni che devono operare su dati prodotti da un istruzione precedente: c’è l’istruzione load e un istruzione che utilizza il dato prelevato con la load. Il compilatore metterà un’istruzione tra la load e quest’ultima istruzione per evitare lo stallo: L.D
F8,(R1)0
DADD R4,(R4),8 ADD.D F6,F8,F2 La seconda istruzione è inserita dal compilatore per evitare lo stallo prodotto dalla load. Questo però ancora non garantisce che l’istruzione ADD.D che usa F8 possa tranquillamente utilizzare F8. In realtà non è detto che la load in due colpi di clock possa estrarre F8, poiché questo può stare nell’HARD DISK. L’istruzione ADD.D F6,F8,F2 comunque partirà, però prima di scrivere F6 dovrà ricevere il commit. L’istruzione load dovrà dare una sorta di “OK”. 1. Buffer di Riordino Il meccanismo è di utilizzare dei buffer, buffer di riordino (ROB), che fungeranno da posteggi provvisori in cui le istruzioni andranno a leggere e scrivere i dati, e solo quando l’istruzione otterrà il commit questi buffer verranno scritti nei registri. Il buffer di riordino è una tabella con una struttura FIFO. Questo meccanismo prende il nome di algoritmo di tomasulo, dal nome di chi lo ha sviluppato, il ricercatore dell’IBM Robert Tomasulo . Il buffer di riordino è utilizzato per passare i risultati fra le istruzioni schedulate speculate. Supponiamo di avere questo programma: L.D
F8,(R1)0
DADD R4,(R4),8 201
ADD.D F6,F8,F2 MULT.D F3,F5,F6 Se l’istruzione ADD.D non ha ancora ricevuto il commit, cioè non ha ancora scritto F6, l’istruzione MULT.D potrà partire speculativamente, non prendendo il dato da F6 ma dal buffer di riordino. In questo modo non si blocca nulla, si avviano istruzioni speculativamente. Si crea una stazione di prenotazione, Reservation Station, per ciascun modulo di calcolo. Queste vengono allocate dalle istruzioni e ad abilitarsi con i dati che non necessariamente vengono dai registri, ma instradati dai buffer di riordino. Ad esempio l’istruzione MULT.D F3,F5,F6 si andrà ad allocare nella stazione di prenotazione, ma il dato F6 non verrà preso dal registro F6, ma dal buffer di riordino associato all’istruzione ADD.D. Il buffer di riordino ha questa struttura: -
-
Tipo di istruzione: specifica oltre al tipo di operazione, anche il tipo di dato che quell’operazione sta costruendo. L’istruzione di branch che non costruisce nessun dato, produrrà un risultato che è un indirizzo di salto. L’istruzione di store ha come destinazione un indirizzo di memoria. Anche la store non è detto che venga scritto in un colpo di clock, può darsi che la pagina su cui scrivere non è in cache e va caricata. E infine ci possono essere istruzioni aritmetiche, operazioni sui registri. Destinazione: il registro dove dovrà essere scritto il contenuto del buffer di riordino. Valore: indica il contenuto del buffer di riordino. E’ quello che viene usato nell’ipotesi in cui come nell’esempio prima bisogna usare F6, senza che la ADD ha ricevuto il commit. Ready: indica se l’istruzione è terminata, ed allora è possibile leggere il valore del buffer. Se il campo Ready è settato a zero, il valore che si legge nel buffer è errato, è quello relativo a qualche istruzione precedente. Quindi Ready indica che il buffer ha un certo valore, quando poi l’istruzione riceverà il commit, questo valore verrà scritto nel registro destinazione nel banco dei registri.
Ottimizzare l’esecuzione del task facendo si che un istruzione potesse cominciare in maniera speculativa anche nell’ipotesi in cui questa istruzione deve usare dati che non sono pronti poiché generati da altre istruzioni, oppure istruzioni che partono speculativamente a seguito di un branch. Tutto questi diventa possibile se si mantiene la capacità di abortire istruzioni avviate speculativamente. Ogni istruzione può anche essere completata però il risultato non viene scritto nel banco dei registri. Si parte ugualmente nell’esecuzione di un istruzione e nel caso in cui questa non doveva essere eseguita viene abortita, oppure a seguito di un branch viene caricata un’istruzione e il risultato di questa viene posteggiato nel buffer che può comunque essere utilizzato da qualche altre istruzioni. Se l’istruzione viene confermata allora il buffer relativo a quella istruzione viene scritto nel banco dei registri. Quindi si aggiunge una caratteristica alle istruzioni, la caratteristica di essere commissionate. Un’istruzione viene schedulata però non avrà la possibilità di scrivere il risultato nei registri finché non viene “timbrato” il flag di commit. Quindi si parte speculativamente con una istruzione, si occupa una riga del buffer di riordino dove si andrà a scrivere il valore del risultato. Questo verrà trascritto poi nel banco dei registri se l’istruzione verrà commissionata. Consideriamo le istruzioni: MULT.D F4,F2,F8 ADD.D F5,F4,F9 202
L’istruzione ADD.D potrà leggere il campo F4 dal buffer di riordino, solo quando questo buffer però avrà il campo ready settato a uno ad indicare che il buffer è pronto per essere utilizzato. Quando l’istruzione MULT.D riceverà il commit, scriverà il valore del buffer in F4. 2. Fasi dell’istruzione Le fasi cambiano lievemente e diventano: -
-
-
-
Fase di Issue: fase di licenziamento dell’istruzione, in cui l’istruzione esce. Affinché un istruzione entri nella fase di Issue, nel buffer di riordino deve esserci una linea libera, altrimenti se il buffer di riordino è satura bisogna aspettare. Fase di Esecuzione: una volta che l’istruzione si è prenotata nel buffer di riordino può cominciare la sua esecuzione, però l’istruzione deve avere gli operandi disponibili, che non sono necessariamente nel banco dei registri, ma anche nel buffer di riordino di qualche altra istruzione ready. Fase di Scrittura del risultato: terminata la fase di esecuzione, l’istruzione scrive il risultato nel buffer di riordino attraverso un bus comune (CDB Common Data Bus). Quando l’unità floating point produce un risultato; questo risultato viene scritto nel buffer di riordino. Quando l’istruzione riceverà il commit il valore andrà dal buffer di riordino nel registro. Quando un sommatore ha scritto il risultato nel buffer di riordino, non va esclusivamente nel buffer ma anche nel bus comune, in modo tale che se un altro sommatore ha bisogno di quel risultato, lo cattura dal bus e non lo va a prendere dal buffer. L’operando si può leggere dal buffer di riordino, oppure se non è ancora presente ci si può mettere in ascolto sulla common data bus per prelevarlo non appena questo è stato prodotto da qualcuno. Quindi il risultato viene scritto sia sul CDB e sia sul buffer di riordino. Quando poi un modulo floating point termina di lavorare marca quella stazione di prenotazione come disponibile. Quando un istruzione va a mettersi in coda nel buffer di riordino (issue), non solo bisogna trovare una riga disponibile nel buffer di riordino, ma bisogna anche attendere la disponibilità dell’unità funzionale che deve essere utilizzata. Bisogna quindi prenotarsi sulle stazioni di prenotazione. Quando un istruzione termina, marca che la stazione di prenotazione è libera. Fase di Commit: l’istruzione riceve il commit, avviene la scrittura del buffer di riordino nel buffer dei registri.
Consideriamo ora come avviene l’esecuzione di alcune istruzioni di un programma attraverso l’algoritmo di Tomasulo.
203
Le istruzioni floating point nella coda di istruzioni aspettano di esser inserite nel buffer di riordino, che ad esempio contiene sette righe. Quando un istruzione entra nella fase di issue, questa entra nel buffer di riordino che ricordiamo essere di FIFO. Consideriamo ad esempio che la prima istruzione ad entrare nel buffer di riordino è LD F0,10(R2). Nel campo Ready(Done) c’è scritto N, l’istruzione non è stata completata. Nel campo destinazione c’è scritto F0, a dire che quella istruzione calcola F0. Quando un istruzione va a mettersi in coda nel buffer di riordino, l’istruzione non solo deve trovare una riga libera nel buffer di riordino, ma deve attendere la disponibilità dell’unità funzione che deve andare a impegnare l’operazione, e allora va a prenotare una riga della tabella stazione di prenotazione. Nell’immagini sono rappresentate le stazioni di prenotazione per un sommatore, moltiplicatore floating point e anche per l’accesso a memoria. Quando poi un istruzione che ad esempio sta effettuando una somma, termina di utilizzare il sommatore, marca la riga della stazione di prenotazione relativa al sommatore come libera, quella stazione si è liberata. Il buffer di riordino attende che l’istruzione riceva il commit in modo tale da scrivere nel banco dei registri. L’istruzione caricata prima nel buffer di riordino la load, va prenotarsi nella stazione di prenotazione relativa all’accesso a memoria. Nella riga c’è scritto nel primo campo il valore 1, ad indicare che si tratta dell’istruzione al buffer di riordino 1, l’informazione al secondo campo invece indica che quell’istruzione deve produrre un dato che sarà letto dalla memoria all’indirizzo R2+10. Al colpo di clock successivo, dalla coda istruzioni entra una nuova istruzione nel buffer di riordino, ad esempio l’istruzione: ADDD F10,F4,F0. Questa istruzione utilizza F0, il valore prodotto 204
dall’istruzione load precedente. Questa utilizza il sommatore, e si prenota nella stazione di prenotazione. Poiché un suo sorgente è F0, nella stazione di prenotazione c’è scritto che gli operandi dell’istruzione, relativa al buffer di riordino 2, sono uno in F4, banco dei registri, e l’altro non è disponibile, poiché nel buffer di riordino prima c’è F0 proprio sorgente di questa, allora il secondo operando sorgente va preso quando sul CDB transiterà il risultato dell’istruzione del buffer di riordino 1, (ROB1). Non appena l’istruzione al buffer di riordino 1 produce il risultato, questa spiando sul CDB lo vedrà transitare e lo userà per andare a fare la somma. Al colpo di clock successivo arriva la nuova istruzione, DIVD F2,F10,F6. Questa si va a prenotare nella stazione di prenotazione relativa al divisore specificando che un operando va preso dal banco dei registri da F6 mentre il primo va preso quando sul CDB transita il dato del buffer di riordino 2. Al prossimo colpo di clock c’è l’istruzione BNE condizionata a F2 e noin ha nessuna destinazione. Ci sono inoltre altre due istruzioni LD F4,0(R3) e ADDD F0,F4,F6 e il procedimento è sempre quello descritto.
Al colpo di clock successivo entra un’istruzione di STORE: ST 0(R3),F4. Il sorgente della store è F4 cioè il destinazione della load e quindi questo valore lo prende direttamente dl buffer di riordino 5. Questa istruzione potrebbe creare un conflitto. Meccanismo di eccezione: interrupt.
205
Le eccezioni e gli interrupt sono dei meccanismi che vengono controllati esternamente e tengono conto dei problemi che dipendono dall’esecuzione del programma. Questo problemi possono essere sincroni o asincroni. Sono sincroni i fenomeni che avvengono sempre nello stesso momento quando il programma viene eseguito con gli stessi dati. Sono asincroni i problemi che avvengono in maniera non prevedibile. Colpo di clock per istruzione inferiore a uno. Fare il fetch di un istruzione per colpo di clock, nella migliore delle ipotesi, il CPI=1, in generale CPI>=1. Si può migliorare il CPI con un sistema che licenzia un numero multiplo di istruzioni. Potrebbe essere il caso di un processore superscalare, oppure di un processore VLIW in cui si esegue un pacchetto di 5 istruzione ad esempio. 3. Esecuzione fuori ordine Infine c’è la possibilità di utilizzare l’uso delle esecuzione fuori ordine. L’esecuzione delle istruzioni comincia nell’ordine così come sono scritte, però ci sono delle istruzioni più lunghe di altre, perché utilizzano unità funzionali più lente, oppure perché richiedono dati che sono destinazioni di altre istruzioni devono attendere che queste vengano prodotto, quindi risultano terminare dopo. Dinamicamente è possibile eseguire un programma che consente la terminazione fuori ordine attraverso l’algoritmo di Tomasulo. E’ possibile gestire l’esecuzione fuori ordine, le istruzioni vengono avviate e portate a termine, e questo può avvenire prima che un'altra istruzione precedente dal punto di vista logica sia terminata. Le istruzioni vengono licenziate in ordine(issue), però le istruzioni terminano fuori ordine, così come la scrittura dei risultati. Questo può funzionare con una unità di controllo che supervisiona l‘accesso alle unità verificando che quando una istruzione prenota l’unità deve avere anche informazioni sugli operandi da usare, e che se non sono pronti non consente all’istruzione di iniziare la fase di esecuzione sull’unità floating point. Si effettua solo uan prenotazione, l’esecuzione comincia solo quando sono pronti tutti gli operandi. Inoltre nelle stazioni di prenotazioni ci sono tante righe per quanti sono i moduli, ci sono 3 lane per l’accesso a memoria, allora ci sono 3 righe su cui prenotarsi sulla stazione di prenotazione per la memoria, e così via. Un istruzione viene messa nel buffer di riordino nel momento in cui può passare nella stazione di prenotazione. Se non ci sono unità libere bisogna attendere.
206