Torniamo un attimo sul loop unrolling per vedere come gestire una problematica. Abbiamo un vettore che prevede 100 elementi. Facciamo uno srotolamento di questo vettore per 4 volte e, facendolo per 4 volte, avremo che il loop srotolato richiederà 25 iterazioni. Se il vettore avesse 101 elementi, come faccio a srotolarlo per 4? Questo è un problema normale, nel senso che nessuno ci garantisce che il programma principale ha, un numero di iterazioni, che può essere gestito secondo uno srotolamento, che potrebbe anche non esistere. Potrebbe anche capitare di avere un loop parametrico Nel codice abbiamo un loop che, il compilatore si trova a tradurre, di un'iterazione che va da 1 a N. Nel compilare un FOR da 1 a N, il compilatore non sa quanto vale N al momento dell’esecuzione, né per quanto bisogna srotolare. Quest’ operazione è abbastanza semplice da gestire perché, il compilatore, quando trova un loop di N generiche iterazioni e se ha deciso di srotolarlo per 4, per esempio, crea un codice compilato dove la 1° parte si chiama: gestione del residuo e poi c'è una 2° parte che contiene il nostro loop srotolato. Riassumendo, il nostro problema originale prevede N iterazioni; lo vogliamo srotolare per 4, ma non sappiamo quanto vale N. Il compilatore, come 1° cosa, nel tradurre il codice, considera N e calcola il residuo di N rispetto al fattore di srotolamento 4. N è una variabile che può valere un numero qualsiasi ad ogni accesso nel programma, ma quando parto per eseguire l’iterazione è nota. Esseno nota, quando inizializzo il valore sul loop, posso fare il resto della divisione di N/4. 4 è una potenza del 2 e quindi devo considerare gli ultimi 2^k bit ( se k è log in base 2 di 4). Dato N, se prendo gli ultimi 2 bit, abbiamo il resto della divisione N/4. La 1° cosa che il sistema fa è calcolare l'operazione attraverso una maschera. Supponiamo che R6 contenga N. ANDI R5 R6 3 // Sto mettendo in R5 il residuo. Devo fare un loop non srotolato di R5 iterazioni, se quello fosse 103, il residuo verrebbe proprio 3. Facciamo il loop di 3 iterazioni non srotolato e resteranno 100 che potremo srotolare. Contemporaneamente faccio la divisione di R6 per 4 per sapere quante volte questo loop srotolato dovrà essere eseguito. DIVI R4 R6 4 // sto mettendo in R4 N/4 Sappiamo che il vettore su cui dobbiamo operare, parte da un certo indirizzo e si sviluppa da quell'indirizzo + R6 che contiene N *8 (se abbiamo un vettore di dati da 8 byte). Calcoliamo R6*8 ( R3 è la dimensione del vettore in byte su cui decido di operare per poi spostarmi su questo vettore sempre di 8): SLLI R3 R6 3 //se il loop gestisce un unico vettore, avremo che il vettore partirà da un certo indirizzo e si svilupperà da quell’indirizzo per la dimensione di R3. Se dobbiamo gestire diversi vettori, per esempio 2 sorgenti e 1 risultato, abbiamo un altro indirizzo di inizio, ma sempre R3 fungerà da puntatore ai vettori. In generale va bene qualsiasi numero di vettori, purché siano omogenei Elementi sono tutti da 8 byte, possiamo usare un solo puntatore. Non ha senso usare più puntatori, non tanto perché spreco 2 registri, ma perché per ogni iterazione dovrò decrementare 2 registri. E’ più intelligente ragionare con un unico puntatore che viene gestito. Avremo, quindi, nel corpo del loop un’istruzione che andrà a puntare ad un certo vettore a partire da 1000 con R3, un’altra istruzione per preleverà un operando da un vettore, sempre considerando R3, e via dicendo. Sottrarremo solo ad R3, 8 nel caso di loop non srotolato e 8x4 nel caso della gestione del puntatore nella parte srotolata. Questa parte che gestisce il loop non srotolato conviene gestirla sulla parte terminale del vettore perché, se ho il vettore di 103 elementi, conviene gestire gli ultimi 3 come residuo e gli altri 25 gruppi da 4 come gestione del loop srotolato. Se il vettore inizia da 1000, il nostro prelievo dell’operando, che fa parte di questa gestione del residuo, sarà 1000-8 + R3. Se il vettore inizia da 1000, il 2° è 1008, ecc. L’ultimo è
1000-8+R3 ( se ha 1 solo elemento quindi R3=8, 1000-8+8 indirizzo ultimo elemento del vettore. Quando abbiamo offset indirizzo del 1° elemento, l’ultimo non è 1000 + dimensione, perché se lo facciamo in questa maniera puntiamo al 1° elemento dopo che il vettore è stato completato. Per l’ultimo la “formula” giusta è 1000-8 + dimensione) SLLI R7 R4 5 // dimensione in termini di byte della parte srotolabile del vettore Il loop per la gestione del residuo sarà un loop di R5 iterazioni, ma potrebbe anche essere un loop mai eseguito. Aggiungiamo uno scalare al vettore V[ ]F8+ V[1000] Codice per la gestione del residuo: L.D F2 992 R3 // Load dell’ultimo elemento del vettore. Prelevo dalla memoria l’oggetto per caricarlo in F2 ADD.D F2 F2 F8 S.D F2 992 R3 A prescindere dalle operazioni, adesso dobbiamo decrementare il puntatore e l'indice di questa operazione: ADDI R3 R3-8 ADDI R5 R5-1 BNEZ R5 -6 //Fintanto che R5 è diverso da 0 dobbiamo ritornare al loop gestione del residuo. Questa cosa è giusta, ma errata dal punto di vista che l’iterazione è eseguita, almeno, una volta. Ci siamo calcolati questi valori, poi partiamo con il loop appena scritto (che preleva l'ultimo elemento del vettore) e, fintanto che R5 non viene decrementato e scompare il resto, eseguiamo l’iterazione. La 1° iterazione del programma verrà effettuata anche se R5 = 0. Se R5 è 0 ( ovvero se N è multiplo di 4) il loop appena scritto non finirà; (diventa -1. Poi -2 fino all’under flow) e non è ammissibile. Per questa ragione, prima di partire con il loop per la gestione del residuo, è consigliato calcolare l’eventualità che il loop debba essere eseguito. Posso quindi fare che BEQZ R5 5, salto dove inizia la parte della gestione del loop srotolato; se invece R5 non è uguale a 0, non salto da nessuna parte e eseguo il loop( possibile caso). Ragioniamo sul codice gestito con 2 decrementi: R3 che mi deve sempre puntare all’elemento da processare alla prossima iterazione, sia R5 che mi serve per contare. Potremmo utilizzare questa cosa impostando solo il decremento di R3 e facendo una condizione di uscita del loop su R3. Cioè dato R3, che è la dimensione del vettore, possiamo calcolarci un R7 (numero non ancora usato) che sarà la dimensione della parte del vettore multipla di 4. Tipo: il vettore di 1003 elementi, la dimensione del vettore è 8024. La dimensione del vettore multiplo di 4 è 1000 elementi*8 e 24 è la dimensione del vettore residuo. Posso lavorare solo con R3, che decremento di volta per volta, e saltare fin tanto che R3 non è uguale a R7 Evito la gestione di un altro eventuale decremento ( dove R7 è uguale a 8* (N/4) con N/4 numero intero non fp). Posso impostare l ritorno sulla BNE di R3 con il registro che è di puntatore all’ultimo elemento non del vettore ma di quella parte del vettore che sarebbe multipla di 4. Per fare questo calcoliamo R7, che possiamo calcolare prendendo R4 che sarebbe il numero di elementi del vettore multipli di 4, prendere R4 e moltiplicarlo x4 x8. Posso fare SLLI R7 R4 32 // ho messo in R7 la dimensione in byte della parte srotolabile del vettore Il numero di elementi del vettore multiplo di 4 ( 17 elementi? Il numero è 16). Avendo in R7 questa dimensione in termini di byte della parte del vettore, fin tanto che R3 decrementando di 8 non diventa uguale a R7, salto indietro. Quindi elimino ADDI R5 R5-1 E imposto BNE R3 R7-6 al posto di BEQZ R5. Alla fine avremo che R3 punta all’ultimo elemento del vettore srotolato.
Inizia ora la parte del loop che durerà R4 ( dove abbiamo messo la parte intera dell’iterazione) iterazioni. Avendo R4 iterazioni inizieremo a scrivere un codice e poi avremo un'istruzione per saltare all'altra iterazione. Attenzione però che questo loop che scriveremo, potrà essere non eseguito se per esempio volessi srotolarlo per un numero maggiore di volte rispetto gli elementi che ho. Quindi, prima di scrivere il codice, dobbiamo verificare che effettivamente ci sia da fare almeno un’iterazione. Come? R4 non deve essere uguale a 0. Se avessi avuto N=2, R4 era uguale a 0. Come abbiamo fatto BEQZ R5, faremo BEQZ R4 per saltare dopo il loop che scriveremo ora. Ritorniamo a quello che abbiamo scritto prima, la schedulazione fatta non è il massimo quindi posso fare una serie di cambiamenti per ridurre gli stalli che staranno fra la LOAD e ADD, tra ADD e STORE e tra la ADD e la BNE più l’eventualità, dopo BNE di fare un’istruzione che verrà abortita. L’ ADDI R3 R3-8 la posso spostare dopo la L.D, ottenendo 2 scopi: riempire uno stallo e allontanarla dalla BNE. Devo modificare la STORE perché è stata decurtata di 8 S.D F2 1000 R3. Invece per eliminare lo stallo tra la ADD e la BNE, prendo la S.D e la metto dopo la BNE in modo tale da far si che sia presente un’istruzione che vada sempre eseguita e non verrà abortita mai, dato che è qualcosa che verrà sempre eseguita. Ciò porta a 2 vantaggi: istruzione che non verrà mai abortita e allontaniamo la STORE dalla ADD così che tra la ADD, e la STORE che deve usare la ADD, avremo 1 stallo in meno. Quindi il programma un po’ più ordinato sarà: 1. LOAD 2. Decremento 3. ADD 4. BNE 5. STORE Quindi cambia anche l'offset della BNE che diventa da -6 a -4 Abbiamo visto la gestione del loop per eseguire il calcolo sul residuo, scriviamo ora il codice che gestisce il loop di una parte srotolata: L.D F2 992 R3 // preleviamo il nostro operando ADD.D F2 F2 F8 S.D F2 992 R3 F4 984 // stessa cosa altre 3 volte, in tutto deve essere fatta 4 volte. Non mi conviene usare F2. Uso altri registri come F4 F6 F10. Ho scritto solo questo ma in realtà per ognuna di queste 3 sto facendo una LOAD, una ADD e una STORE. F6 976 F10 968 ADDI R3 R3-32 // decremento R3 8* numero fasi srotolato (4) BNEZ R3 ( immediato che calcoleremo) Questa è non schedulata, quindi la scheduleremo in maniera da avere le istruzioni per riempire alcuni buchi. L.D F2 992 R3 L.D F4 984 R3 L.D F6 976 R3 L.D F10 968 R3 ADD.D F2 F2 F8 ADD.D F4 F4 F8 ADD.D F6 F6 F8 ADD.D F10 F10 F8
S.D F2 992 R3 S.D F4 984 R3 S.D F6 976 R3 S.D F10 968 R3 ADDI R3 R3-32 BNEZ R3 -14 Il codice funziona, vediamo se si può fare una schedulazione migliore. Abbiamo una serie di stalli che sono quelli dati dalle latenze dalle ADD, STORE, ecc. Cosa dobbiamo fare è inserire un'istruzione dopo la BNEZ. Di certo non possiamo prendere l’ ADDI prima di lei perché la BNZ lavora proprio su R3. Se sposto S.D F10 968 R3, potrebbe andare meglio e qualcosa deve cambiare nel suo immediato. Sposto l’ ADDI R3 R3-32, allontanandola dalla BNEZ, dopo tutte le ADD.D per allontanare ancora di più le ADD dalle STORE regalando un colpo di clock a tutto il sistema che calcola il risultato prima di scriverlo in memoria. Sto creando una latenza di 4 istruzioni tra la ADD che produce un registro e la STORE che lo vuole usare. Sto anche allontanando questa ADD da BNEZ, in maniera che R3 è calcolato prima del confronto. Quindi S.D diventa F2 1024 R3, S.D F4 1016 R3, S.D F6 1008 R3 e infine F10 1000 R3. Devo anche ricalcolare l’immediato della BNEZ che da 14 diventa 13. Vediamo un'altra tecnica che è la pipeline da programma. Supponiamo di avere un loop: AX+Y=Z L.D x 1000 R1 // R1 inizializzato a 8*N L.D y 2000 R2 // già anticipato la LOAD per evitare uno stallo MULT.D x' x A // abbiamo una latenza che ci impedisce di avviare subito la somma ADD.D z x' y // altra latenza per arrivare alla STORE successiva S.D z 3000 R1 ADDI R1 R1-8 BNEZ R1-7 Su questo codice si può fare una schedulazione allontana la ADD R1 R1-8 dalla BNEZ e posizionandola tra la MULT e ADD Riempio così una latenza della MULT. Se faccio questo, 3000 diventa 3008 e inserisco un’istruzione dopo la BNEZ, dove la miglior candidata è la STORE. Quindi BNEZ R1-7 diventa R1-6 Loop canonico non srotolato. Abbiamo che ognuna di queste istruzioni si trova a operare sui dati prodotti dalle istruzioni precedenti. E’ vero che tra un’istruzione e l’altra devo aspettare che una produca il risultato, ma se invece di riempire questi stalli con altre istruzioni come abbiamo fatto con lo srotolamento del loop ( loop con molte istruzioni in modo da avere delle istruzioni da mettere al posto degli stalli); la filosofia che voglio perseguire è: se gli stalli vengono riempiti non da istruzioni del loop ma da Istruzioni che fanno parte, dal punto di vista logico, da altre istruzioni del loop. Ovvero? Supponiamo che quando sto producendo questo prodotto, il risultato del prodotto dovrà essere sommato alla y ma nell’iterazione successiva del loop. Cioè, quando sto eseguendo la ADD, sto facendo la somma del registro che è stato prodotto, dall’istruzione, nell’iterazione precedente. E’ come se ogni volta che eseguo un'istruzione del loop, l’istruzione si trova a operare con operandi che provengono da istruzioni di iterazioni precedenti. E' come se avessi una pipeline del programma in cui ogni istruzione che parte è in pipeline con quella successiva del loop ma la quale è agganciata a quella precedente. Posso immaginare una specie di diagramma:
Abbiamo strutturato la pipeline e abbiamo un transitorio di svuotamento e uno di riempimento. Vediamo come viene scritto il codice: Una prima parte transitoria: L.D X L.D Y L.D X L.D Y ADD LD LD ADD MULT Adesso parte il loop vero e proprio, questo viene organizzato in loop N-3 volte. LD LD ADD MULT STORE Dopo questo loop abbiamo ADD MULT STORE MULT STORE STORE La struttura è completamente differente dal loop unrolling, un transitorio di riempimento, un loop che gira N-3 dove 3 sono le 3 iterazioni e infine un transitorio di riempimento dove si completano a vicenda ( 3 2 1).
VEDERE DISPENSE DI CALCOLATORI A PARTIRE DA PAGINA 91, SONO LE SLIDE PROIETTATE IN CLASSE CON SPIEGAZIONI.
Possiamo avere un CPI minore di 1? In maniera teorica è possibile supponendo di poter prelevare più di un’istruzione per colpo di clock. Se ne riesco a prelevare 1 sola, sono limitato a non poter eseguire più di un’istruzione per colpo di clock. Facciamo finta di avere una memoria con banda più importante, riesco a fare un CPI minore di 1? Si, ma bisogna capire che cosa dobbiamo gestire. Nel momento in cui abbiamo la possibilità di prelevare più di un’istruzione per colpo di clock, dobbiamo anche eseguirle e devo far si di non avere conflitti di dati tra le istruzioni. Se sto eseguendo contemporaneamente 2 istruzioni, e una vuole usare il risultato dell’altra, è chiaro che questa cosa non deve avvenire. La prima cosa è poter prelevare, prendo 2 istruzioni: i EX i+1 EX Queste 2 istruzioni le posso eseguire contemporaneamente, se non ho conflitti di dati e strutturale. Nel senso che, se sto facendo la EX di una e dell’altra, non possono poter usare entrambe l’ALU, o se lo devono usare, devono esserci necessariamente 2 ALU. Ci viene in mente una certa cosa vista quando abbiamo definito il processore fp. Abbiamo una parte di hardware completamente distinta dalla parte di hardware a virgola fissa. Le unità di calcolo sono diverse e anche i registri erano registri diversi. Se preleviamo 2 istruzioni e queste sono una che opera in virgola fissa, e una che opera a virgola mobile, abbiamo risolto, in 1 colpo, solo i problemi enunciati poco fa(un risultato in virgola mobile non viene usato da quello a virgola fissa e viceversa, tanto è vero che sono su 2 banchi di registri differenti). C’è un eventuale conflitto solo se entrambe vogliono fare accesso in memoria Questo avviene solo con operazioni di tipo LOAD/STORE, ovvero non è detto che tutte le istruzioni siano necessariamente di LOAD o STORE. Immagino una situazione in cui un processore, se riesce a leggere 2 istruzioni dalla memoria, e casualmente queste 2 istruzioni sono una virgola intera e una in virgola mobile, le 2 istruzioni possono vivere simultaneamente senza dover stare a litigare né sui dati né sulle risorse di calcolo. Se il compilatore è “stato bravo” a scrivere il programma, che ha una serie di istruzioni ( alternanza fissa mobile), e riesco ad andare in memoria, non a leggere un'istruzione ma a leggerne 2, trovo all’interno del processore 2 istruzioni che, senza aver fatto cose complicate( come raddoppiare l’ ALU), possono essere eseguite contemporaneamente senza conflitti di dato e strutturale. Questo processore si chiama processore superscalare Ogni colpo di clock opera su qualcosa che è più di uno scalare. E' chiaro che il compilatore non sempre riesce a fare un lavoro di questo genere. Un processore superscalare, quindi, parte prelevando le 2 istruzioni, fa immediatamente un controllo per vedere se sono una fissa e una mobile, se lo sono vengono eseguite in contemporanea. Se sono dello stesso tipo, farò partire la 1° e l’altra la terrò posteggiata a partire dal prossimo colpo di clock. L’efficienza di questo tipo di macchina si potrà quantificare in funzione del programma che la deve eseguire. Nel momento in cui ho un programma, in cui il compilatore è riuscito a schedulare in coppie di istruzioni di diverso tipo, siamo apposto; altrimenti avrò prelevato una coppia dalla memoria, ma il prossimo fetch viene stallato perché ho ancora da eseguire l’avvio della 2° istruzione. Vediamo come questo schema può essere ancora più potenziato. Dal punto di vista del conflitto strutturale, abbiamo un ulteriore margine di lavoro: è vero che all'interno abbiamo una dicotomia fra unità che gestiscono dati interi e fp, ma si è detto che all'interno della parte di processore del fp, le unità di calcolo sono separate e indipendenti (esiste anche una certa ridondanza di queste unità Più di una). Supponendo di avere a che fare con istruzioni che non hanno conflitto di dati, posso pensare di avviare contemporaneamente una intera
e 2o3 in virgola mobile, che però non devono far uso di dati dipendenti ma non devono usare la stessa unità funzionale. Possiamo immaginare un sistema che prelevi un certo numero di istruzioni o, se vogliamo, un istruzione che conterrà al suo interno le informazioni di più di un’istruzione, quella che viene chiamata un’istruzione molto lunga, ( a very long instruction word) e gestire un processore che prelevi un’istruzione che sia una vliw e, da questi, se le istruzioni che sono contenute in questa vliw non sono conflittevoli tra loro, allora le posso eseguire contemporaneamente. Per fare questo bisogna capire, però, come gestire e organizzare queste istruzioni. L’idea è avere un gruppo d’istruzioni che fa uso di unità strutturali diverse da quelle altre istruzioni Se in un modo riesco ad averlo, posso pensare di avviare un’istruzione che al suo interno abbia più di un’istruzione.