Università degli Studi di Udine
Facoltà di Scienze Matematiche Fisiche e Naturali
Corso di Laurea Triennale in Informatica
Tesi di Laurea
Implementazione distribuita del
linguaggio Pascal
Candidato:
Relatore:
Matteo Cicuttin
Prof. Marco Comini
Anno Accademico 2006/2007
Università degli Studi di Udine
Via delle Scienze, 206
33100 Udine
Italia
A tutti quelli che hanno fatto in modo che arrivassi fino a qui, in particolare alla
mia famiglia, che mi ha dato ogni supporto, incondizionatamente.
A tutti quelli che in qualche modo mi hanno ostacolato o non hanno creduto in
questa mia impresa, che mi hanno dato un’occasione per crescere ed imparare a
muovermi da solo.
Ringraziamenti
I miei più sentiti ringraziamenti vanno a tutti coloro che mi sono stati vicini durante
questi anni di università, a tutta l’ASCI che ha sopportato la mia politica di rete
“default deny”, in particolare all’ultimo direttivo di cui ho fatto parte e a Beppe,
che ha sopperito alle mie “mancanze” dal ruolo di presidente durante questo ultimo
anno in cui sono stato veramente carico al 100%.
Grazie alla combriccola del Tomadini, ovvero Attila, Baba grande, Baba piccolo,
Mio e Samuele per il fantastico anno passato in quel luogo altrimenti, dal punto di
vista persone, pessimo.
Grazie a Jimmy, che ha sopportato le mie divagazioni durante i momenti di studio
che avrebbero dovuto essere seri.
Sono in debito con tutti i miei professori dell’ITIS Da Vinci di Portogruaro,
per la solida preparazione che mi hanno dato. Grazie a loro ho potuto “vivere di
rendita” in parecchie occasioni all’università. Continuate cosi! Un grazie particolare
va ai miei insegnanti di matematica: alla Prof.ssa Volcan che ha messo le basi per
permettermi di “fare pace” con questa disciplina. Nonostante il mio 4 fisso, ha
creduto in me e ha visto oltre al voto. A Paolo, che ha avuto la capacità di farmi
vedere il lato divertente dei numeri anche se non mi volevano entrare in testa. Al
Prof. Rizzetto, che mi ha trasmesso il fascino e la perfezione della matematica, e
che con il 9 in quinta mi ha convinto che era alla mia portata. L’informatica è
qualcosa di molto diverso da un semplice computer: l’informatica vera è fatta di
teoremi e dimostrazioni ed impone un certo rigore matematico. È grazie a loro che
sono riuscito ad entrare nell’ottica giusta e a laurearmi in questa disciplina e spero
che questo mio risultato possa essere per tutti loro una piccola ricompensa per la
fiducia che hanno riposto in me.
Elaborare questa tesi è stato un compito non difficile, ma decisamente molto
impegnativo, infatti il rapporto
ragionamento
righe di codice
è stato per me abbastanza alto. Tut-
tavia, questo lavoro mi ha migliorato, e non di poco. Mi ha innanzitutto permesso
di esplorare il campo dei compilatori: prima di accettare questa tesi, per me essi
erano qualcosa di simile alla magia nera, e non avrei mai creduto che sarei riuscito a
scriverne uno, traendone tra l’altro notevole soddisfazione. Durante la costruzione
di questo sistema sono andato diverse volte “off topic”. Se da un lato ciò mi ha fatto
perdere tempo, dall’altro ho avuto modo di imparare moltissimo sui compilatori.
Un lavoro come questo, che sfiora le 10000 righe di codice (senza considerare tutto il
codice che è finito in /dev/null), inoltre mi ha permesso di rendermi conto almeno
un po’ di cosa significhi programmare “in grande” e di quanto sia importante in ogni
caso progettare per bene prima di “fare”. Mi ha anche fatto capire che è assolutamente necessario che, finito questo lavoro, mi metta a studiare come progettare
ad oggetti. Infine, ho imparato (in parte) LATEX. Se avessi dovuto scrivere questa
tesi con il famoso word processor di $(una_software_house_a_caso), sarebbe stata
una tragedia.
In breve, ora mi sento una persona diversa ed il mio grazie va a tutti coloro che
hanno fatto si che questo succedesse.
La presente tesi è stata prodotta utilizzando esclusivamente software open source. L’ambiente LATEXutilizzato è TeXShop. Per la grafica vettoriale è stato utilizzato
InkScape. Per i grafi è stato utilizzato GraphViz.
La comunità che ruota attorno all’open source ci offre del software ottimo senza
chiederci alcunché in cambio: per questo motivo tutto il lavoro svolto è disponibile all’indirizzo http://sourceforge.net/projects/opc, nella speranza che, come i tre
software che ho citato mi sono stati utili nell’elaborarlo, anche tutto questo lo sia a
qualcuno.
Indice
1 Introduzione
1.1
1.2
Descrizione generale del sistema . . . . . . . . . . . . . . . . . . . . .
2
1.1.1
L’ambiente runtime . . . . . . . . . . . . . . . . . . . . . . .
3
1.1.2
Il compilatore . . . . . . . . . . . . . . . . . . . . . . . . . . .
3
Organizzazione del lavoro . . . . . . . . . . . . . . . . . . . . . . . .
5
2 Panoramica sulle architetture parallele e distribuite
2.1
2.2
2.3
7
Parallelismo fine-grained . . . . . . . . . . . . . . . . . . . . . . . . .
8
2.1.1
Pipeline . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8
2.1.2
Processori vettoriali . . . . . . . . . . . . . . . . . . . . . . .
8
Parallelismo coarse-grained . . . . . . . . . . . . . . . . . . . . . . .
9
2.2.1
Architetture a memoria condivisa . . . . . . . . . . . . . . . .
10
2.2.2
Architetture a memoria distribuita . . . . . . . . . . . . . . .
10
Programmazione delle macchine a memoria distribuita con MPI . . .
11
2.3.1
Concetti alla base di MPI . . . . . . . . . . . . . . . . . . . .
12
2.3.2
Esempio di utilizzo: Integrazione numerica con MPI . . . . .
13
2.3.3
Compilazione ed utilizzo del software in ambiente MPI . . . .
15
3 P-Code e P-Machine
3.1
1
17
I registri della P-Machine . . . . . . . . . . . . . . . . . . . . . . . .
18
3.1.1
PC - Il program counter . . . . . . . . . . . . . . . . . . . . .
18
3.1.2
SP - Stack Pointer . . . . . . . . . . . . . . . . . . . . . . . .
18
3.1.3
MP - Mark Stack Pointer . . . . . . . . . . . . . . . . . . . .
19
3.2
Istruzioni aritmetico/logiche . . . . . . . . . . . . . . . . . . . . . . .
19
3.3
Istruzioni di LOAD/STORE . . . . . . . . . . . . . . . . . . . . . . .
20
3.3.1
LDA - Load Address . . . . . . . . . . . . . . . . . . . . . . .
21
3.3.2
LDI - Load Immediate . . . . . . . . . . . . . . . . . . . . . .
23
3.3.3
LDC - Load Constant . . . . . . . . . . . . . . . . . . . . . .
23
ii
Indice
3.3.4
LOD - Load Indirect . . . . . . . . . . . . . . . . . . . . . . .
23
3.3.5
STO - Store . . . . . . . . . . . . . . . . . . . . . . . . . . . .
23
3.4
Istruzioni di accesso con indice . . . . . . . . . . . . . . . . . . . . .
24
3.5
Istruzioni di salto e di confronto
. . . . . . . . . . . . . . . . . . . .
25
3.5.1
UJP - Unconditional Jump . . . . . . . . . . . . . . . . . . .
25
3.5.2
FJP - Jump On False . . . . . . . . . . . . . . . . . . . . . .
25
Istruzioni relative alle chiamate . . . . . . . . . . . . . . . . . . . . .
26
3.6.1
Istruzioni relative al chiamante . . . . . . . . . . . . . . . . .
26
3.6.2
Istruzioni relative al chiamato . . . . . . . . . . . . . . . . . .
28
3.6
4 Architettura del compilatore
4.1
29
Introduzione al concetto di linguaggio formale . . . . . . . . . . . . .
30
4.1.1
Linguaggi regolari . . . . . . . . . . . . . . . . . . . . . . . .
30
4.1.2
Linguaggi context-free . . . . . . . . . . . . . . . . . . . . . .
31
Analisi Lessicale e Sintattica . . . . . . . . . . . . . . . . . . . . . . .
33
4.2.1
Lo scanner . . . . . . . . . . . . . . . . . . . . . . . . . . . .
33
4.2.2
Il parser . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
34
4.3
Analisi semantica . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
38
4.4
Determinazione del tipo delle espressioni . . . . . . . . . . . . . . . .
39
4.5
La tabella dei simboli . . . . . . . . . . . . . . . . . . . . . . . . . .
40
4.6
Generazione del codice . . . . . . . . . . . . . . . . . . . . . . . . . .
41
4.7
Conclusioni . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
43
4.2
5 Tecniche di analisi volte alla parallelizzazione automatica
5.1
5.2
45
Analisi delle dipendenze . . . . . . . . . . . . . . . . . . . . . . . . .
45
5.1.1
Dipendenze sui dati . . . . . . . . . . . . . . . . . . . . . . .
45
5.1.2
Dipendenze sui cicli . . . . . . . . . . . . . . . . . . . . . . .
48
5.1.3
Dipendenze sul controllo . . . . . . . . . . . . . . . . . . . . .
48
Analisi dei dati utilizzati dal codice . . . . . . . . . . . . . . . . . . .
49
5.2.1
49
Condizioni di Bernstein . . . . . . . . . . . . . . . . . . . . .
6 Descrizione del lavoro svolto
53
6.1
Idea generale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
53
6.2
Modifiche al compilatore . . . . . . . . . . . . . . . . . . . . . . . . .
55
Indice
6.3
iii
6.2.1
Le relazioni uses e usedby . . . . . . . . . . . . . . . . . . . .
55
6.2.2
Operazione di join . . . . . . . . . . . . . . . . . . . . . . . .
58
6.2.3
Numerazione delle funzioni . . . . . . . . . . . . . . . . . . .
59
6.2.4
Numerazione delle variabili ed allocazione . . . . . . . . . . .
60
Modifiche all’ambiente runtime . . . . . . . . . . . . . . . . . . . . .
62
6.3.1
Istruzione WAIT . . . . . . . . . . . . . . . . . . . . . . . . .
63
6.3.2
Istruzione CUP . . . . . . . . . . . . . . . . . . . . . . . . . .
64
6.3.3
Istruzione RET . . . . . . . . . . . . . . . . . . . . . . . . . .
65
Conclusioni
67
Bibliografia
69
iv
Indice
Elenco delle figure
1.1
Albero di sintassi astratta . . . . . . . . . . . . . . . . . . . . . . . .
4
2.1
Multiprocessore a memoria condivisa . . . . . . . . . . . . . . . . . .
10
2.2
Macchina a memoria distribuita . . . . . . . . . . . . . . . . . . . . .
11
3.1
Struttura a livelli del linguaggio Pascal . . . . . . . . . . . . . . . . .
18
3.2
Collocazione di un array in memoria . . . . . . . . . . . . . . . . . .
24
3.3
Stack frame . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
28
4.1
Albero di sintassi astratta . . . . . . . . . . . . . . . . . . . . . . . .
35
4.2
Albero di sintassi astratta completo di informazione di tipo . . . . .
40
4.3
Tabella dei simboli . . . . . . . . . . . . . . . . . . . . . . . . . . . .
41
4.4
Albero per l’espressione T rue or F alse and T rue . . . . . . . . . . .
42
6.1
Esempio di programma che contiene funzioni separabili . . . . . . . .
54
6.2
Programma con cluster non banali . . . . . . . . . . . . . . . . . . .
56
6.3
Albero con le informazioni relative ai cluster . . . . . . . . . . . . . .
59
vi
Elenco delle figure
1
Introduzione
Quando negli anni ’40 nacquero i primi calcolatori elettronici, mai si sarebbe immaginato un loro cosı̀ massiccio e pesante impiego come quello che possiamo osservare
ai giorni nostri. Oggi, pur non pensandoci, siamo continuamente assistiti da diversi
computer, da quelli di scala più ridotta, come l’orologio digitale che indossiamo, a
quelli di classe server con i quali interagiamo ad esempio durante la navigazione in
Internet. La loro flessibilità ci porta a chiedere a queste macchine di automatizzare
sempre più operazioni quotidiane e offrire sempre più servizi. Nell’ambito scientifico
i computer sono di primaria importanza nelle simulazioni e in altre applicazioni che
richiedano enormi moli di calcoli. Il sempre crescente carico di lavoro che imponiamo alle macchine ha richiesto un continuo sviluppo dell’hardware, fino ad arrivare
ai sistemi SMP (Simmetrical Multi Processor) e più recentemente SMT (Simultaneous Multi Threading) e CMP (Chip-level Multi Processor). Se i primi non hanno
conosciuto una grande diffusione sotto alla scrivania dell’utente generico, gli SMT
e i CMP stanno acquistando sempre maggiore diffusione a causa del loro basso costo. Il trend di sviluppo dell’hardware di calcolo si sta orientando sempre più verso
queste architetture. Intel, maggiore produttore al mondo di microprocessori, ha un
prototipo di CPU ad 80 nuclei, mentre Sun Microsystems nel 2005 ha presentato
il chip Niagara, conosciuto anche come UltraSPARC T1. Questo chip esiste nelle
versioni da 4, 6 e 8 core, capace di eseguire 4 thread simultaneamente per ogni core.
Recentemente è stata presentata la versione T2, di capacità altamente superiori al
precedente T1. Anche IBM ha presentato un chip parallelo, però sostanzialmente
diverso dagli Intel Core e dagli UltraSPARC T1. Il chip in questione è CELL. Esso
è caratterizzato da un core basato su architettura Power, ed affiancato da 8 unità
specializzate in task di carattere multimediale, connesse tra loro ad altissima velocità. Un tale grado di parallelismo a livello di hardware richiede che il software sia
scritto secondo certi criteri, che mirino al massimo sfruttamento di questo tipo di
2
1. Introduzione
architetture. Purtroppo è il programmatore ad avere l’arduo compito di capire come
scrivere al meglio il software per sfruttare questo massiccio parallelismo. Esistono
numerosi tool e librerie che vengono in aiuto, basti pensare a MPI, PVM, MOSIX.
Il risultato rimane comunque qualcosa di “fatto a mano”.
Diversi lavori sono stati fatti nella direzione della parallelizzazione automatica:
IBM sta lavorando ad un compilatore sperimentale, Octopiler, per ottenere binari
“paralleli” da eseguire su CELL. Dai report di IBM si può vedere che con l’aiuto del
compilatore si possono ottenere incrementi di performance in alcuni casi anche di
20 volte. Anche la Stanford University ha sviluppato un compilatore parallelizzante
di livello molto avanzato, SUIF, non per CELL ma general-purpose.
Questa tesi si pone come obiettivo l’esplorazione di alcune tecniche volte a cercare il parallelismo all’interno del codice di un programma, e alla compilazione di
tale codice in un formato adatto all’esecuzione su più unità di calcolo di una macchina a memoria distribuita, svincolando in parte il programmatore dal compito di
parallelizzare manualmente il codice. L’idea che ha guidato l’implementazione di
questo sistema è che in un software generalmente esistono sezioni di codice che non
condividono dati. Tali sezioni di codice, previa la creazione di un ambiente adatto,
possono essere fatte eseguire su una macchina diversa da quella in cui il programma
è stato lanciato. Per tali sezioni di codice viene generata dal compilatore una chiamata di funzione remota, che di fatto trasferisce l’esecuzione su una CPU remota.
Al momento del ritorno della funzione remota il controllo viene restituito alla CPU
chiamante. Se da un lato il singolo programma non trae alcun beneficio da un simile
approccio, dall’altro lato lanciare più programmi compilati in questo modo su una
macchina a memoria distribuita ha come risultato che i vari frammenti sono messi
in esecuzione su CPU differenti, di fatto utilizzando tutte le risorse di calcolo a disposizione. Questo pertanto può essere visto come un approccio “trasparente” alla
programmazione delle macchine a memoria distribuita.
1.1
Descrizione generale del sistema
Il sistema presentato si compone di un compilatore “distribuente” e di un ambiente
runtime “distribuito” adatto ad eseguire il bytecode generato dal compilatore stesso.
L’implementazione è da considerarsi un prototipo: si è scelto di concentrarsi maggiormente sulle tecniche di analisi piuttosto che sui dettagli relativi all’hardware ed
al compilatore stesso. L’ambiente runtime è come idea simile ad una Java Virtual
Machine, ma invece di eseguire il bytecode Java esegue il P-Code, ovvero il codice
1.1. Descrizione generale del sistema
3
intermedio del linguaggio Pascal. La macchina intermedia del Pascal è, come la
JVM, una macchina a stack, architettura che si contrappone alle macchine a registri, quali le CPU che sono installate in qualunque elaboratore. L’aver scelto Pascal
come linguaggio su cui sviluppare tutto il lavoro ha diversi risvolti interessanti per
il lavoro stesso: innanzitutto, il linguaggio scelto permette dichiarazioni di funzioni
annidate, e questo rende non banale il compito di decidere quali siano le sezioni di
codice che è possibile rendere indipendenti. Inoltre sviluppare un compilatore per
esso è molto semplice. Infine, la portabilità è garantita: infatti è sufficiente ricompilare la macchina virtuale per poter eseguire su qualunque architettura il codice
ottenuto dal compilatore.
1.1.1
L’ambiente runtime
Una macchina a stack è in tutto e per tutto una calcolatrice RPN. Chi è abituato
ad utilizzare una calcolatrice HP troverà il concetto della macchina a stack molto
familiare. Supponiamo di voler calcolare il valore dell’espressione seguente:
5 + 3 ∗ 8 − 4/6
Essa, equivale all’espressione in forma postfissa:
538 ∗ +46/−
Le istruzioni da eseguire per ottenere il risultato saranno pertanto:
push 5
push 3
push 8
mul
add
push 4
push 6
div
sub
L’espressione inoltre corrisponde al seguente albero, che è qualcosa di vagamente
simile all’albero di sintassi astratta prodotto dal compilatore:
1.1.2
Il compilatore
Il parser presente all’interno del compilatore, genera tramite una semplice tecnica
approfondita in seguito, denominata “a discesa ricorsiva”, l’albero di sintassi astratta. Una volta ottenuta una simile rappresentazione, una visita in post-ordine genera
4
1. Introduzione
sub
5
3
add
div
mul
4
6
8
Figura 1.1: Albero di sintassi astratta
il codice. L’algoritmo, molto informalmente, può essere rappresentato nel seguente
modo:
void g e n e r a t e c o d e (ASTNode ∗ node )
{
g e n e r a t e c o d e ( node−> l e f t ) ;
g e n e r a t e c o d e ( node−>r i g h t ) ;
node−>emit my co de ( ) ;
}
Prima di poter effettivamente emettere il codice è però necessario innanzitutto
capire se l’albero generato dal parser rappresenta un programma sensato, e questo
controllo è svolto dall’analisi semantica. In seguito si deve procedere alle analisi volte a capire quali dati condividono le funzioni. Esse si articolano in due macro-fasi:
la prima macro-fase ha il compito di capire effettivamente “chi usa cosa” all’interno
del codice. La seconda macro-fase ha il compito di allocare i dati in modo opportuno ai vari blocchi di codice eseguibile che si hanno in output. Tutto il lavoro di
implementazione si è svolto in due passi: prima sono stati scritti un compilatore
ed una macchina virtuale “standard”, ed in seguito li si sono modificati in modo
da supportare quanto proposto. Il lavoro più consistente è stato svolto dal lato del
1.2. Organizzazione del lavoro
5
compilatore. All’ambiente runtime è solo stata aggiunta un’istruzione ed è stata
modificata la semantica di altre due.
1.2
Organizzazione del lavoro
I successivi capitoli si sviluppano inizialmente presentando alcune tipologie di hardware parallelo e un paradigma per programmare una particolare classe di questo
hardware, ovvero le macchine a memoria distribuita. Il paradigma è il Message
Passing. In seguito vengono presentati in dettaglio il P-Code ed il compilatore, assieme ad alcune ulteriori osservazioni volte a parallelizzare il codice in maniera più
spinta, al fine di contestualizzare il capitolo 6, che parla delle tecniche di analisi
implementate.
6
1. Introduzione
2
Panoramica sulle architetture
parallele e distribuite
Negli ultimi anni abbiamo assistito ad un’incredibile esplosione delle performance
dei calcolatori. Per fare un esempio, nel 1984 un personal computer di fascia alta,
quale poteva essere L’HP-150 con l’unità disco 9153C aveva una CPU 8088 ad 8
MHz. Qualche anno più tardi, nel 1988, erano disponibili le CPU Intel 80286 a 12
MHz. A metà degli anni ’90 già erano disponibili processori ad oltre 100 MHz e
l’inizio di questo secolo ha visto i produttori di CPU infrangere la soglia di 1 GHz.
Dopo soli 5 anni dal 2000 è stata infranta la barriera dei 3 GHz.
La legge di Moore, che prende il nome da uno dei fondatori di Intel, sostiene
che il numero di transistori contenuti nei chip di memoria raddoppia ogni 18 mesi,
in altre parole la crescita predetta da Moore è esponenziale. Qualcuno ha adattato
questa legge anche alle prestazioni di calcolo dei chip. Se osserviamo bene l’evoluzione appena descritta, la legge di Moore sembra corretta. Tuttavia, nonostante in
ambito scientifico ed industriale la potenza di calcolo non sia mai sufficiente, prima
o poi questa crescita dovrà fermarsi a causa delle leggi fisiche. È infatti noto che i
segnali elettrici viaggiano sui cavi a velocità finita, pari a circa 23 c. Per esempio, a
clock elevati, una semplice curva delle linee di un bus parallelo porterebbe il segnale
che transita nelle linee più esterne ad arrivare a destinazione in ritardo rispetto a
quello che transita all’interno. Tale fenomeno è noto con il nome di bus skew. I
ritardi costringono pertanto a costruire circuiti sempre più piccoli, per permettere
al segnale di arrivare in tempo. Ma anche rimpicciolendo i transistori, oltre all’oggettiva difficoltà di farlo, si giunge al limite della dimensione dell’atomo. A tali scale
di grandezza diventano non trascurabili svariati fenomeni di natura quantistica.
Se la velocità della luce e le dimensioni infinitesime dei transistori sono dei limiti
insormontabili imposti dalla fisica, allora invece di cercare di abbatterli, è opportuno cercare di aggirarli, ed è a questo proposito che entra in gioco la computazione
8
2. Panoramica sulle architetture parallele e distribuite
parallela. Gli approcci al parallelismo (ed i problemi che insorgono) sono innumerevoli, ed una loro completa trattazione esula dagli scopi di questa tesi. [10] è un buon
punto di partenza per un approfondimento. Riguardo a questo tema, ci limiteremo a
identificare la categoria del parallelismo fine-grained e quella del parallelismo coarsegrained. Possiamo indicativamente dire che il parallelismo fine-grained è qualcosa
a livello di istruzione macchina, mentre il parallelismo coarse-grained è qualcosa a
livello di “architettura di calcolo”.
2.1
Parallelismo fine-grained
Il parallelismo di tipo fine viene implementato all’interno del singolo microprocessore,
tramite tecniche che permettono alle unità di calcolo di eseguire o più operazioni
contemporaneamente o la stessa operazione su più dati. Nel primo caso le operazioni
sono i passi fetch-decode-execute e parliamo di pipeline, nel secondo caso invece le
operazioni sono le istruzioni che una CPU può eseguire e parliamo di processore
vettoriale, ovvero di macchina SIMD in riferimento alla tassonomia di Flynn (Flynn,
1966). Esistono anche ulteriori tecniche ed ottimizzazioni: a livello di citazione si
possono indicare le CPU superscalari, che integrano più pipeline in modo da eseguire
più istruzioni contemporaneamente, l’esecuzione speculativa e la branch-prediction.
2.1.1
Pipeline
La pipeline si basa su un’osservazione molto semplice: le letture in memoria impiegano tempo, e durante le letture la CPU è ferma. Se facciamo in modo che la
CPU dopo aver letto un’istruzione la metta in esecuzione e contemporaneamente ne
prelevi un’altra, abbiamo (più o meno) ottenuto il risultato che la execution unit ha
sempre qualcosa da fare. In questo modello, detto pipeline a due stadi abbiamo l’esecuzione parallela della fase di fetch e della fase di decode+execute. Volendo, e nelle
CPU attuali viene fatto, si può aumentare il numero di stadi, in modo da aumentare
anche il grado di parallelismo. Questo però ha un prezzo, infatti nel caso in cui nel
programma ci sia un salto, parte del lavoro fatto dalla pipeline, in particolare quello
svolto fino alla decodifica dell’istruzione di salto, è sprecato. Gli attuali compilatori
ottimizzano il codice emesso al fine di evitare questo fenomeno, noto come “bolla”.
2.1.2
Processori vettoriali
Le CPU attuali si trovano normalmente alle prese con immagini e dati multimediali
in genere. Un’immagine ad esempio, può essere vista come un array di numeri
2.2. Parallelismo coarse-grained
9
che rappresentano il valore di ogni singolo pixel. Alternativamente, se abbiamo
un’immagine I di N ∗ M pixel, essa sarà rappresentata da un vettore contenente
N ∗ M quadruple nella forma P = hA, R, G, Bi. Normalmente R, G, B e A sono
valori ad 8 bit (1 byte), per cui se volessimo rappresentare I avremmo bisogno di
N ∗ M ∗ 4 byte. Una spiegazione pratica e molto semplicistica del funzionamento
di un’unità vettoriale è la seguente: un banale filtro potrebbe voler aggiungere una
quantità fissata, diciamo k, ad ogni pixel (l’effetto è quello di sbiadire l’immagine).
L’approccio standard è il seguente:
unsigned char ∗ img = new unsigned char [N∗M∗ 4 ] ;
l o a d i m a g e ( img ) ;
for ( i nt i = 0 ; i < N∗M∗ 4 ; i ++)
i f ( i % 4 != 0 )
/∗ s a l t a l ’ a l p h a c h a n n e l ∗/
img [ i ] += k ;
Immaginiamo ora un pixel come un vettore. L’algoritmo diventa il seguente:
vector unsigned char ∗ img = new vector unsigned char [N∗M] ;
vector unsigned char qty = { 0 , k , k , k } ;
l o a d i m a g e ( img ) ;
for ( i nt i = 0 ; i < N∗M; i ++)
vec a dd ( img [ i ] , img [ i ] , qty ) ;
Trascurando il dettaglio che in una situazione simile il pixel può essere visto come un
intero a 32 bit, quello che abbiamo fatto è stato di sommare invece che due scalari,
due vettori di 4 elementi, potenzialmente utilizzando lo stesso tempo che avremmo
impiegato per la somma scalare, ottenendo uno speedup ideale di 4. Ogni moderna
CPU integra un’unità vettoriale: AltiVec, VIS ed SSE sono le implementazioni delle
CPU PowerPC, SPARC e x86. I “veri” computer vettoriali, un esempio su tutti i
sistemi CRAY, sono qualcosa di più sofisticato ma l’idea su cui si basano è la stessa.
2.2
Parallelismo coarse-grained
Quando prendiamo molte CPU, le colleghiamo in qualche modo e le facciamo lavorare insieme, parliamo di parallelismo a grana grossa. Se le CPU sono collegate allo
stesso bus per accedere alla memoria, parliamo di architetture a memoria condivisa.
Altrimenti, se prendiamo qualche personal computer, li colleghiamo via ethernet e
ci installiamo MPI, abbiamo costruito un sistema a memoria distribuita.
10
2. Panoramica sulle architetture parallele e distribuite
Figura 2.1: Multiprocessore a memoria condivisa
2.2.1
Architetture a memoria condivisa
I multiprocessori a memoria condivisa sono il modo più semplice di implementare una macchina parallela, ed anche quelli più facili da programmare. Infatti, un
software scritto per macchine monoprocessore gira senza modifiche su macchine multiprocessore, e se strutturato in thread trae beneficio in modo trasparente dalle CPU
aggiuntive. Ovviamente, la semplicità del modello shared-memory porta con se degli inconvenienti notevoli. È facile rendersi conto che, essendo le CPU connesse allo
stesso bus, esse competono per l’accesso, ed in linea teorica, se B è la banda disponibile sul bus ed n è il numero di CPU, ogni singola CPU potrà contare su una
banda di Bcpu =
B
n.
Già quando si arriva ad 8 CPU si vede che il modello a memoria
condivisa standard funziona male, e per questo è necessario introdurre architetture di tipo NUMA. Lo sviluppo delle CPU inoltre ha visto un’evoluzione molto più
rapida di quella delle memorie e questo ha portato ad introdurre una gerarchia di
queste ultime, in primis le cache. Nel momento in cui diverse CPU si trovano a
lavorare sullo stesso insieme di dati, sorgono problemi di coerenza della cache che
non possono essere trascurati.
2.2.2
Architetture a memoria distribuita
Quando sono necessarie moltissime CPU, non potendo adottare il modello a memoria
condivisa, si dota ogni nodo di memoria propria, e lo si connette agli altri tramite reti ad alta velocità e bassa latenza, quali InfiniBand o Myrinet. L’approccio
alla programmazione però è completamente diverso, infatti questi sistemi vengono
programmati con il paradigma del message-passing, che porta con se una maggiore
difficoltà in termini di programmazione. In ambienti a memoria distribuita il non
scrivere il proprio codice ad hoc porta alla completa impossibilità di trarre un benefi-
2.3. Programmazione delle macchine a memoria distribuita con MPI
11
Figura 2.2: Macchina a memoria distribuita
cio da queste architetture, a differenza del caso delle macchine a memoria condivisa,
dove, lanciando diversi processi CPU-bound, è il sistema operativo ad occuparsi del
bilanciamento del carico. MOSIX, software nato all’università di Gerusalemme, ha
tentato di raccogliere i benefici di entrambe le architetture in una tipologia di clustering denominata Single System Image, ma, pur essendo un progetto estremamente
valido, non ha riscosso il giusto successo. Qualcosa di simile è correntemente in fase
di implementazione all’interno della variante DragonFly del sistema BSD. A differenza di MOSIX, che è una piccola patch del kernel Linux, DragonFlyBSD è una
fork del sistema FreeBSD, progettata con l’obiettivo di ottenere proprio un sistema
operativo distribuito che supporti nativamente il clustering SSI.
2.3
Programmazione delle macchine a memoria distribuita con MPI
Visto l’obiettivo della tesi, si è reso necessario far comunicare tutte le macchine
virtuali su cui gira il codice in modo distribuito. Molto probabilmente si sarebbe
rivelato opportuno progettare uno strato con delle primitive di comunicazione tra
macchine virtuali pensate ad-hoc per questa applicazione, ma per motivi di semplicità si è scelto di utilizzare MPI. MPI non è altro che una libreria di message-passing
pensata per il calcolo ad alte prestazioni. MPI è inoltre lo standard de-facto per
quanto riguarda le librerie di comunicazione nei sistemi a memoria distribuita. Esistono decine di diverse implementazioni di questa libreria di message passing. Le più
comuni sono senz’altro MPICH, MVAPICH (MPI over Verb API, implementazione
specifica per l’utilizzo su reti InfiniBand), LAM-MPI. Molte delle implementazioni
disponibili stanno però convergendo su OpenMPI, progetto che mira a creare la migliore libreria di message-passing disponibile, raccogliendo gli sforzi di tutti i progetti
12
2. Panoramica sulle architetture parallele e distribuite
che ruotano attorno ad essa. È stata utilizzata proprio OpenMPI per implementare
parte del software oggetto di questa tesi. Altra libreria degna di nota, ma non della
famiglia di MPI è PVM (Parallel Virtual Machine). Verranno ora presentati i concetti e le primitive strettamente necessari allo sviluppo di questa tesi. La libreria è
qualcosa di molto più esteso e versatile, ed esite un’ampia documentazione di essa.
Per ulteriori approfondimenti si veda, ad esempio [9].
2.3.1
Concetti alla base di MPI
MPI, come detto, è una libreria di message passing, disponibile per C, C++ e Fortran, adatta ad essere utilizzata su numerose tipologie di architetture a memoria
distribuita. Il compito principale di MPI è quello di astrarre dalla specifica tipologia
di interconnessione dei vari nodi di un sistema, sia essa Ethernet, InfiniBand, Myrinet o quant’altro, offrendo un modo semplice e snello di eseguire le comunicazioni
tra i processi.
Parlando di Message Passing, ci aspettiamo quantomeno un modo per inviare e ricevere dei messaggi. MPI mette a disposizione le funzioni MPI_Send e MPI_Recv a
questo scopo [9]. Di seguito i loro prototipi:
MPI_Send (void *msgbuf
/* in */,
int count
/* in */,
MPI_Datatype dt
/* in */,
int destination
/* in */,
int tag
/* in */,
MPI_Comm comm
/* in */);
MPI_Recv (void *msgbuf
/* out */,
int count
/* in */,
MPI_Datatype dt
/* in */,
int source
/* in */,
int tag
/* in */,
MPI_Comm comm
/* in */
MPI_Status* status
/* out */);
All’avvio di un programma parallelo basato su MPI, viene stabilito il numero di
processi: in altre parole, i processi che fanno parte del programma sono gli stessi
dall’inizio alla fine. Ogni processo possiede un identificatore, il rank, valore di tipo
2.3. Programmazione delle macchine a memoria distribuita con MPI
13
intero. Nel caso un programma sia composto da p processi, il rank sarà compreso nell’intervallo 0, 1, 2, ..., p − 1. Il rank è unico, ovvero non esistono due processi
con lo stesso rank. Osservando i prototipi di MPI_Send e MPI_Recv, notiamo rispettivamente due parametri, destination e source. Nel caso della trasmissione
del messaggio, destination rappresenta il rank del processo a cui vogliamo spedire
qualcosa. Analogamente, nel caso della ricezione, source rappresenta il rank del
processo da cui vogliamo ricevere qualcosa.
Un esempio banale dell’utilizzo delle due funzioni appena descritte potrebbe essere
il seguente:
/∗ P r o c e s s o che i n v i a un m e s s a g g i o ( rank 0) ∗/
#define BUFSZ 10
char buf [ BUFSZ ] ;
s t r n c p y ( buf , pippo , BUFSZ)
MPI Send(&buf , s t r l e n ( buf ) , MPI CHAR,
1 , 0 , MPI COMM WORLD ) ;
/∗ P r o c e s s o che r i c e v e un m e s s a g g i o ( rank 1) ∗/
MPI Recv(&buf , BUFSZ, MPI CHAR,
0 , 0 , MPI COMM WORLD, &s t a t u s ) ;
Generalmente entrambe le precedenti istruzioni trovano posto nello stesso programma, l’esecuzione di una piuttosto che dell’altra è decisa a runtime in base al rank
del processo.
L’esempio seguente, tratto da [9], mette chiaramente in luce gli aspetti di base della
programmazione con MPI.
2.3.2
Esempio di utilizzo: Integrazione numerica con MPI
Un semplicissimo modo di calcolare l’integrale definito di una funzione, ad esempio
f (x), è quello di suddividere il suo dominio in un certo numero, diciamo n, di intervalli. Una volta fatto ciò, proiettando ogni suddivisione sul grafico della funzione e
congiungendo con dei segmenti i punti cosı̀ ottenuti, otteniamo una serie di trapezi.
Sommando la loro area si ottiene un’approssimazione dell’integrale cercato. Supponiamo che tutte le suddivisioni siano di uguale lunghezza: dati a e b gli estremi di
integrazione, pertanto, la lunghezza della base di ogni trapezio sarà:
B=
b−a
n
14
2. Panoramica sulle architetture parallele e distribuite
Le basi dei singoli trapezi saranno pertanto gli intervalli [a, a + B], [a + B, a + 2B], ....
Più in generale, la base dell’i-esimo trapezio sarà l’intervallo:
[a + (i − 1)B, a + iB].
Sia ora xi = a + iB, con 0 < i < n. I lati sinistro e destro di ogni trapezio saranno
pertanto dati rispettivamente da f (xi−1 ) e f (xi ). Da questo ragionamento si deduce
che l’area dell’i-esimo trapezio sarà data da:
B[f (xi−1 ) + f (xi )]
2
L’area totale tra gli estremi di integrazione scelti pertanto sarà data dalla sommatoria:
n
X
B[f (xi−1 ) + f (xi )]
i=0
2
che con semplici passaggi si trasforma in:
n−1
f (x0 ) + f (xn ) X
B
f (xi )
+
2
i=1
Implementare un simile procedimento in modo sequenziale si riduce a poco più di
un ciclo for.
double i n t e g r a t e ( double ( ∗ f ) ( double x ) ,
double a , double b , i nt n )
{
double r e s u l t , B, x ;
i nt i ;
B = ( b−a ) / n ;
r e s ul t = ( f (a) + f (b ))/2;
x = a;
for ( i nt i = 1 ; i < n ; i ++)
{
x += B;
r e s u l t += f ( x ) ;
}
r e s u l t ∗= B ;
return r e s u l t ;
}
Volendo parallelizzare questo algoritmo, l’idea è quella di far calcolare i valori di
f (x) in diversi sottointervalli di [a, b] a diversi processi. Se ad esempio abbiamo a
disposizione 4 processori, e vogliamo calcolare l’integrale utilizzando n = 100, allora
2.3. Programmazione delle macchine a memoria distribuita con MPI
15
assegneremo 25 punti per processore. Essendo quello di MPI un approccio SPMD,
decideremo in quale intervallo eseguire i calcoli in base al rank assegnato al singolo
processo. Di seguito lo pseudo-codice.
void p a r a l l e l i n t e g r a t e ( double ( ∗ f ) ( double x ) ,
double a , double b , i nt n )
{
B = ( b−a ) / n ;
l o c a l n = n/ t o t a l p r o c e s s e s ;
l o c a l a = a+my rank ∗ l o c a l n ∗B ;
l o c a l b = l o c a l a + l o c a l n ∗B ;
r e s u l t = integ r a te ( f , local a , local b , local n , B) ;
i f ( rank == 0 )
{
double r c v ;
for ( i nt s r c = 1 ; i < n ; i ++)
{
MPI Recv(&rcv , 1 , MPI DOUBLE, s r c , [ . . . ] ) ;
r e s u l t += r c v ;
}
}
else
{
MPI Send(& r e s u l t , 1 , MPI DOUBLE, 0 , [ . . . ] )
}
i f ( rank == 0 )
printf ( result );
}
2.3.3
Compilazione ed utilizzo del software in ambiente MPI
Una volta scritto il proprio codice, al fine di poterlo rendere eseguibile, le implementazioni di MPI disponibili mettono solitamente a disposizione dei wrapper per
il compilatore di sistema. Tale wrapper, (mpicc, mpif77, ..) si occupa di collegare
l’eseguibile con tutte le opportune librerie. In seguito, è necessario lanciare il proprio
codice tramite il runtime MPI, e per questo viene fornito il comando mpirun. Nel
caso di OpenMPI, mpirun si aspetta di avere in input un hostfile, nel quale sono
specificate tutte le macchine disponibili nel proprio cluster. Una volta ottenuti gli
indirizzi IP di tutti gli host, mpirun lancia su ogni macchine orted, ed in seguito
l’eseguibile MPI.
Il demone orted, parte integrante di OpenMPI, si occupa di gestire tutte le comunicazioni relative al message-passing tra i vari nodi del cluster. Pertanto, una
volta compilato il proprio codice, volendolo far eseguire su n CPU, si darà un co-
16
2. Panoramica sulle architetture parallele e distribuite
mando del tipo: mpirun -np n -hostfile myhostfile.dat ./myprogram.
3
P-Code e P-Machine
Il P-Code è il linguaggio macchina della P-Machine, una macchina intermedia di
alcune implementazioni di Pascal. Questa macchina viene implementata via software su delle architetture fisiche. Il perché si dovrebbe voler far eseguire del codice
macchina ad un software è dettato da motivi che sono i più svariati:
• Portabilità degli eseguibili risultanti dal codice sorgente su sistemi operativi e
hardware diversi.
• Facilitazioni nel debugging
• Facilitazioni nella scrittura del compilatore
• Più generalmente, riduzione della complessità di implementazione del linguaggio
Le motivazioni appena elencate hanno portato diversi implementatori a strutturare
i loro linguaggi di programmazione in modo da essere compilati in codice astratto
e poi essere eseguiti da una macchina astratta [3]. Altri linguaggi oltre Pascal che
appartengono a questa categoria sono Java (compilato in Java Bytecode), Basic
(anche esso in P-Code), BCPL (in O-code).
Il P-Code mette a disposizione una serie di insiemi di istruzioni che ricordano
molto quelle di una macchina hardware, tranne per il fatto che solo dove è strettamente necessario prendono uno o più operandi. Questo è dovuto alla natura a stack
della macchina, che conferisce al suo codice delle caratteristiche leggermente di più
alto livello rispetto ad un assembly di una macchina a registri. La macchina in realtà
possiede alcuni registri, ma che servono soltanto al funzionamento della macchina
stessa e non sono accessibili al programmatore.
18
3. P-Code e P-Machine
Figura 3.1: Struttura a livelli del linguaggio Pascal
3.1
I registri della P-Machine
Prima di poter analizzare in dettaglio quali siano le istruzioni della P-Machine, è
necessario comprendere il significato dei suoi registri.
3.1.1
PC - Il program counter
Per programma si intende nient’altro che una lista di istruzioni in linguaggio macchina che si susseguono. Il program counter è pertanto responsabile di indicare in ogni
momento l’istruzione in esecuzione all’interno del programma. Esso, come vedremo
in seguito, viene modificato dalle istruzioni di salto e dalle istruzioni di chiamata e
ritorno da procedura.
3.1.2
SP - Stack Pointer
In ogni istante, il programma necessita dei dati su cui lavorare. Un programma
che non elabora alcun dato, infatti, ha tutta l’aria di essere poco utile, se non per
sprecare cicli di CPU. Come si è visto in precedenza, e come sarà approfondito in
seguito, l’esecuzione del programma si basa su uno stack, e il registro SP punta
costantemente alla sua cima.
3.2. Istruzioni aritmetico/logiche
3.1.3
19
MP - Mark Stack Pointer
Il registro MP tiene traccia dell’inizio del frame della procedura correntemente in
esecuzione. Il suo funzionamento sarà chiaro in seguito, analizzando le istruzioni di
caricamento dalla memoria e di chiamata.
3.2
Istruzioni aritmetico/logiche
Le istruzioni aritmetiche messe a disposizione dal P-Code sono le classiche presenti
in qualunque architettura di calcolo. Pertanto, verrà esposto il funzionamento di
una sola di esse, essendo identico a quello di tutte le altre.
Ogni operazione aritmetica prende i propri parametri dallo stack, pertanto all’occorrenza di una di esse ci troviamo di fronte ad un listato simile al seguente:
...
ADD
...
Eseguendo la ADD, la macchina astratta si occupa di estrarre gli ultimi due elementi
presenti sulla cima dello stack, addizionarli, e porre il risultato di nuovo in cima allo
stack. Ogni operazione aritmetica presenta due diverse versioni, una per gli interi
ed una per i reali. Nel caso di ADD i due opcode sono rispettivamente ADI e ADR.
Ovviamente le operazioni che non hanno senso sui numeri reali, quali il modulo,
hanno solo la versione intera, ad esempio MOD. L’esecuzione da parte dell’ambiente
runtime rispecchia molto da vicino il seguente pseudo-codice:
20
3. P-Code e P-Machine
while ( t r u e )
{
instruction inst = fetch ( ) ;
switch ( i n s t )
{
[...]
case ADI :
SP −= 4 ;
op2 = m memory−>r e a d i n t (SP ) ;
SP −= 4 ;
op1= m memory−>r e a d i n t (SP ) ;
m memory−>w r i t e i n t (SP , op1+op2 ) ;
SP += 4 ;
break ;
[...]
}
}
Di fatto, per ogni istruzione da eseguire, viene svolta una sequenza di operazioni
fetch-decode-execute simili a quelle eseguite da una qualunque CPU. Pertanto, per
osservare nel dettaglio come vengono eseguite le altre istruzioni, si rimanda al codice
sorgente, e precisamente al modulo ExecutionUnit.cpp
3.3
Istruzioni di LOAD/STORE
Questa categoria di istruzioni è necessaria al fine di poter caricare i dati nell’area di
lavoro del programma. Essendo la macchina del P-Code una macchina a stack e non
a registri, potrebbe sembrare strano avere delle istuzioni che eseguono, ad esempio,
dei caricamenti “da memoria a memoria”. Tuttavia, se ricordiamo il funzionamento
di una qualsiasi istruzione aritmetica, notiamo che ciò è necessario. Osserviamo il
seguente frammento di programma:
var k : integer ;
k := 5 ;
[...]
procedure sum k ( a , b : integer )
begin
sum k := a + b + k ;
end sum k ;
Tralasciando l’utilità di un simile programma, una volta tradotto in P-Code, il
risultato è il seguente:
LDA
1, 4
; indirizzo di k
3.3. Istruzioni di LOAD/STORE
LDC
5
; valore da caricare
LDA
0, 0
; indirizzo dove salvare il valore di ritorno
LDA
0, 4
; indirizzo di a
0, 5
; indirizzo di b
21
STO
[...]
LOD
LDA
LOD
ADD
; eseguo la somma
LDA
1, 4
; indirizzo di k
LOD
; valore di k
ADD
; eseguo la somma
STO
; salvo il tutto nella locazione del valore di ritorno
Si può notare che, ad esempio nel caso del caricamento del valore di a, si procede
nel seguente modo:
• LDA 0, 4: Load Address, scope corrente, variabile all’offset 4. Viene caricato
l’indirizzo della variabile a.
• LOD: Load Indirect, viene dereferenziato l’indirizzo presente in cima allo stack,
in modo da sostituirlo con il valore presente a tale indirizzo.
• Si ripete il procedimento analogo per b
• Ora sullo stack sono presenti i valori di a e b, si può procedere alla somma.
Cosı̀ facendo, ogni istruzione trova i suoi operandi subito in cima allo stack. Ma
vediamo in dettaglio il funzionamento delle istruzioni disponibili.
3.3.1
LDA - Load Address
Pascal, a differenza di altri linguaggi, ad esempio C, ammette uno scoping “non
piatto”. In altre parole, sono ammesse dichiarazioni di funzioni innestate. Un
programma simile è perfettamente legale:
22
3. P-Code e P-Machine
program n e s t e d f u n c t i o n s ;
var a : integer ;
procedure f ( ) ;
var b : integer ;
procedure g ( ) ;
var c : integer ;
begin
(∗ corpo d i g ∗ )
end g ;
begin
(∗ corpo d i f ∗ )
end f ;
(∗ corpo d e l programma p r i n c i p a l e ∗ )
end n e s t e d f u n c t i o n s .
Tuttavia, questo richiede un certo supporto a runtime, ovvero il mantenimento della
catena statica. La catena statica altro non è che una rappresentazione del modo
in cui i blocchi sono innestati “a livello di testo”. L’utilità della catena statica si
manifesta nel momento in cui è necessario accedere a variabili non locali. Vediamo
prima però come avviene l’accesso alle variabili locali. Innanzitutto, è necessario
rendersi conto che fino a quando una funzione non viene eseguita, lo spazio per i
suoi dati non viene allocato. Nel momento in cui la procedura viene chiamata, il
chiamante si occupa di creare il frame che contiene:
• Valore di ritorno
• Legame statico
• Legame dinamico
• Indirizzo di ritorno
• Parametri attuali
I primi quattro dati sono tutti indirizzi, ed essendo la nostra una macchina a 32 bit,
occupano esattamente 4 word. I parametri infine, occuperanno lo spazio relativo
al loro tipo di dato. Ad esempio, nel caso di due parametri di tipo integer, si
avrà un’occupazione di due ulteriori word. L’inizio dello spazio utile per le variabili
locali, ricordando il significato del registro MP, si troverà per cui all’indirizzo MP + 6.
Supponiamo ora, in riferimento al programma precedente, che dal corpo di g si voglia
accedere alla variabile c. Non avendo parametri, rispetto all’esempio precedente,
vengono risparmiate 2 word, per cui c si troverà all’indirizzo MP + 4. Quando ci
si trova nella necessità di dover caricare c, pertanto dovremmo in qualche modo
3.3. Istruzioni di LOAD/STORE
23
eseguire qualcosa di simile ad un LDA (MP+4), al fine di ottenere l’indirizzo assoluto
di c. Nel caso in cui invece g voglia accedere a b, il meccanismo dovrebbe essere
lo stesso, ad eccezione che l’offset andrebbe riferito al frame di f . Ricordiamo che
affinché g possa essere chiamata deve essere stata chiamata anche f , precisamente,
il programma principale chiama f ed f chiama g. In altre parole, i frames delle
funzioni “testualmtente” più esterne, devono essere presenti sullo stack. Il legame
statico serve proprio a poter risalire al frame, in questo caso, di f . Essendo che f è
al livello subito sopra di quello di g, la catena statica va risalita di 1. Per caricare
b pertanto sarà sufficiente eseguire LDA nel seguente modo: LDA 1, 4 (4 è l’offset
rispetto al frame di f ).
3.3.2
LDI - Load Immediate
L’istruzione Load Immediate è banale, e durante la sua esecuzione la macchina
astratta non fa altro che prendere il suo operando e porlo in cima allo stack. Un
valore immediato è una costante priva di nome inserita direttamente nel codice, ad
esempio nell’espressione x := 5;.
3.3.3
LDC - Load Constant
Il caricamento di una costante è un’istruzione estremamente semplice. Infatti, si
limita a prendere l’indirizzo della costante specificata dalla tabella delle costanti e a
caricarla sullo stack, in modo molto analogo ad LDA.
3.3.4
LOD - Load Indirect
Anche l’istruzione di dereferenziazione ha un funzionamento molto semplice. All’esecuzione, la macchina astratta non fa altro che sostituire l’indirizzo di memoria in
cima allo stack (l’argomento di LOD) con ciò che è effettivamente contenuto a quel
dato indirizzo.
3.3.5
STO - Store
La Store è l’esatto opposto della LOD. Sullo stack si aspetta sulla cima un valore
e subito dopo un indirizzo. All’esecuzione della Store, tale valore verrà posto in
memoria all’indirizzo presente come secondo elemento dello stack.
24
3. P-Code e P-Machine
Figura 3.2: Collocazione di un array in memoria
3.4
Istruzioni di accesso con indice
Il P-Code mette a disposizione un’istruzione per l’accesso indicizzato agli array, tale
istruzione è la IXA (Compute Indexed Address). Il suo funzionamento è molto
semplice e potente. Il seguente frammento di codice esegue l’accesso ad un array:
program a r r a y a c c e s s ;
var a : array 5 of integer ;
begin
a [ 3 ] = 12345;
end a r r a y a c c e s s ;
Un array a è un’area contigua di memoria che contiene un certo numero di dati, del
tipo specificato, T . Alla luce di ciò è molto semplice intuire che per eseguire l’accesso
ad un array è sufficiente conoscere il suo indirizzo di memoria, l’indice dell’elemento
voluto, e la dimensione del singolo elemento. Definiamo pertanto:
• base(a), che ci restituisce l’indirizzo a cui inizia l’array
• sizeof (T ), che ci restituisce la dimensione del tipo di dati di cui è formato
l’array
L’n-esimo elemento si troverà pertanto all’indirizzo:
base(a) + n ∗ sizeof (T )
L’istruzione IXA si aspetta pertanto:
• di trovare base(a) sullo stack
• di avere n e sizeof (T ) come operandi
3.5. Istruzioni di salto e di confronto
25
Una volta che IXA è stata eseguita sotto queste condizioni, essa lascia sullo stack
l’indirizzo dell’elemento desiderato, e con una semplice LOD possiamo dereferenziare tale indirizzo ed ottenere sullo stack l’elemento dell’array indicato ad IXA. In
riferimento al frammento di programma precedente, la sua traduzione in P-Code è:
LDA
0, 4
; Carica l’indirizzo di a
IXA
3, 1
; n = 3, sizeof(T) = 1, indirizzo di a[3]
LDI
12345
; Carica il valore 12345
STO
3.5
; Memorizzalo in a[3]
Istruzioni di salto e di confronto
Ogni programma deve essere in grado di eseguire dei salti: in caso contrario non
potremmo eseguire blocchi di codice in modo condizionato oppure in ciclo. Il P-Code
definisce due sole istruzioni di salto, il salto incondizionato e il salto su condizione
falsa.
3.5.1
UJP - Unconditional Jump
L’istruzione UJP prende come operando un indirizzo di memoria. L’esecuzione
consiste solamente nel caricare il Program Counter con il valore dell’operando.
3.5.2
FJP - Jump On False
L’esecuzione dell’istruzione FJP è leggermente più complessa. Infatti, presuppone
che subito prima venga eseguita un’istruzione di confronto. Vediamo un frammento
di codice Pascal e relativa traduzione in P-Code.
x := 1 ;
y := 2 ;
i f x < y then
[...]
else
[...]
end ;
26
3. P-Code e P-Machine
start:
LDA
<indirizzo di x>
LDC
1
STO
LDA
<indirizzo di 2>
LDC
2
STO
LDA
<indirizzo di x>
LOD
LDA
<indirizzo di y>
LOD
LSS
FJP
<indirizzo else>
<codice ramo then>
UJP
exit_if
; x := 1
; y := 2
;
;
;
;
;
carica l’indirizzo di x
carica il suo valore
carica l’indirizzo di y
carica il suo valore
è minore?
else:
<codice ramo else>
exit_if:
LSS è un’istruzione di confronto, ed il suo funzionamento è simile a quello di
una qualsiasi istruzione aritmetica. La differenza sta nel fatto che le istruzioni di
confronto lasciano sullo stack un valore booleano invece che, ad esempio, un intero.
Nel caso di LSS, viene lasciato sullo stack il valore “true” nel caso il primo operando
sia minore del secondo. Il funzionamento di FJP a questo punto è chiaro: nel caso
sullo stack, dopo aver eseguito LSS, sia presente il valore “false”, si ha l’esecuzione
del salto.
3.6
Istruzioni relative alle chiamate
L’ultima categoria di istruzioni che prendiamo in esame è la classe delle istruzioni
di chiamata e di ingresso nelle procedure.
3.6.1
Istruzioni relative al chiamante
Il chiamante, per poter chiamare una procedura deve:
• Allocare spazio per: l’indirizzo di ritorno, il legame statico, il legame dinamico
e il valore di ritorno (stack frame, istruzione MST)
• Allocare i parametri
• Eseguire la chiamata (istruzione CUP)
3.6. Istruzioni relative alle chiamate
27
[...]
procedure f a t t ( n : integer ) : integer ;
begin
i f n < 2 then
f a t t := 1 ;
else
f a t t := n∗ f a t t ( n − 1 );
end ;
end f a t t ;
[...]
k :=5;
n := f a t t ( k ) ;
[...]
Una tipica sequenza di chiamata per fatt è la seguente:
LDA
<indirizzo di k>
LDC
5
; k := 5
STO
MST
0
; preparo lo stack frame
LDA
<indirizzo di k>
; carico il parametro attuale k
<indirizzo di fatt>
; chiamo fatt
LOD
CUP
MST - Mark Stack
L’operando dell’istruzione MST serve a specificare il dislivello di annidamento tra
gli ambienti di chiamante e chiamato, e serve per il legame statico. Specificare 1
equivale a dire che si sta chiamando una funzione allo stesso livello di scoping, 2 si
sta chiamando al livello padre e cosı̀ via. Si specifica 0 nel caso si voglia chiamare
una procedura locale.
CUP - Call User Procedure
Osservando il precedente esempio di codice, l’esecuzione di CUP è di semplice comprensione. Infatti, dopo aver creato il frame per la funzione da eseguire tramite
MST, e dopo aver caricato i parametri sullo stack, quello che rimane da fare è salvare nello spazio creato da MST l’indirizzo dell’istruzione da eseguire subito dopo il
ritorno dal chiamato, e ricaricare il registro PC con l’indirizzo del punto di ingresso
della funzione da chiamare. Quello appena descritto è proprio il compito di CUP.
28
3. P-Code e P-Machine
Figura 3.3: Stack frame
3.6.2
Istruzioni relative al chiamato
Il chiamato non deve fare nient’altro che allocare spazio per le proprie variabili, utilizzando l’istruzione ENT. ENT si limita ad incrementare il valore dello stack pointer
di quanto specificato tramite l’operando. Terminata l’esecuzione, servendosi di RET
il chiamato provvede alla distruzione dello stack frame e a ritornare il controllo al
chiamante. RET è il duale di CUP, infatti, tramite RET il Program Counter viene
caricato con l’indirizzo di ritorno memorizzato nella zona creata dall’istuzione MST.
4
Architettura del compilatore
Pianificando lo sviluppo del software presentato si è posto il problema di decidere
se utilizzare un compilatore già pronto e modificarlo o se scrivere un compilatore ed
una virtual machine da zero. Principalmente per due motivi si è scelto di scrivere
l’intero sistema da zero. Innanzitutto i compilatori disponibili o erano troppo complessi, per cui il tempo necessario a capirli sarebbe stato pari se non superiore alla
scrittura di uno nuovo, oppure erano troppo “cablati” per semplicità implementativa e la loro modifica si sarebbe rivelata quasi una riscrittura. Inoltre, i compilatori
valutati “complessi” erano fatti per compilare in codice nativo. Questo avrebbe
creato svariati problemi implementativi. Il compilatore che è stato scritto si basa
volutamente su un’architettura estremamente semplice e priva di alcune componenti
presenti in qualunque compilatore professionale. Ciò ha permesso un rapido sviluppo. Inoltre, tale struttura permette una veloce comprensione da parte di chi voglia
studiarne il funzionamento interno. Nonostante il linguaggio Pascal ben si presti
ad essere compilato in una singola passata, questo compilatore ne effettua diverse,
ognuna con un ben preciso scopo.
• Analisi lessicale
• Analisi sintattica
• Analisi semantica
• Generazione del codice eseguibile
Brevemente, la fase di analisi lessicale è responsabile di tradurre il testo del programma in entità di più alto livello, ovvero i token. Di seguito, l’analisi sintattica
ha il compito di verificare che le stringhe di token che si ricevono in ingresso siano
grammaticalmente corrette. Un token rappresenta un’unità atomica del linguaggio
che si vuole compilare, ad esempio una parola riservata (begin,end,if,while) oppure
30
4. Architettura del compilatore
un operatore matematico, un identificatore e cosı̀ via. La traduzione da testo a token
è svolta più precisamente dallo scanner o lexer. I token da esso generati rappresentano l’input del parser. È quest’ultimo che si occupa di verificare la correttezza
grammaticale del flusso di token in ingresso. Pur essendo entità distinte, scanner e
parser lavorano in stretta accoppiata, in quanto il primo è continuamente chiamato dal secondo per ottenere nuovi oggetti sintattici. Questa fase genera l’albero di
sintassi astratta (Abstract Syntax Tree).
Successivamente si ha la fase di analisi semantica. Essa ha il delicato compito
di comprendere se il programma che il compilatore riceve in ingresso ha un senso.
Ad esempio, è nella fase di analisi semantica che il compilatore genera un errore
se si è tentato ad esempio di assegnare un intero ad un record. Infine, vi è la fase
di generazione del codice, che si occupa di tradurre l’albero di sintassi astratta in
P-Code.
L’albero di sintassi astratta è una struttura dati fondamentale per un compilatore,
ma non è l’unica. Senza una tabella dei simboli, il compilatore non riuscirebbe a
fare molto. In essa infatti, sono memorizzate tutte le informazioni relative ai tipi,
alle variabili, alle funzioni ed alle costanti. Ad esempio è la tabella dei simboli che
custodisce l’informazione che una variabile è di tipo intero oppure che una funzione
prende n parametri.
4.1
Introduzione al concetto di linguaggio formale
Prima di passare ad una descrizione più approfondita dell’effettivo funzionamento
di qualunque compilatore è necessario comprendere delle caratteristiche strutturali proprie di ogni linguaggio. Vi sono svariate tipologie di linguaggi, ognuna con
diverso potere espressivo. Al fine di questa semplice trattazione ci basta prendere
in considerazione i linguaggi regolari ed i linguaggi liberi dal contesto. Per ulteriori
approfondimenti si veda ad esempio [2].
Per poter parlare di linguaggi formali si rende necessaria l’introduzione di alcune
definizioni. La prima di esse è quella di simbolo, il quale è un’entità primitiva di un
linguaggio, che non è precisamente definita ed il cui significato è dato per scontato,
come, ad esempio, nel caso di un punto in geometria.
4.1.1
Linguaggi regolari
I simboli da soli non servono a molto, pertanto, per arrivare a parlare di linguaggio
formale è necessario introdurre alcuni altri concetti. L’alfabeto Σ di un linguaggio è
4.1. Introduzione al concetto di linguaggio formale
31
l’insieme dei simboli validi nell’ambito del linguaggio stesso, esattamente come nel
caso dell’italiano che è costruito sull’alfabeto latino. Una stringa è una concatenazione di simboli presi dall’alfabeto. Di nuovo, l’analogia con la lingua parlata è con
la parola. Il linguaggio L per cui è un insieme di stringhe di simboli appartenenti
all’alfabeto Σ. Un linguaggio regolare è un linguaggio riconosciuto da un automa a
stati finiti. Questo “dispositivo” è fatto in modo da leggere un simbolo e cambiare
stato di conseguenza. Ad esempio, l’insieme di tutti i numeri divisibili per quattro
e rappresentati in base due, è un linguaggio regolare. Intuitivamente, ogni numero
binario divisibile per quattro deve avere le due cifre meno significative uguali a zero.
Dobbiamo far pertanto in modo che, dopo aver letto due zeri, l’automa si trovi in
uno stato detto “di accettazione”, ovvero che ci indica che è stato letto un numero
con le caratteristiche richieste. Il seguente automa riconosce questo linguaggio, e lo
stato di accettazione è q2 :
q0
q1
q2
0
q1
q2
q2
1
q0
q0
q0
Supponiamo ora di voler riconoscere tutte e sole le stringhe di lungezza 5 che siano
palindrome, quali ad esempio la parola “radar”. Nel momento in cui l’automa si trova
a dover capire se la quarta lettera è corretta o meno, ha la necessità di ricordare quale
era il secondo simbolo letto, ovvero ha bisogno di memoria. La parola “radar” infatti
non è una stringa facente parte di un linguaggio regolare. È però appartenente alla
classe dei linguaggi liberi dal contesto, che sono riconosciuti dagli automi a pila.
4.1.2
Linguaggi context-free
I linguaggi context-free sono generati da una grammatica. Una grammatica non
è altro che un insieme di regole, dette produzioni, che indicano come generare un
linguaggio. Supponiamo di voler descrivere formalmente una semplice frase: essa
sarà composta da un soggetto e da un predicato.
f rase := soggetto predicato
(4.1)
La produzione dell’esempio è composta dai simboli denominati frase, soggetto, predicato. Accostiamo ora alla precedente le seguenti due produzioni, ove leggiamo |
32
4. Architettura del compilatore
come “oppure”:
soggetto := “Alice′′ |“Bob′′
(4.2)
predicato := “legge′′ |“scrive′′
(4.3)
Applicando le produzioni otteniamo cosı̀ quattro possibili frasi e precisamente “Alice
legge”, “Alice scrive”, “Bob legge”, “Bob scrive”. Queste ultime quattro frasi sono
definite come il linguaggio generato dalla grammatica. Osservando le produzioni date, possiamo notare che la (4.1) ha, molto informalmente, la proprietà di “portarci”
ad altre produzioni, mentre la (4.2) e la (4.3) ci informano che soggetto e predicato
sono dei simboli che possono assumere solo ben precisi valori. La (4.1) definisce
frase come un simbolo non terminale, mentre soggetto e predicato sono dei simboli terminali. Una grammatica, volendola definire formalmente, è una quadrupla
G = hV, S, T, P i, in cui:
• V è l’insieme dei simboli non terminali, altrimenti detti variabili
• S è il simbolo iniziale
• T è l’insieme dei simboli terminali
• P è un insieme di produzioni
Consideriamo ora il seguente insieme di produzioni:
B := 0
(4.4)
B := 1
(4.5)
B := (B and B)
(4.6)
B := (B or B)
(4.7)
B := (not B)
(4.8)
Esso è poco più complesso del precedente, ma vi appare qualcosa di nuovo, ovvero il
simbolo B anche a destra di alcune produzioni. Abbiamo cosı̀ introdotto una sorta
di ricorsione, ovvero l’elemento che da l’espressività in più ai linguaggi liberi dal
contesto rispetto ai linguaggi regolari. In questa grammatica si ha V = {B}, S =
B, T = {0, 1, and, or, not, (, )}. Essa genera tutte le possibili espressioni logiche.
4.2. Analisi Lessicale e Sintattica
4.2
33
Analisi Lessicale e Sintattica
Dopo questa breve e non rigorosa descrizione dei linguaggi formali, possiamo entrare
nei dettagli dei processi di analisi lessicale e sintattica, svolti dai moduli scanner.cpp
e parser.cpp.
4.2.1
Lo scanner
Il compito dello scanner è quello di trasformare in token il testo in input. Ogni
linguaggio di programmazione infatti è caratterizzato da sequenze di simboli che
hanno un ben preciso significato.
i f t e m p e r a t u r e < 2 0 . 5 then
power on heater ( ) ;
else
power off heater ( ) ;
end ;
Nel precedente esempio possiamo osservare un costrutto if-then-else: In esso compaiono le parole if, then, else ed end. Queste parole, che sono parole riservate,
fungono in un certo modo da “delimitatori” per le varie sezioni del costrutto. Inoltre, nel codice appaiono le espressioni temperature < 20.5, power on heater(); e
power off heater();. Nel testo tutti questi elementi sono semplici sequenze di caratteri, non comprensibili al parser. Lo scanner pertanto ha il compito di trasformarle
in “unità atomiche”. Lo spezzone di codice precedente è trasformato in qualcosa di
simile:
_if _identifier _lt _floatnumber _else
_identifier _lparen _rparen _semicolon
_else
_identifier _lparen _rparen _semicolon
_end _semicolon
A questo punto, la parola riservata if non è più una sequenza di caratteri, ma un
“segnaposto” per essa. Anche temperature, non è più la stringa che compare nel
testo, ma un token avente una proprietà “nome” uguale alla stringa “temperature”.
34
4. Architettura del compilatore
enum t o k e n s { i f , then ,
c l a s s Token
{
public :
Token ( t o k e n s type ) ; /∗
Token ( s t r i n g name ) ; /∗
Token ( i nt v a l u e ) ;
/∗
string
i nt
tokens
else ,
identifier ,
[ . . . ] };
C o s t r u t t o r e g e n e r i c o ∗/
C o s t r u t t o r e per un i d e n t i f i e r ∗/
C o s t r u t t o r e per un number ∗/
get name ( void ) ;
g e t v a l u e ( void ) ;
type ;
};
È abbastanza intuitivo vedere che tutte queste entità sono stringhe appartenenti ad
un linguaggio regolare. Lo scanner non è nient’altro che un automa a stati finiti
abbastanza complesso, ed il token è un “indicatore” dello stato di accettazione in
cui l’automa si è fermato. Ad esempio, per riconoscere un numero floating point si
può utilizzare l’automa di seguito rappresentato:
q0
q1
q2
q3
q4
q5
q6
q7
4.2.2
+
q1
q6
q1
q6
[0-9]
q2
q2
q2
q4
q4
q7
q7
q7
.
E
q3
q5
q5
Il parser
Precedentemente è stato accennato il fatto che il parser per portare a termine il proprio compito utilizza un algoritmo di parsing detto a discesa ricorsiva. L’obiettivo
del parser è quello di generare una struttura dati detta albero di sintassi astratta.
Supponiamo di voler eseguire il parsing del linguaggio generato dalla grammatica
data dalle (4.4), (4.5) e (4.8). Supponiamo inoltre di avere a disposizione uno scanner che quando chiamiamo la sua get token() ci restituisca i simboli f alse, true e
not. Lo pseudocodice di un possibile parser potrebbe essere il seguente:
4.2. Analisi Lessicale e Sintattica
35
Not
Not
True
Figura 4.1: Albero di sintassi astratta
ASTNode ∗ b o o l e a n e x p r e s s i o n ( void )
{
to ken t = s c a n n e r . g e t t o k e n ( ) ;
switch ( t )
{
case f a l s e :
return new F a l s e ( ) ;
case
true :
return new True ( ) ;
case
not :
ASTNode ∗ t h i s r o o t = new Not ( ) ;
thisroot . add child ( boolean expression ( ) ) ;
return t h i s r o o t ;
default :
p a r s i n g e r r o r ( ” Unexpected to ken ” ) ;
return NULL ;
}
}
i nt main ( void )
{
ASTNode∗ t r e e = b o o l e a n e x p r e s s i o n ( ) ;
}
Ovviamente, Not, True e False sono sottoclassi di ASTNode. Un simile parser,
chiamato su un’espressione che potrebbe essere not not 1, genererebbe un albero
simile a quello della Figura 4.1. L’idea sembra buona. Cerchiamo ora di introdurre
36
4. Architettura del compilatore
anche le due produzioni che avevamo precedentemente escluso, ovvero la (4.6) e la
(4.7), e modifichiamo il parser in accordo alla nuova grammatica.
ASTNode ∗ b o o l e a n e x p r e s s i o n ( void )
{
ASTNode ∗ l e f t s u b e x p r = b o o l e a n e x p r e s s i o n ( ) ;
to ken t = s c a n n e r . g e t t o k e n ( ) ;
switch ( t )
{
case f a l s e :
[...]
and :
ASTNode ∗ t h i s r o o t = new And ( ) ;
thisroot . add child ( left subexpr ) ;
thisroot . add child ( boolean expression ( ) ;
return t h i s r o o t ;
case o r :
[...]
case
default :
p a r s i n g e r r o r ( ” Unexpected to ken ” ) ;
return NULL ;
}
}
i nt main ( void )
{
ASTNode∗ t r e e = b o o l e a n e x p r e s s i o n ( ) ;
}
La prima istruzione di boolean expression è una chiamata a se stessa: questo causa
un loop infinito. Cosa è successo? E soprattutto, come risolviamo questo problema?
La grammatica data presenta delle produzioni in cui il simbolo B appare come primo
simbolo nella parte destra di alcune regole. Questa particolarità è chiamata ricorsione sinistra, e causa la non-terminazione del parser. Fortunatamente è possibile
rimuoverla in modo semplice, addirittura algoritmico, operando alcune modifiche
sulla grammatica. In [2] si dimostra che per ogni grammatica data è possibile ottenerne una equivalente senza ricorsione sinistra. La nostra nuova grammatica, una
volta eliminata la ricorsione sinistra, diventa:
booleanV alue =′′ 0′′ |′′ 1′′
(4.9)
booleanF act = booleanV alue|′′ not′′ booleanExpr
(4.10)
booleanExpr = booleanF act(′′ and′′ |′′ or ′′ )booleanExpr
(4.11)
4.2. Analisi Lessicale e Sintattica
Il nuovo parser potrebbe essere il seguente:
ASTNode∗ b o o l e a n f a c t o r ( void )
{
to ken t = s c a n n e r . g e t t o k e n ( ) ;
switch ( t )
{
case f a l s e :
return new F a l s e ( ) ;
case
true :
return new True ( ) ;
case
not :
ASTNode ∗ t h i s r o o t = new Not ( ) ;
thisroot . add child ( boolean expression ( ) ) ;
return t h i s r o o t ;
default :
p a r s i n g e r r o r ( ” Unexpected to ken ” ) ;
return NULL;
}
}
37
38
4. Architettura del compilatore
ASTNode∗ b o o l e a n e x p r e s s i o n ( void )
{
ASTNode∗ l e f t s u b e x p r = b o o l e a n f a c t o r ( ) ;
to ken t = s c a n n e r . g e t t o k e n ( ) ;
switch ( t )
{
case and :
ASTNode∗ t h i s r o o t = new And ( ) ;
thisroot . add child ( l e f t ) ;
thisroot . add child ( boolean expression ( ) ) ;
return t h i s r o o t ;
case
or :
ASTNode∗ t h i s r o o t = new Or ( ) ;
thisroot . add child ( l e f t ) ;
thisroot . add child ( boolean expression ( ) ) ;
return t h i s r o o t ;
default :
p a r s i n g e r r o r ( ” Unexpected to ken ” ) ;
return NULL ;
}
}
i nt main ( void )
{
ASTNode∗ t r e e = b o o l e a n e x p r e s s i o n ( ) ;
}
Si può facilmente intuire che questo secondo parser termina, restituendo l’albero
di sintassi astratta cercato. Ovviamente non è stata tenuta in considerazione la
precedenza delle operazioni, ma la si può codificare con uno sforzo minimo.
La tecnica a discesa ricorsiva è semplice ed efficace [11], [8], e ben si presta per
l’implementazione di prototipi di compilatori. Tuttavia, è poco flessibile, perché il
linguaggio riconosciuto da un parser cosı̀ scritto è “cablato” all’interno del codice
sorgente del parser stesso. I compilatori “di produzione” utilizzano algoritmi di
parsing guidati da tabelle. L’idea di fondo di tali algoritmi è che oltre al programma
da analizzare, anche le produzioni siano un input del parser [7], [1].
4.3
Analisi semantica
I controlli statici che questo compilatore implementa sono soltanto quelli strettamente necessari. La difficoltà implementativa di un’analisi statica “seria” non è affatto
elevata, infatti anch’essa è una sorta di visita ad alberi, ma è decisamente lunga.
4.4. Determinazione del tipo delle espressioni
39
Per questo si è deciso di ridurla al minimo. Ulteriori informazioni si possono trovare
in [7].
program s t a t i c c h e c k s ;
var x : integer ;
y : real ;
a : array 10 of r e a l ;
begin
x := y ;
y := x ;
a := x ;
a [ y ] := x ;
end .
L’analizzatore statico prevede tutti i casi mostrati nell’esempio di codice precedente.
Nel primo assegnamento si ha che, essendo x intero ed y reale, per poter eseguire l’operazione deve essere fatto un troncamento ad x. Viceversa, in y := x; il membro di
destra deve essere promosso a real perché l’assegnamento sia eseguito correttamente.
L’analisi semantica in queste circostanze informa il generatore di codice di emettere,
in corrispondenza del membro destro dell’assegnamento, l’opportuna istruzione di
conversione.
Il terzo comando richiede che la variabile x, che è di un tipo predefinito, venga assegnata ad una variabile di un tipo definito dall’utente: questo è un errore, ed in un
caso simile il compilatore termina senza produrre un eseguibile. Nel quarto caso si
tenta invece di utilizzare un numero reale come indice. Anche in questa situazione
viene segnalato un errore. Un indice reale infatti avrebbe ben poco senso.
4.4
Determinazione del tipo delle espressioni
Nel caso più complesso in cui si abbia qualcosa come
a[y] := 3*x+y;
si rende necessario capire che tipo di dato genera l’espressione a destra dell’assegnamento. Il compito non è difficile, infatti è sufficiente osservare la parte di albero di
sintassi astratta che rappresenta l’espressione. Tale sottoalbero è rappresentato in
Figura 4.2.
Una visita all’albero assegna ai nodi delle operazioni un attributo di tipo. Nel
caso delle operazioni matematiche quali addizione e prodotto, il tipo del risultato è il
40
4. Architettura del compilatore
ADD
MUL
3
type=integer
y
type=real
x
type=integer
Figura 4.2: Albero di sintassi astratta completo di informazione di tipo
tipo dell’operando “più ampio” coinvolto. Nell’esempio pertanto la moltiplicazione
ha come tipo di risultato un intero, mentre la somma un reale. Il tipo complessivo
dell’espressione per cui è real.
4.5
La tabella dei simboli
Nessuna delle fasi del compilatore può funzionare senza un luogo in cui poter memorizzare quanto scoperto durante l’analisi del programma dato in input. La struttura
più importante, oltre all’albero di sintassi astratta, è la tabella dei simboli.
Questa struttura dati è organizzata in modo gerarchico: i vari livelli rispecchiano
come sono innestate le funzioni all’interno del programma. Ogni livello punta ad
una serie di entry che rappresentano variabili, funzioni, costanti e tipi e contengono
tutti i dati che riguardano queste particolari entità. Ad esempio, una entry per una
funzione contiene il suo indirizzo, il numero ed il tipo dei parametri. A sua volta
ogni oggetto TabEntry (questo è il nome della classe all’interno del codice), punta
ad un oggetto Type, che rappresenta il tipo di dato dell’oggetto descritto dalla entry.
Anche Type è una struttura gerarchica, e questo è necessario a rappresentare i tipi
definiti dall’utente come record ed array.
La struttura dati mette a disposizione diverse operazioni:
• Enter
4.6. Generazione del codice
41
Figura 4.3: Tabella dei simboli
• Leave
• New
• Lookup
In breve, Enter e Leave servono a muoversi tra i vari livelli si scoping. New serve
per aggiungere una TabEntry allo scope corrente ed infine Lookup permette, dato
un nome, di risalire alla relativa TabEntry. Una corretta implementazione di questa
struttura dati e delle sue operazioni non è banale. Per ogni approfondimento si
rimanda a [7].
4.6
Generazione del codice
Nel capitolo introduttivo si è accennato molto informalmente al fatto che la generazione del codice è poco di più di una visita in post-ordine dell’albero di sintassi
astratta. I precedenti esempi di parser assumono che ogni entità di un programma di fatto un nodo dell’albero - sia rappresentata da un oggetto di un qualche sottotipo
di ASTNode. L’idea di fondo su cui si basa il generatore di codice di questo compilatore è quella di dotare la classe ASTNode di un metodo denominato generate(),
con il preciso compito di emettere il codice per una determinata sezione di programma. Ogni sottoclasse di ASTNode ridefinirà tale metodo in modo opportuno. Se
consideriamo il parser per le espressioni logiche visto nel paragrafo 4.2.2, i sottotipi
di ASTNode che ci sono necessari sono And, Or, Not, True e False. Ricordando che
stiamo compilando il codice per una macchina a stack, la generate() della classe
And potrebbe assomigliare a qualcosa del genere:
42
4. Architettura del compilatore
And
Or
True
True
False
Figura 4.4: Albero per l’espressione T rue or F alse and T rue
And : : g e n e r a t e ( )
{
ASTNode ∗ l e f t = c h i l d s . f i r s t ( ) ;
ASTNode ∗ r i g h t = c h i l d s . seco nd ( ) ;
l e f t −>g e n e r a t e ( ) ;
r i g h t −>g e n e r a t e ( ) ;
e m i t o p c o d e (AND) ;
}
Vale l’analogo per le classi Or e Not. Per quanto riguarda la classe True, che
rappresenta una costante, si potrebbe pensare a qualcosa di simile alla seguente
funzione:
True : : g e n e r a t e ( )
{
e m i t o p c o d e ( LDI true ) ;
}
Supponendo di avere l’espressione T rue or F alse and T rue, il parser genererebbe
un albero simile a quello in Figura 4.4 Per generare il codice è sufficiente chiamare generate() sulla radice. Quello che succede è che a cascata viene chiamata la
generate() di Or, ed in seguito quella di True. Quest’ultima emette LDI true. Al
ritorno, Or chiama il suo figlio destro, che emette LDI false. A questo punto si
ritorna di nuovo ad Or, che emette OR. Si ritorna cosı̀ ad And, che chiamando il suo
figlio destro emette LDI true. And infine emette AND. Quello che si è ottenuto è
l’equivalente in P-Code dell’espressione di partenza, ovvero:
4.7. Conclusioni
43
LDI true
LDI false
OR
LDI true
AND
Un’ottima trattazione sulla generazione del P-Code si può trovare in [7].
4.7
Conclusioni
Il campo dei compilatori è sconfinato: in questo capitolo si è voluto dare l’idea
generale di quello che è il funzionamento del compilatore scritto, che non è comunque
dissimile da quello di un compilatore “vero”, almeno per le prime fasi. Infatti, questo
compilatore innanzitutto non è ottimizzante: una fase di ottimizzazione è facilmente
integrabile, ma oltre a non rientrare tra gli obiettivi del lavoro, avrebbe richiesto
troppo tempo per essere scritta. Inoltre, questo compilatore produce codice per una
macchina a stack: questa è una semplificazione notevole. Intuitivamente, quello che
succede è che non viene svolta la fase di traduzione da codice intermedio a codice
macchina. Questa fase si dovrebbe occupare di gestire una notevole quantità di
dettagli relativi al processore per cui si vuole generare il codice, alcuni dei quali non
sono semplici, ad esempio l’allocazione dei registri.
Una trattazione di base sui compilatori si può trovare in [11], mentre una trattazione più completa la si può trovare in [7]. La bibbia in fatto di compilatori, testo
che richiede un certo impegno, è [1].
44
4. Architettura del compilatore
5
Tecniche di analisi volte alla
parallelizzazione automatica
5.1
Analisi delle dipendenze
Il semplice susseguirsi delle istruzioni in un programma comporta che si instaurino
tra loro alcuni tipi di dipendenze. Non rispettare queste dipendenze ha come risultato un cambiamento di significato del programma. È perció chiaro che, se vogliamo
eseguire in modo parallelo un programma, è necessario che tali vincoli vengano rispettati pienamente. Le problematiche presentate di seguito sono ben conosciute ai
progettisti di CPU nell’ambito delle pipeline e dell’esecuzione fuori ordine, tecniche
volte alla massimizzazione delle prestazioni raggiungibili da un determinato chip
[10]. L’analisi delle dipendenze per cui è una tecnica di analisi del codice che è in
grado di produrre dei vincoli tra le istruzioni di un programma [4], [5]. Essa è necessaria a determinare se è possibile ri-ordinare o, più in generale, eseguire in un ordine
arbitrario, anche contemporaneamente, una sequenza di comandi senza cambiare il
significato del programma, o senza fargli assumere comportamenti imprevisti.
5.1.1
Dipendenze sui dati
In tutto possiamo osservare i seguenti tipi di dipendenze sui dati: [6]
• Dipendenza Read After Write, o Flow-dipendenza
• Dipendenza Write After Read, o Antidipendenza
• Dipendenza Write After Write, o Output-dipendenza
• Dipendenza Read After Read, o Input-dipendenza
46
5. Tecniche di analisi volte alla parallelizzazione automatica
Dipendenza di flusso
Consideriamo il seguente frammento di codice:
x := 1 2 3 4 ;
y := x + 1 ;
Nella prima riga di codice assegnamo alla variabile x il valore 1234, mentre nella
seconda riga alla variabile y viene assegnato il valore di x incrementato di uno.
Supponiamo ora che il grado di parallelismo che abbiamo a disposizione sia al livello
della singola istruzione; in altre parole supponiamo che le due righe di codice possano
venire eseguite assieme: si osserva immediatamente che per eseguire la seconda
istruzione è necessario conoscere il valore di x, calcolato dalla prima. Si deduce
perció che le due istruzioni devono essere eseguite una di seguito all’altra, cosı̀ come
sono scritte, onde evitare di leggere un valore non aggiornato di x. La dipendenza
che nasce è definita Read After Write, in breve RAW, oppure Flow-dipendenza,
ricordando che il dato x deve fluire dalla prima istruzione alla seconda. Siano per
cui C1 e C2 comandi: essi sono flusso-dipendenti se e solo se C1 modifica una risorsa
che C2 legge e C1 precede C2 in esecuzione, e si indica nel seguente modo:
C1 δf C2
Antidipendenza
L’antidipendenza è l’esatto inverso della dipendenza di flusso. Siano per cui C1 e C2
comandi: essi sono antidipendenti se e solo se C2 modifica una risorsa che C1 legge
e C1 precede C2 in esecuzione, e si indica nel seguente modo:
C1 δa C2
Un semplice frammento di codice che presenta tale tipo di dipendenza è il seguente,
esatto speculare del precedente:
y := x + 1 ;
x := 1 2 3 4 ;
Il significato dell’antidipendenza è il seguente: la prima istruzione necessita di leggere
x, ma se permettiamo che il loro ordine venga invertito, otteniamo una non corretta
lettura di x, da cui il nome Write After Read.
5.1. Analisi delle dipendenze
47
Output-dipendenza
Questo terzo tipo di dipendenza è generato da due scritture consecutive dello stesso
elemento. Se infatti prendiamo in considerazione le istruzioni:
x := 1 2 3 ;
x := 4 5 6 ;
otteniamo che, se non eseguite nell’esatto ordine in cui sono scritte, all’uscita della
sezione di codice che le contiene otteniamo un valore errato per x. La dipendenza
per cui si genera nel momento il cui due comandi C1 e C2 modificano la stessa risorsa
e C1 viene eseguito prima di C2 . L’output-dipendenza è definita anche dipendenza
Write After Write, ed è indicata nel seguente modo:
C1 δo C2
Input-dipendenza
L’input-dipendenza, pur non essendo propriamente una dipendenza, si verifica nel
caso due istruzioni leggano lo stesso dato:
y := x + 1 2 3 ;
z := x + 4 5 6 ;
Tale tipo di dipendenza, se cosı̀ si può chiamare, non impone un particolare ordine
di esecuzione trattandosi di sole letture, ed è indicata nel seguente modo:
C1 δi C2
Altre considerazioni sulle dipendenze
Dei tipi di dipendenza finora presentati, soltanto la dipendenza di flusso è una dipendenza non eliminabile. Le altre dipendenze nascono dal fatto che le variabili
coinvolte sono considerate come locazioni di memoria, e non come un puro valore. In svariati casi infatti sono eliminabili rinominando le variabili coinvolte. Le
dipendenze sui dati stabiliscono un ordine parziale sui comandi che utilizzano i dati
oggetto delle dipendenze.
48
5. Tecniche di analisi volte alla parallelizzazione automatica
5.1.2
Dipendenze sui cicli
La parallelizzazione dei cicli di un programma è un’ottima idea ai fini della computazione parallela. Il problema nel caso dei cicli si pone nel momento in cui un’iterazione dipende dalla successiva. Vediamo innanzitutto un ciclo che non crea particolari
problemi ai fini della parallelizzazione:
begin
for i := 0 to n do
a[ i ] = b[ i ] + c [ i ];
end ;
end .
In un caso simile è evidente che anche se tutti i cicli dovessero essere eseguiti parallelamente il risultato del programma non cambierebbe, anzi, si avrebbe uno speed-up
teoricamente pari ad n.
begin
for i := 0 to n do
a [ i ] = b [ i −1] + c [ i ] ;
end ;
end .
Una piccola modifica come quella effettuata nel codice precedente già vanifica molti
sforzi volti a parallelizzare un ciclo.
5.1.3
Dipendenze sul controllo
Supponiamo di avere il seguente frammento di codice:
begin
b := 2 ;
i f a = 0 then
b = 3;
end ;
write ( b ) ;
end .
L’esecuzione del comando b := 3 (chiamiamolo C2 ) è condizionata alla verità della
condizione del comando if (chiamiamolo C1 ). In altre parole C2 è control-dipendente
da C1 o, in simboli:
C1 δc C2
5.2. Analisi dei dati utilizzati dal codice
5.2
49
Analisi dei dati utilizzati dal codice
Alla luce di quanto detto finora sulle dipendenze sui dati, possiamo rivedere il tutto
da una prospettiva più macroscopica, ovvero quella delle funzioni. Un’idea molto
semplice per parallelizzare un programma è infatti cercare di capire, analizzandolo,
quali siano i dati effettivamente utilizzati dalle varie sezioni di codice, le funzioni, in
esso presenti. Esaminiamo il seguente frammento:
program prova1 ;
var a : array 10 of integer ;
var b : array 10 of integer ;
begin
quicksort(a );
quicksort (b ) ;
end .
Tralasciando alcuni dettagli quali l’inizializzazione dei vettori e ammettendo che
la funzione quicksort prenda il suo parametro per riferimento, e che utilizzi per i
suoi scopi solo il parametro stesso, possiamo senz’altro eseguire le due chiamate in
parallelo, senza preoccuparci che vi siano interferenze non volute, ed essendo sicuri
di ottenere il risultato corretto. Cerchiamo ora di ragionare in modo più formale sul
problema.
5.2.1
Condizioni di Bernstein
Supponiamo di analizzare il seguente frammento di codice, composto da due semplici
procedure:
program prova2 ;
var x : integer ;
var y : integer ;
procedure f ;
begin
x := 5 ;
end ;
procedure g ;
begin
y := 6 ;
end ;
begin
f ();
g ();
end .
50
5. Tecniche di analisi volte alla parallelizzazione automatica
Definiamo Rf l’insieme delle variabili che vengono lette dalla funzione f , e,
analogamente, Wf l’insieme delle variabili che vengono scritte dalla stessa funzione
f . Relativamente al programma in questione, otteniamo che per f si ha Rf = ∅ e
Wf = {x}, mentre, per quanto riguarda g, si ha Rg = ∅ e Wg = {y}. Osserviamo che
se le due funzioni non scrivono gli stessi dati, possono essere eseguite in parallelo.
Diciamo per cui che, date due funzioni f e g, la loro esecuzione parallela f kg è
equivalente alla loro esecuzione sequenziale f, g se l’intersezione dei loro insiemi di
scrittura è vuota. In simboli:
f kg ≡ f, g ⇒ Wf ∩ Wg = ∅
(5.1)
Se non teniamo in considerazione questa condizione, modificando g nel seguente
modo sorge un problema:
procedure g ;
begin
x := 6 ;
end ;
Se eseguiamo in parallelo f e g incorriamo in una race condition, in quanto il valore
di x sarà dipendente dal momento in cui le due funzioni vengono eseguite. La (5.1)
esprime nient’altro che una dipendenza Write After Write tra f e g, ovvero che
f δo g.
È tuttavia abbastanza banale accorgersi del fatto che la (5.1) non è una condizione sufficiente. Per evidenziare ciò basta modificare una delle due funzioni,
supponiamo f , nel seguente modo:
procedure f ;
begin
x := 5 + y ;
end ;
da cui si ottiene che Rf = {y} e Wf = {x}, mentre per g la situazione rimane
Rg = ∅ e Wg = {y}. Ci si trova cosı̀ in una situazione di antidipendenza e si ottiene:
f kg ≡ f, g ⇒ Rf ∩ Wg = ∅
(5.2)
Agendo analogamente su g si può arrivare ad una dipendenza di flusso, da cui:
5.2. Analisi dei dati utilizzati dal codice
51
f kg ≡ f, g ⇒ Wf ∩ Rg = ∅
(5.3)
Le tre condizioni appena trattate si riassumono in:


Wf ∩ Wg = ∅
f kg ≡ f, g ⇔ Rf ∩ Wg = ∅


Wf ∩ Rg = ∅
(5.4)
e sono chiamate condizioni di Bernstein.
52
5. Tecniche di analisi volte alla parallelizzazione automatica
6
Descrizione del lavoro svolto
6.1
Idea generale
Il capitolo precedente mostra alcune tecniche utili a cercare il parallelismo in un programma. In particolare, permettono di capire se vi sono parti di codice che possono
essere eseguite parallelamente, senza cambiare il significato del programma stesso.
Queste tecniche ben si adattano ai multiprocessori a memoria condivisa, anzi, danno buoni risultati, mentre creano notevoli overhead di comunicazione su macchine a
memoria distribuita.
L’approccio che abbiamo deciso di seguire per strutturare il meccanismo distribuito
è essenzialmente diverso perché si vuole evitare di dover trasferire troppi dati, al
fine di non abbattere le prestazioni invece che migliorarle. Quanto presentato sono
alcune tecniche alla base di uno strumento che, dato un programma sequenziale,
supporti la multiprogrammazione rilevando le sezioni di codice indipendenti all’interno del codice dato e ponendo queste in diverse unità di calcolo, assicurandosi
che la comunicazione tra le unità sia ridotta al minimo. L’approccio non è quello
di “parallelizzare” il codice: esso rimane puramente sequenziale. Il vantaggio lo
si ottiene nel momento in cui vengono lanciati più programmi compilati nel modo
proposto. L’effetto complessivo è quello che l’insieme dei processi in esecuzione trae
beneficio da tutte le risorse di calcolo disponibili. Si può osservare un’idea simile in
MOSIX: in tal caso la granularità è a livello di processo, mentre nel caso del sistema
presentato la granularità è a livello di funzione. Si pensi ad esempio al programma
di Figura 6.1:
È facile osservare che è possibile rendere indipendenti le due procedure dichiarate. Per un simile frammento di codice, vengono generati 3 eseguibili, uno per il
“main”, uno per do_something_on_a ed uno per do_something_on_b. Il risultato
è che le due chiamate nel “main” diventano remote, e la loro esecuzione avviene su
54
6. Descrizione del lavoro svolto
program example ;
var a : array 20 of integer ;
var b : array 20 of integer ;
procedure d o s o m e t h i n g o n a ( ) ;
var i ;
begin
i = 0;
while i < 20 then
begin
a[ i ] = i ;
end ;
end ;
procedure d o s o m e t h i n g o n b ( ) ;
var i ;
begin
i = 0;
while i < 20 then
begin
b[ i ] = i ;
end ;
end ;
begin
do something on a ( ) ;
do something on b ( ) ;
end .
Figura 6.1: Esempio di programma che contiene funzioni separabili
6.2. Modifiche al compilatore
55
macchine virtuali presenti su unità di elaborazione potenzialmente diverse da quella
del chiamante. L’idea generale è pertanto:
• avere un compilatore che implementi delle tecniche che identificano sezioni di
codice che non condividono dati e le compili su eseguibili differenti
• avere un ambiente run-time, ovvero una virtual machine, che esegua il codice
compilato in questo modo, mettendo a disposizione dei meccanismi di chiamata di procedura remota.
Il problema principale che si pone è che ogni funzione, per poter essere eseguita,
ha bisogno di avere sullo stack l’ambiente corretto su cui operare. Ogni macchina
virtuale pertanto, durante l’inizializzazione deve eseguire del codice opportuno per
ricreare i frame con la stessa struttura che avrebbero nel caso “non distribuito”, ma
senza allocare le variabili inutili, e poi portarsi in una condizione di “wait” in cui attende le chiamate delle macchine virtuali remote. Affinché si possa ottenere qualcosa
di simile è necessario introdurre delle semplici analisi all’interno del compilatore, che
di seguito sono presentate.
6.2
Modifiche al compilatore
Per decidere se sia possibile o meno “separare” delle sezioni di codice è innanzitutto
necessario capire quali dati esse utilizzano. Questo non è un compito semplice, e, per
mantenere bassa la complessità del prototipo da sviluppare, sono state introdotte
alcune limitazioni:
• Non è ammesso il passaggio per riferimento
• Nel caso di array o record, questi sono considerati come “atomici”: se due
funzioni accedono ad esempio a membri diversi di un record, esse non vengono
separate.
Nel caso del passaggio per riferimento o dei record rimane decidibile staticamente quali dati vengano effettivamente utilizzati; cosı̀ non è invece, a meno di casi
particolari, nel caso di array e puntatori.
6.2.1
Le relazioni uses e usedby
Per decidere quali funzioni siano separabili, vengono costruite due relazioni, denominate rispettivamente uses (U) ed usedby (V). Entrambe vengono calcolate a partire
56
6. Descrizione del lavoro svolto
program prova ;
var x , y , z : integer ;
procedure f ( ) ;
begin
x := 3 ;
end f ;
procedure g ( ) ;
begin
x := 4 ; y := 5 ;
end g ;
procedure h ( ) ;
begin
y := 6 ;
end h ;
procedure k ( ) ;
begin
z := 7 ;
end k ;
begin
f (); g (); h(); k();
end prova .
Figura 6.2: Programma con cluster non banali
da relazioni precedentemente generate. Tali relazioni sono declares (D), reads (R),
writes (W), nonlocal reads (N R) e nonlocal writes (N W). Le relazioni ovviamente
coinvolgono le funzioni e le variabili che esse utilizzano. Nel listato precedente ad
esempio si ha che do_something_on_b declares(i), reads(i) e writes(b). Vediamone
i dettagli.
D viene calcolata al momento del parsing. Ad ogni dichiarazione di variabile, la
variabile viene aggiunta a D.
R viene calcolata nello stesso momento, e fanno parte di essa tutti gli identificatori utilizzati come r-valori, ovvero gli identificatori che si trovano a destra di
un’espressione oppure ad indice di un array.
Assieme ad R viene calcolata anche W, e di essa fanno parte tutti gli identificatori
utilizzati come l-valori, ovvero gli identificatori che che compaiono a sinistra di un
assegnamento.
N R altro non è che R \ D ed analogamente N W è data da W \ D. Ad esempio,
in riferimento al programma di Figura 6.2, vengono calcolate le seguenti relazioni:
6.2. Modifiche al compilatore
57
• Per lo scope globale: Dprova = x, y, z, Rprova = ∅, Wprova = ∅
• Per f : Df = ∅, Rf = ∅, Wf = {x}
• Per g: Dg = ∅, Rg = ∅, Wg = {x, y}
• Per h: Dh = ∅, Rh = ∅, Wh = {y}
• Per k: Dk = ∅, Rk = ∅, Wk = {z}
Per capire quali sezioni di codice sia possibile separare serve sapere se esistono
funzioni che utilizzano le stesse variabili, pertanto viene calcolata U = N R ∪ N W.
Affinché due funzioni f1 ed f2 siano separabili, si deve avere che Uf1 ∩ Uf2 = ∅. Nel
caso del programma di Figura 6.2, dalla definizione della relazione U si ottiene che:
• Per lo scope globale: Uprova = ∅
• Per f : Uf = {x}
• Per g: Ug = {x, y}
• Per h: Uh = {y}
• Per k: Uk = {z}
mentre dalla definizione di V si ottiene che:
• Per x: Vx = {f, g}
• Per y: Vy = {g, h}
• Per z: Uz = {k}
V, a differenza delle precedenti relazioni, associa variabili a funzioni anziché
funzioni a variabili. In altre parole, V rappresenta “cosa è utilizzato da chi”. U e V,
tramite l’operazione di join, permettono di calcolare i gruppi di funzioni cercati, che
denominiamo cluster. Un cluster pertanto è un insieme di funzioni che metteremo
in un singolo processo.
Una volta ottenuti i cluster, si provvede ad assegnare ad ogni funzione l’indice del
cluster a cui appartiene. Altrettanto si fa con le variabili. Questa ultima operazione
sulle variabili ha come effetto collaterale il calcolo dei loro offset all’interno del frame
corrispondente, che vengono cosı̀ memorizzati sulla tabella dei simboli.
58
6. Descrizione del lavoro svolto
6.2.2
Operazione di join
La definizione di join è abbastanza intricata, ma l’idea su cui si basa è molto semplice. Data una funzione f , si va a vedere quali variabili utilizza tramite U. Se U
ci informa che f utilizza a e b, allora tramite V si risale a quali funzioni utilizzano
le stesse variabili, e le si fanno appartenere allo stesso cluster di f . Ripetendo il
procedimento per ogni funzione definita in un programma, si ottiene l’insieme di
tutti i cluster.
Siano f1 , . . . , fn tutte le funzioni presenti in un dato programma P , e sia Sfi
con 1 ≤ i ≤ n il cluster che contiene soltanto l’i-esima funzione. Sia inoltre S =
{S1 , . . . , Sn } l’insieme di tutti i cluster. L’operazione di join è cosı̀ definita:
for all f ∈ P do
for all v ∈ Uf do
for all (g 6= f ) ∧ (g ∈ Vv ) do
S ← S \ {Sf , Sg } ∪ (Sf ∪ Sg )
Un prerequisito per l’esecuzione di join è che ogni funzione appartenga ad un cluster che contiene solo essa. Pertanto, eseguendo l’operazione sulle relazioni ottenute dal programma in Figura 6.2 prima di join si avrà l’insieme di cluster S =
{S0 , S1 , S2 , S3 }, ed in particolare S0 = {prova}, S1 = {f }, S2 = {g}, S3 = {h}
e S4 = {k}. Ora, per ogni funzione definita nel programma si va a vedere quali
variabili usa.
• prova: Uprova = ∅, pertanto si passa alla funzione successiva.
• f : Uf = x. Da Vx si deduce che x è utilizzata anche da g, pertanto S1
ed S2 collassano in un singolo cluster. La situazione ora è: S0 = {prova},
S1 = {f, g}, S2 = {h} e S3 = {k}.
• g: Ug = x, y. Da Vy si deduce che y è utilizzata anche da h, e anche in questo
caso si verifica che S1 ed S2 collassano. Si ha che: S0 = {prova}, S1 = {f, g, h}
e S2 = {k}
• h: Non si deduce nulla di nuovo.
• k: Di nuovo, non si deduce altro. Non vi sono ulteriori funzioni definite nel
programma e l’algoritmo termina con S = {{prova}, {f, g, h}, {k}}.
Il codice che implementa la costruzione di U e V, assieme alla funzione join si trova
nel modulo analysis.cpp.
6.2. Modifiche al compilatore
59
main
rank = 0
a
rank = 1
b
rank = 2
statement
sequence
statement
sequence
statement
sequence
call a
call b
Figura 6.3: Albero con le informazioni relative ai cluster
6.2.3
Numerazione delle funzioni
Subito dopo l’esecuzione di join quello che si ottiene è un certo numero di cluster. Il
cluster che contiene il “main” è il numero 0, gli altri sono numerati progressivamente.
Ad ogni nodo dell’albero di parsing che rappresenta una funzione, ovvero ai nodi di
tipo Procedure viene assegnato il numero del cluster a cui appartiene. In riferimento
al programma precedente, si ha una situazione del genere:
Durante la fase di generazione del codice il compilatore mantiene diversi stream:
gli stream non sono niente altro che il bytecode che verrà inserito nei diversi eseguibili. Affinché il compilatore inserisca il bytecode nello stream corretto, la generate()
dei nodi di tipo Procedure è qualcosa di simile al seguente pseudo-codice:
P r o cedur e : : g e n e r a t e ( )
{
f o r e a c h ( c o f type P r o cedur e i n c h i l d s )
c . generate ( ) ;
prev rank = get stream ( ) ;
s e t s t r e a m ( rank ) ;
my body . g e n e r a t e ( ) ;
set stream ( prev rank ) ;
}
60
6. Descrizione del lavoro svolto
Gli stream ottenuti in questo modo, in riferimento al programma in Figura 6.1, sono
cosı̀ formati:
main
ENT 160
MST
CUP do ... a
MST
CUP do ... b
do_something_on_a
ENT 4
LDA 0, 10
LDI 0
STO
[...]
LDA 1, 0
LDA 0, 10
LOD
IXA 4
[...]
do_something_on_b
ENT 4
LDA 0, 10
LDI 0
STO
[...]
LDA 1, 80
LDA 0, 10
LOD
IXA 4
[...]
È immediato osservare che il codice di do_something_on_a e di do_something_on_b
ha un problema: l’istruzione che carica l’indirizzo dell’array a cui si vuole accedere,
LDA 1, 80 nello stream di do_something_on_b, genera un riferimento in memoria
non valido. Nello stream di do_something_on_a è corretta, ma questo è solo frutto
del caso. Questo problema deve essere risolto, e la fase successiva del compilatore
ha proprio questo compito.
6.2.4
Numerazione delle variabili ed allocazione
Questa seconda fase, come già visto, è necessaria per far in modo che al momento
dell’esecuzione il codice presente nei vari stream possa avere il corretto ambiente su
cui girare. L’idea è di emettere del codice aggiuntivo che ricrei sullo stack l’ambiente
che le funzioni del cluster si aspettano. Per questo motivo anche le variabili vengono
numerate, sempre in base al cluster a cui appartengono. Vediamo l’algoritmo.
Sia ofs(v ) l’offset della variabile v e sizeof (v ) la dimensione del tipo di dato di
v. Sia inoltre level(P ) il livello di annidamento della procedura P. Come visto in
precedenza, le variabili vengono allocate sullo stack, subito dopo le informazioni
relative alle catene statiche e dinamiche e al valore ed indirizzo di ritorno. Questo
comporta che ogni variabile abbia un offset rispetto all’inizio del frame. L’ultima
variabile allocata si troverà all’indirizzo i: la successiva dovrà essere posizionata
all’indirizzo i + sizeof (v ). Sia tale posizione nextof s.
1. Inizialmente qualunque variabile appartiene al cluster -1
2. Per ogni procedura P
(a) Per ogni variabile v ∈ UP , il cluster di v diventa quello di P
6.2. Modifiche al compilatore
61
(b) Per ogni variabile v ∈ DP , se è un parametro viene saltata, come pure se
non appartiene allo stesso cluster di P. Altrimenti, se il suo cluster è lo
stesso di P, allora ofs(v ) = nextofs e nextofs = nextofs + sizeof (v ). Alla
fine di questo passo nextof s contiene il valore con cui eseguire ENT per P.
Tutte queste informazioni vengono mantenute nella tabella dei simboli.
(c) Per ogni variabile v ∈ DP , se appartiene allo stesso cluster di P è già
stata considerata per cui va saltata, come pure se è un parametro. Nel
caso in cui appartenga ad un cluster diverso da quello di P, diciamo il
cluster s, allora essa va allocata al livello level(P ) del cluster s.
Si noti come nel passo 2c s possa essere -1. Questo succede nel caso v non sia
utilizzata, e anche in tal caso la variabile va saltata: questo algoritmo ha il sideeffect di escludere dall’allocazione eventuali variabili non utilizzate. Il risultato che
con questo algoritmo si ottiene è il seguente:
main
MST
CUP
MST
CUP
do_..._a
do_..._b
do_something_on_a
ENT
80
MST
ENT
4
LDA
0, 10
LDI
0
STO
[...]
LDA
1, 0
LDA
0, 10
LOD
IXA
4
[...]
do_something_on_b
ENT
80
MST
ENT
4
LDA
0, 10
LDI
0
STO
[...]
LDA
1, 0
LDA
0, 10
LOD
IXA
4
[...]
Vediamone l’esecuzione sul programma in Figura 6.2. Tramite il passo 2a, viene
deciso che x ed y appartengono al cluster 1, mentre z appartiene al cluster 2. Questa
informazione viene mantenuta nell’entry della tabella dei simboli relativa ad ogni
variabile.
Il passo 2b si occupa di calcolare gli offset delle variabili locali delle funzioni,
e di conseguenza la quantità di spazio necessaria ad ognuna (la quantità da utilizzare come argomento di ENT). È indispensabile mantenere questo passo separato
dal precedente. Infatti, se fusi, durante l’allocazione dei dati di una certa funzione alcune variabili potrebbero non essere ancora state numerate, e di conseguenza
l’informazione necessaria al calcolo degli offset non sarebbe sufficiente.
Infine, il passo 2c alloca, analogamente al 2b le variabili non locali. Ad esempio,
è in questo passo che x, y e z vengono allocate ai blocchi 1 e 2.
62
6. Descrizione del lavoro svolto
Grazie a questo procedimento ogni macchina può eseguire il codice correttamente, e avendo ricalcolato opportunamente gli indirizzi anche il problema dei riferimenti è risolto. Manca però da capire come far si che le macchine che hanno in
carico do_something_on_a e do_something_on_b “aspettino” di essere chiamate
dal “main”.
Da notare che il compilatore in una situazione analoga al frammento seguente
non genera codice corretto:
program i n c o r r e c t ;
var x , y : integer ;
procedure f ( ) ;
procedure g ( ) ;
begin
x := 1 ;
y := 2 ;
end ;
begin
end f ;
begin
x := 3 ;
end i n c o r r e c t .
In termini più astratti, data una funzione a livello di scoping n, se essa non
utilizza dati del livello n − 1 ma utilizza dati del livello n − 2, e la funzione a livello
n − 1 non utilizza dati a livello n − 2, l’ambiente che la procedura a livello n trova al
momento dell’esecuzione non è corretto. Questa limitazione è dovuta a problemi di
tempo. Tecnicamente è causata dal modo in cui è tenuta traccia dei valori di ENT
dei vari livelli di scoping. Eliminarla non richiede un grande sforzo.
6.3
Modifiche all’ambiente runtime
L’esecuzione distribuita del codice ottenuto deve essere in qualche modo coordinata,
e per questo si sono rese necessarie delle modifiche alla macchina virtuale.
• Aggiunta dell’istruzione WAIT
• Modifica del comportamento di CUP
• Modifica del comportamento di RET
L’istruzione WAIT serve per portare la macchina virtuale in una sorta di stato di
attesa, dove essa attende che le vengano inviate le informazioni per poter iniziare ad
6.3. Modifiche all’ambiente runtime
63
eseguire la parte di codice ad essa assegnata.
CUP è stata modificata, nel caso che la chiamata da eseguire sia ad una funzione
remota, in modo da poter interagire con una macchina in condizione di wait, mentre
RET ha il compito aggiuntivo di riportare la macchina in wait dopo essere stata
chiamata da remoto.
6.3.1
Istruzione WAIT
Nella tabella precedente, si vede come agli stream di do_something_on_a e di
do_something_on_b siano state aggiunte le istruzioni ENT e MST all’inizio del
codice. Queste istruzioni fanno parte di quello che è stato definito lo stub di inizializzazione. Lo stub non fa altro che quello che ci eravamo proposti, ovvero ricostruire
la parte di stack necessaria. Una volta che lo stub è stato eseguito però la VM deve
fermarsi in attesa di istruzioni. Per questo motivo, subito dopo di esso, viene emessa
l’istruzione WAIT. Il suo pseudocodice è il seguente ed è di immediata comprensione.
while ( PC < p r o g r a m l e n g t h )
{
instruction inst = fetch ( );
switch ( i n s t )
{
[...]
case WAIT:
MPI Recv ( a d d r t o c a l l ) ;
MPI Recv ( p a r a m e t e r s ) ;
mark stack for remote return ( ) ;
put o n sta ck ( parameters ) ;
PC = a d d r t o c a l l ;
break ;
[...]
}
}
WAIT marca lo stack con un magic number nella posizione in cui andrebbe posizionato l’indirizzo di ritorno. Il magic number vero e proprio sono i 2 byte più significativi del return address, mentre il byte meno significativo indica a quale macchina
inviare il valore di ritorno della funzione eseguita, e di conseguenza a quale macchina
far riprendere il controllo. In mancanza di un simile accorgimento, MPI Send non
potrebbe sapere a chi inviare il messaggio di return.
64
6. Descrizione del lavoro svolto
6.3.2
Istruzione CUP
CUP nella versione non distribuita dell’ambiente runtime prevede come operando un
indirizzo a 32 bit, che specifica l’entry point della funzione chiamata. Nella versione
distribuita, sono stati sacrificati gli 8 bit più significativi dell’indirizzo passato a
CUP, al fine di poter inserire al loro posto l’indice del cluster a cui appartiene la
funzione da chiamare. Questo indice si traduce, nella terminologia MPI, nel rank
della VM che sta per essere chiamata.
La versione di CUP non distribuita è qualcosa di simile a quanto segue:
while ( PC < p r o g r a m l e n g t h )
{
instruction inst = fetch () ;
switch ( i n s t )
{
[...]
case CUP:
adjust MP ( ) ;
PC = g e t c u p a r g u m e n t ( ) ;
break ;
[...]
}
}
La versione distribuita invece prevede il caso di chiamata remota:
6.3. Modifiche all’ambiente runtime
65
while ( PC < p r o g r a m l e n g t h )
{
instruction inst = fetch ( );
switch ( i n s t )
{
[...]
case CUP:
adjust MP ( ) ;
m a c h i n e t o c a l l = ( g e t c u p a r g u m e n t ( ) >> 2 4 ) & 0xFF ;
a d d r e s s t o c a l l = g e t c u p a r g u m e n t ( ) & 0xFFFFFF ;
i f ( m a c h i n e t o c a l l != me)
{
MPI Send ( a d d r e s s t o c a l l , m a c h i n e t o c a l l ) ;
MPI Send ( pa r a meter s , m a c h i n e t o c a l l ) ;
MPI Recv ( r e t u r n v a l u e ) ;
put on stack ( return value ) ;
}
else
PC = a d d r t o c a l l ;
break ;
[...]
}
}
6.3.3
Istruzione RET
L’istruzione RET, nella sua versione di base non fa nulla altro che ripristinare MP e
distruggere il frame della funzione appena eseguita. Nel caso debba essere eseguito
un remote return, oltre a fare ciò, RET deve inviare il return value alla macchina
chiamante e riportare la propria macchina virtuale in condizione di WAIT, in modo
che possa rispondere ad una successiva richiesta di esecuzione.
66
6. Descrizione del lavoro svolto
Conclusioni
Il lavoro svolto consiste in una implementazione per il linguaggio Pascal pensata per
sfruttare le caratteristiche di un ambiente distribuito. Lo scopo, in particolare, è stato quello di gettare le basi di uno strumento che potesse in qualche modo supportare
il programmatore evitando di dover scrivere appositamente software per macchine a
memoria distribuita. L’idea di base è stata quella di identificare automaticamente
delle sezioni di codice che non condividono dati, per poi porle su unità di calcolo
differenti. Un meccanismo di call/return remoti fa si che il flusso del programma
si articoli su diverse CPU invece che su una sola. L’approccio adottato non porta
benefici al singolo programma in esecuzione: una call remota infatti è bloccante
dal lato della macchina che la esegue. Questo implica che l’esecuzione del singolo
programma rimanga strettamente seriale. Nella quasi totalità dei casi però, su un
elaboratore si vuole eseguire diversi processi, i quali sono potenzialmente composti
da più sezioni, distribuite su più CPU. L’esecuzione concorrente di un certo numero
di processi cosı̀ compilati trae perciò beneficio in modo trasparente da hardware a
memoria distribuita.
Quanto proposto è stato implementato ed è disponibile all’indirizzo internet
http://sourceforge.net/projects/opc. Il software si pone l’obiettivo di essere un
proof-of-concept piuttosto che un tool interamente funzionante. Durante l’implementazione, per ragioni principalmente dettate dai tempi, si è persa di vista l’architettura generale del compilatore di base, e sono state introdotte alcune inconsistenze,
compromettendo la mantenibilità. Pertanto, ogni ulteriore miglioramento andrebbe sviluppato su una riscrittura del software progettata tenendo conto di quanto
appreso scrivendo questa “versione 0”.
Di seguito sono proposte due possibili estensioni del lavoro proposto.
Scheduler Come già illustrato, per lo strato di comunicazione è stata utilizzata la
libreria MPI. Volendo utilizzare quest’ultima invece che scrivere un sottosistema ad-hoc, si pone il problema che, lanciando i processi, questi vengono messi
in esecuzione sulle macchine elencate nell’hostfile passato al comando mpirun
seguendo l’esatto ordine in cui gli host compaiono in questo file. Ciò ha l’effetto di sovraccaricare gli host elencati per primi e lasciare liberi gli ultimi, e
68
6. Conclusioni
questo ovviamente non è voluto. Pertanto, si rende necessario l’utilizzo di un
sistema che allochi in modo bilanciato i processi.
Thread L’ambiente di esecuzione distribuito che è stato implementato permette
una naturale estensione del linguaggio con il supporto ai thread. Questo potrebbe essere ottenuto introducendo ad esempio una parola riservata thread
che agisce da modificatore per le definizioni di funzioni. Prendiamo come
esempio la seguente definizione:
thread procedure t e s t t h r e a d ( )
begin
[...]
end ;
In questo caso, una chiamata a test thread provoca una chiamata remota, che
però viene eseguita in modo asincrono rispetto al resto del programma. In
altre parole, nello stub di inizializzazione di test thread non verrebbe emessa
l’istruzione WAIT. L’introduzione dei thread potenzialmente richiede un certo
sforzo supplementare: è necessario dotare il linguaggio dei consueti costrutti
di sincronizzazione, ma è anche necessario dotare le macchine virtuali di un
meccanismo di notifica di terminazione dei thread lanciati.
Bibliografia
[1] A.V. Aho, R. Sethi, and J. D. Ullman. Compilers: Principles, tecniques and
tools. Addison-Wesley.
[2] Agostino Dovier and Roberto Giacobazzi.
Fondamenti dell’informatica:
Linguaggi formali, Calcolabilità e Complessità.
[3] Maurizio Gabbrielli e Simone Martini. Linguaggi di programmazione, Principi
e paradigmi. McGraw-Hill.
[4] Mary W. Hall, Brian R. Murphy, and Saman P. Amarasinghe. Interprocedural
parallelization analysis: A case study.
[5] Mary W. Hall, Brian R. Murphy, Saman P. Amarasinghe, Shih-Wei Liao, and
Monica S. Lam. Interprocedural analysis for parallelization.
[6] Steve Hoxey, Faraydon Karim, Bill Hay, and Hank Warren. The PowerPC
Compiler Writer’s Guide. IBM.
[7] Kenneth C. Louden. Compiler Construction - Principles and Pratice. PWS.
[8] Ronald Mak. Writing Compilers and Interpreters - An Applied Approach Using
C++. Wiley.
[9] Peter S. Pacheco. Parallel programming with MPI. Morgan Kaufmann.
[10] Andrew S. Tanenbaum. Architettura dei computer. UTET.
[11] Nicklaus Wirth. Compiler Construction.
Scarica

Implementazione distribuita del linguaggio Pascal