Università degli studi di Roma Tor Vergata Facoltà di Scienze MM FF NN Corso di laurea specialistica in Fisica

Documenti analoghi
Classificazione delle Architetture Parallele

Alcuni strumenti per lo sviluppo di software su architetture MIMD

Scuola di Calcolo Scientifico con MATLAB (SCSM) 2017 Palermo 31 Luglio - 4 Agosto 2017

Sistemi a processori multipli

Modelli di programmazione parallela

Architetture della memoria

Modello a scambio di messaggi

Architettura hardware

Calcolo parallelo. Una sola CPU (o un solo core), per quanto potenti, non sono sufficienti o richiederebbero tempi lunghissimi

CLASSIFICAZIONE DEI SISTEMI OPERATIVI (in ordine cronologico)

Il Modello a scambio di messaggi

Funzioni, Stack e Visibilità delle Variabili in C

Componenti principali. Programma cablato. Architettura di Von Neumann. Programma cablato. Cos e un programma? Componenti e connessioni

Componenti principali

Il processore. Istituzionii di Informatica -- Rossano Gaeta

MPI. MPI e' il risultato di un notevole sforzo di numerosi individui e gruppi in un periodo di 2 anni, tra il 1992 ed il 1994

Componenti e connessioni. Capitolo 3

Non blocking. Contro. La programmazione di uno scambio messaggi con funzioni di comunicazione non blocking e' (leggermente) piu' complicata

Le reti rete La telematica telematica tele matica Aspetti evolutivi delle reti Modello con mainframe terminali Definizione di rete di computer rete

Comunicazione tra Computer. Protocolli. Astrazione di Sottosistema di Comunicazione. Modello di un Sottosistema di Comunicazione

Come aumentare le prestazioni Cenni alle architetture avanzate

La CPU e la Memoria. Sistemi e Tecnologie Informatiche 1. Struttura del computer. Sistemi e Tecnologie Informatiche 2

Calcolatori Elettronici A a.a. 2008/2009

Programma del corso. Introduzione Rappresentazione delle Informazioni Calcolo proposizionale Architettura del calcolatore Reti di calcolatori

Architettura dei Calcolatori. Macchina di von Neumann /2. Macchina di von Neumann /1. Architettura dei Calcolatori

Il calcolatore. È un sistema complesso costituito da un numero elevato di componenti. è strutturato in forma gerarchica

Modelli di interazione tra processi

ARCHITETTURA DI UN ELABORATORE! Ispirata al modello della Macchina di Von Neumann (Princeton, Institute for Advanced Study, anni 40).!

Modelli di interazione tra processi

TEORIA DEI SISTEMI OPERATIVI. Sistemi monoprogrammatie multiprogrammati

Von Neumann Bottleneck

Architettura di von Neumann

Architettura di von Neumann

Introduzione al Calcolo Parallelo Algoritmi e Calcolo Parallelo. Daniele Loiacono

Informatica Generale 07 - Sistemi Operativi:Gestione dei processi

LA GESTIONE DELLA I/O

Architetture di rete. 4. Le applicazioni di rete

Lezione 15 Il Set di Istruzioni (1)

Corso di Informatica

Architettura dei Calcolatori Elettronici

Spazio di indirizzamento virtuale

Il Processore. Informatica di Base -- R.Gaeta 27

Primi passi col linguaggio C

Architettura degli elaboratori - 2 -

Architetture Applicative Altri Esempi

Architettura dei computer

Il Processore: l unità di controllo

Elementi di informatica

Sottosistemi ed Architetture Memorie

Architettura hardware

Modelli di interazione tra processi

Corso di Fondamenti di Informatica Elementi di Architettura

Come aumentare le prestazioni Cenni alle architetture parallele

La memoria principale

Informatica 3. LEZIONE 1: Introduzione. Modulo 1: Introduzione al corso Modulo 2: Introduzione ai linguaggi di programmazione

Introduzione al Many/Multi-core Computing

ISO- OSI e architetture Client-Server

Sistemi Operativi: Concetti Introduttivi

Il problema dello I/O e gli Interrupt. Appunti di Sistemi per la cl. 4 sez. D A cura del prof. Ing. Mario Catalano

Informatica 3. Informatica 3. Lezione 1- Modulo 1. LEZIONE 1: Introduzione. Concetti di linguaggi di programmazione. Introduzione

Programma del corso. Introduzione Rappresentazione delle Informazioni Calcolo proposizionale Architettura del calcolatore Reti di calcolatori

La gerarchia di memorie (2)

Introduzione al Calcolo Parallelo Algoritmi e Calcolo Parallelo. Daniele Loiacono

Richiami sull architettura del processore MIPS a 32 bit

Componenti di un processore

Programmi per calcolo parallelo. Calcolo parallelo. Esempi di calcolo parallelo. Misure di efficienza. Fondamenti di Informatica

Sistemi di Elaborazione delle Informazioni

Introduzione all'architettura dei Calcolatori

L ARCHITETTURA DEI CALCOLATORI. Il processore La memoria centrale La memoria di massa Le periferiche di I/O

Elementi di informatica

Sistemi e Tecnologie per l'automazione LS. HW per elaborazione digitale in automazione: Microcontrollori e DSP

Introduzione. Caratteristiche generali. Sistemi e Tecnologie per l'automazione LS. HW per elaborazione digitale in automazione: Microcontrollori e DSP

static dynamic random access memory

SISD - Single Instruction Single Data. MISD- Multiple Instructions Single Data. SIMD Single Instruction Multiple Data. Architetture di processori

Linguaggi, Traduttori e le Basi della Programmazione

Unità Didattica 1 Linguaggio C. Fondamenti. Struttura di un programma.

Esonero di Informatica I. Ingegneria Medica

Macchine Astratte. Luca Abeni. February 22, 2017

Lezione 15. L elaboratore Elettronico

Corso di Architettura (Prof. Scarano) 09/04/2002

I THREAD O PROCESSI LEGGERI

5. I device driver. Device driver - gestori delle periferiche. Struttura interna del sistema operativo Linux. Tipi di periferiche. Tipi di periferiche

Capitolo 2: Strutture dei sistemi di calcolo

Il Sottosistema di Memoria

Struttura hw del computer

Il Sistema Operativo

Macchina di Riferimento: argomenti

Algoritmo. La programmazione. Algoritmo. Programmare. Procedimento di risoluzione di un problema

Architettura dei Calcolatori elettronici

Sistemi Operativi SISTEMI DI INPUT/OUTPUT. D. Talia - UNICAL. Sistemi Operativi 10.1

ISA Input / Output (I/O) Data register Controller

Che cos e l Informatica. Informatica generale. Caratteristiche fondamentali degli algoritmi. Esempi di algoritmi. Introduzione

Architettura di un calcolatore: Introduzione parte 2

Linguaggi di Programmazione

Informatica ALGORITMI E LINGUAGGI DI PROGRAMMAZIONE. Francesco Tura. F. Tura

Introduzione alle gerarchie di memoria

Sistemi e reti CPU Concetti di base

Introduzione al funzionamento di un calcolatore elettronico

Esame di INFORMATICA Lezione 4

Transcript:

Università degli studi di Roma Tor Vergata Facoltà di Scienze MM FF NN Corso di laurea specialistica in Fisica Relazione sul corso avanzato di Calcolo Parallelo e Grid Computing svolto presso l Università di Catania, promosso dall Istituto Nazionale d Astrofisica (INAF) in collaborazione con il CINECA e l Istituto Nazionale di Fisica Nucleare (INFN) di Catania, dal 28/09/2008 al 04/10/2008. A cura di Alessandro Maiorana sotto la supervisione della prof.ssa Silvia Morante e della dott.ssa Velia Minicozzi

Sommario Introduzione II 1. HPC, calcolo distribuito e applicazioni scientifiche 1.1 Sistemi di calcolo in parallelo 1 1.2 Architetture e modelli di programmazione paralleli 8 1.3 Prospettive future 11 2. Message Passing Interface (MPI) 2.1 Introduzione e sintassi essenziale 13 2.2 Comunicazioni point to point 16 2.3 Comunicazioni collettive 26 2.4 Ottimizzazione dei codici 31 3. La GRID 3.1 GRID computing 37 3.2 Utilizzo GRID in ambito scientifico 41 4. Tecniche di debugging di codici 45 Appendice 1 Installazione della libreria Open-MPI 48 Appendice 2 Operazioni collettive MPI 49 Glossario 50 Bibliografia 58

Introduzione Negli ultimi vent anni le scienze computazionali sono diventate, insieme con le scienze teoriche e sperimentali, il terzo pilastro dell indagine scientifica, permettendo ai ricercatori di costruire e testare i modelli creati per spiegare fenomeni complessi. Il calcolo ad alta perfomance (High Performance Computing, HPC) è diventato uno strumento essenziale nello studio in numerose discipline scientifiche e tecnologiche, ad esempio: Simulazione di sistemi ingegneristici completi; Simulazione di sistemi biologici completi; Astrofisica; Scienza dei materiali; Bio-informatica, proteomica, farmaco-genetica; Nanotecnologia; Modelli funzionali 3D scientificamente accurati del corpo umano; Climatologia e meteorologia; Digital libraries per la scienza e l ingegneria. Queste sono solo una parte delle applicazioni dell HPC. Esso ci permette, inoltre, di creare una rete di interdisciplinarietà fra branche della scienza, altrimenti separate. Le innovazioni tecnologiche e scientifiche permetteranno all HPC di superare i problemi esistenti, in particolare: Aumentare la velocità delle comunicazioni tra le varie CPU; Distribuire geograficamente il lavoro; Incrementare la produttività; Elaborare un sempre maggior afflusso di dati. Poter incrementare la complessità dei problemi da studiare; Per uno scienziato uno dei problemi da risolvere è quello di riuscire ad elaborare ed analizzare in tempi rapidi un sempre maggior numero di dati, ad esempio il genoma unamo. In futuro l HPC permetterà di elaborare un numero di dati sempre maggiore. Più grande sarà l incremento tecnologico maggiore sarà la capacità di risolvere sistemi complessi ed elaborare una grande quantità di dati. Un esempio di tale sviluppo sono i Test Crash Dummy, che da un paio d anni avvengono in maniera virtuale (Fig.1), dimezzando i costi delle case automobilistiche ed aumentando il numero di informazioni estraibili. Infatti, grazie all HPC, non solo siamo in grado di verificare la sicurezza Fig.1. E-crash dummy II

Fig.2. Visualizzazione 3D dellacassa toracica e degli organi. delle automobili e delle sue componenti, ma possiamo analizzare l impatto in 3D, oppure visualizzare quali sono le conseguenze dell apertura dell airbag sulla cassa toracica (Fig.2) e quali organi sono coinvolti. Questo è solo uno degli esempi, in cui l HPC ci permette di integrare ed elaborare una grande quantità di dati e di visualizzarli, attraverso la costruzione di un modello 3D. Dopo quest esempio di applicazione dell HPC, vediamo com è possibile misurare la potenza di un cluster HPC e farne il confronto con altri. Esiste una grandezza che ci permette di misurare il numero di operazioni al secondo che una CPU può effettuare, e prende il nome di flops. Sulla base di questa grandezza ogni anno viene pubblicata una classifica mondiale dei calcolatori più potenti del mondo, che prende il nome di top500. Le posizioni in classifica dipendono da quante operazioni al secondo (floatingpoint/ sec) riesce a compiere un dato sistema, basato su un problema di risoluzione di sistemi lineari. In Fig.3, per esempio, possiamo osservare come lo sviluppo di macchine sempre più potenti ci permetta di raggiungere perfomance sempre maggiori. Le tre curve rappresentano l andamento dei flops in funzione del tempo per il calcolatore che si trova nella prima posizione in classifica nella top500 (quadrati rossi), per il calcolatore che si trova nella posizione 500 (quadrati viola), e la loro somma (quadrati verdi). Si è passati dai primi supercomputer negli anni 70 che raggiungevano i MFlops, ai recenti supercomputer che raggiungono i PFlops. Nel 2008 entrerà in azione negli USA il supercomputer più potente al mondo che prende il nome di Roadrunner; un sistema ibrido che connette 6562 processori dual-core AMD Opteron integrati con 12240 processori IBM Cell (disegnati in origine per piattaforme video game come la Playstation 3 Sony) che fungono da acceleratori. L intero sistema si compone di 122400 cores e 98 TeraByte di RAM interconnessi via Infiniband 4x DDR. Attualmente al primo posto in classifica nella top500 troviamo il supercomputer Blue Gene P, costruito dall IBM. III Fig.3. Trend delle perfomance

Oltre ad un progressivo sviluppo di macchine superpotenti, le case costruttrici di processori stanno sviluppando nuove CPU, che al loro interno contengono più core, aumentando notevolmente le prestazioni del singolo calcolatore. Naturalmente per sfruttare al meglio l HPC bisogna sviluppare degli algoritmi che siano in grado di ottimizzare il calcolo in parallelo. Per questo motivo stanno nascendo nuovi linguaggi di programmazione che supportano il sistema di Global Address Space (GAS), ad esempio UPC, Co-Array Fortran, Titanium, ecc. IV

1. HPC, calcolo distribuito e applicazioni scientifiche 1.1 Sistemi di calcolo in parallelo Sappiamo che i calcolatori convenzionali sono basati sul modello di Von Neumann, dove le istruzioni vengono processate in maniera sequenziale, cioè: I. un istruzione viene caricata dalla memoria (fetch) e decodificata; II. vengono calcolati gli indirizzi degli operandi; III. vengono prelevati gli operandi dalla memoria; IV. viene eseguita l istruzione; V. il risultato viene scritto in memoria (store); Naturalmente l obiettivo primario è quello di aumentare la velocità del processo e può essere fatto in due modi: aumentando la velocità dei componenti elettronici; aumentando il grado di parallelismo architetturale (numero di attività che possono essere compiute contemporaneamente); L aumento della velocità dei componenti elettronici è limitato da due fattori: limite della velocità della luce; problema della dissipazione del calore; Esiste, inoltre, un gap nello sviluppo dell architettura dei calcolatori dovuto alle differenze di perfomance tra processori e memoria, infatti mentre la performance dei processori raddoppia ogni 18 mesi quella della memoria raddoppia circa ogni 6 anni. La legge empirica di Moore (Fig.4), afferma che la complessità Fig.4. Andamento della legge empirica di Moore dei dispositivi (numero di transistor per square inch nei microprocessori) raddoppia ogni 18 mesi. Si stima che la legge di Moore valga ancora almeno per questo decennio. Oltre alla potenza del processore, altri fattori influenzano le prestazioni degli elaboratori: dimensione della memoria; larghezza di banda (bandwidth) fra memoria e processore; larghezza di banda verso il sistema di I/O; dimensione e larghezza di banda della cache; latenza fra processore, memoria e sistema di I/O; Storicamente il concetto di parallelismo parte negli anni 40, ma è solo negli anni 70 che avvengono i primi miglioramenti architetturali, come: Memoria bit-parallel e Aritmetica bit-parallel; 1

Maggior velocità Registri Cache Memory Memoria Primaria Memoria Secondaria Fig.5. Gerarchie di memoria Maggior Capacità Minor costo Processori indipendenti per l attività di I/O; Memoria interleaved; Gerarchie di memoria; Unità funzionali multiple; Unità funzionali segmentate; Pipelining; Anche nel campo dei software emergono dei miglioramenti legati alla multiprogrammazione, al look ahead e scheduling delle istruzioni. Con la nascità dei sistemi vettoriali, degli array processors, delle architetture sistoliche e dei very long instruction word (VLIW) possiamo realmente parlare di parallelismo, in particolare di parallelismo sincrono, cioè dove ogni processore esegue le stesse istruzioni degli altri ma su dati diversi. Solo con la nascita dei sistemi a multiprocessore e dei multicomputer possiamo parlare di parallelismo asincrono, dove ogni processore esegue le proprie istruzioni in modo indipendente dagli altri. Come già detto, oltre ai problemi legati allo sviluppo dei processori, abbiamo dei problemi legati alla memoria. Innanzitutto vediamo quali sono le gerarchie di memoria (Fig.5), che possono essere identificate attraverso due fattori: A. Tempo di accesso alla memoria: è il tempo necessario ad eseguire un operazione di lettura/ scrittura su una locazione di memoria; B. Tempo di ciclo della memoria: indica il tempo che deve trascorrere prima di un nuovo accesso a una stessa locazione di memoria Negli ultimi anni questi tempi sono molto migliorati rendendo possibili sistemi di memoria più veloci ma al tempo stesso è diminuito anche il tempo di clock del processore. La memoria cache è un unità di dimensioni ridotte usata come buffer per i dati che vengono scambiati fra la memoria centrale e il processore. Consente di ridurre il tempo speso dal processore in attesa dei dati dalla memoria principale. L'efficienza della cache dipende dalla località dei riferimenti nei programmi e si divide in: Località temporale: una istruzione o un dato possono essere referenziati più volte; Località spaziale: quando si referenzia una locazione di memoria, è molto probabile che vengano referenziate anche le locazioni adiacenti; Diversi calcolatori dispongono anche di cache per le istruzioni (o instruction buffer) oppure di cache che contengono sia dati che istruzioni, cioè: l'istruzione viene prelevata dalla cache anziché dalla memoria; un'istruzione può essere eseguita ripetutamente senza ulteriori riferimenti 2

alla memoria principale; E' demandato all'abilità del sistema far rientrare le istruzioni che compongono un kernel computazionale pesante (es. un loop) all interno della cache, se queste possono esservi contenute per intero. Lo stesso vale per i dati, ma in questo caso l opera di ottimizzazione coinvolge anche il programmatore. La cache è divisa in slot della stessa dimensione (linee). Ogni linea contiene k locazioni di memoria consecutive (es. 4 word, ognuna di grandezza 4byte). Quando è richiesto un dato dalla memoria, se questo non è già in cache, viene Memoria Cache caricata dalla memoria l intera linea di cache che lo contiene (Fig.6), sovrascrivendo così il contenuto precedente della linea. Quando si modifica un dato, memorizzando un nuovo valore in cache, occorre aggiornare il dato anche in memoria principale, ed esistono due modi per farlo: Cache write-back: i dati scritti nella cache vi rimangono finché la linea di cache non è richiesta per memorizzare altri dati e quando si deve rimpiazzare la cache il dato viene scritto in memoria; comune nei sistemi monoprocessore; Cache write-through: i dati vengono scritti immediatamente nella cache e nella memoria principale. Nei sistemi con più processori occorre gestire la coerenza della cache: per accedere a dati aggiornati e i processori devono essere a conoscenza dell attività delle cache locali. Poiché in ogni istante temporale la cache può contenere solo un sottoinsieme della memoria, occorre sapere quale: si tratta di associare un insieme di locazioni di memoria ad una linea di cache (mapping). In base al mapping la cache può essere organizzata in uno dei seguenti modi: Fig.6. Accesso alla memoria cache Direct mapped: con questo schema, se ad es. la dimensione della cache è di 8 KByte e la memoria centrale ha word di 8 Byte, la prima locazione di memoria (word 1) viene mappata sulla linea 1 (Fig.7), così come la locazione d+1, 2d+1, 3d+1 ecc. (dove d = N linee * N word per linea); quando si hanno riferimenti di memoria che alternativamente puntano alla stessa linea di cache (es. word 1, word 1025, word 1, word 1025, ripetutamen- 3

1 1.9 te), ogni riferimento causa un 2 725 3 51.9 cache miss e si deve rimpiaz- 4-18.5 zare la linea appena inserita. 5 1.7 6-25.3 Si genera parecchio lavoro ag- giuntivo (overhead); questo Linea 1 1.9 725 51.9-18.5 1024 Linea 2 1.7-25.3 1025 fenomeno è detto thrashing. Linea 3 1026 Set associative: la cache Linea 4 1027 Linea 5 fa più copie dello stesso ban- 1028.. 1029 co, in questo modo crea una Linea 256 1030 doppia possibilità di accedere. 2048 Cache ai dati senza correre il rischio 2049 di sovrascriverli(fig.8) su dati 2050 ancora utili. Memoria Full associative: in questo caso la cache è libera di Fig.7. Esempio di cache direct mapping scegliere in quale linea caricare il dato senza restrizioni. Nei calcolatori tradizionali, la CPU 1 1.9 2 way set associative 2 725 è costituita da un insieme di registri 2048 word, linee di 4 word 3 51.9 generali, da alcuni registri speciali 4-18.5 Memoria (es. il program counter) e dall'unità aritmetico logica (ALU) che, una per volta, calcola le operazioni. Un primo passo verso il parallelismo consiste nel suddividere le funzioni dell'alu e progettare unità indipendenti capaci di operare in parallelo (unità funzionali indipendenti). E' compito del compilatore esaminare le istruzioni e stabilire quali operazioni possono essere fatte in parallelo, senza alterare la semantica del programma. Un altro concetto molto importante è il pipelining, che è analogo a quello di catena di montaggio dove in una linea di flusso (pipe) di stazioni di assemblaggio gli elementi vengono assemblati a flusso continuo. I- dealmente tutte le stazioni di assemblaggio devono avere la stessa velocità di elaborazione altrimenti la stazione più lenta diventa il bottleneck dell'intera pipe. Per esempio un task T viene scomposto in un insieme di sottotask {T 1,T 2,...T k } legati da una relazione di dipendenza: il task T j non può partire finché tutti i sottotask precedenti {T i, i<j} non sono terminati (Fig.9). Nel 1966 Michael J. Flynn classifica i sistemi di calcolo a seconda della molteplicità del flusso di istruzioni e del flusso dei dati che possono gestire; in seguito questa classificazione è stata estesa con una sottoclassificazione per considerare anche il tipo di architettura della memoria (Fig.10). Si considerino lo stre- 4 5 6 1024 1025 1026 1027 1028 1029 1030. 2048 2049 2050 1.7-25.3 66 71 33 100 17 Cache Linea 1 Linea 2 Linea 3 Linea 4 Linea 5.. Linea 256 Linea 1 66 71 33 100 Linea 2 17 1.9 1.7 725 51.9-18.5-25.3 Banco 1 Fig.8. Esempio di cache set associative Banco 2

am delle istruzioni (sequenza delle Spazio istruzioni eseguite dal calcolatore) e lo T j i j mo subtask del task i mo S 4 S 3 S 2 S 1 1 T 2 1 2 1 T 3 T 1 T 1 T 1 2 1 2 3 4 5 T 4 T 4 T 4 T 4 T 4 2 4 T 3 T 3 T 3 T 3 3 4 T T T T 2 2 2 2 3 4 T 1 T 1 3 5 5... 5......... 0 1 2 3 4 5 6 7 8 9 10 Fig.9. Sovrapposizione delle operazioni in una pipe a 4 stadi. Fig.10. Architetture di calcolo 5 Tempo(cicli) stream dei dati (sequenza dei dati usati per eseguire uno stream di istruzioni): Single Instruction stream (SI), Single Data stream (SD), Multiple Instruction stream (MI), Multilpe Data stream (MD). Si ottengono così quattro combinazioni possibili: Single Instruction Single Data (SISD): corrisponde alla classica architettura di Von Neumann; sono sistemi scalari monoprocessore dove l'esecuzione delle i- struzioni può essere pipelined e dove ciascuna istruzione aritmetica inizia un'operazione aritmetica; Single Instruction Multiple Data (SIMD): in questo caso si parla di parallelismo sincrono. I sistemi SIMD hanno una sola unità di controllo, ed una singola istruzione opera simultaneamente su più dati. Appartengono a questa classe gli array processor e i sistemi vettoriali; Multiple Instruction Single Data (MISD): è un'architettura parallela in cui diverse unità effettuano diverse elaborazioni sugli stessi dati. Attualmente non esistono macchine MISD. Sono stati sviluppati alcuni progetti di ricerca ma non esistono processori commerciali che ricadono in questa categoria; Multiple Instruction Multiple Data (MIMD): in questo caso si parla di pa-

rallelismo asincrono dove più processori eseguono istruzioni diverse e operano su dati diversi. Versione multiprocessore della classe SIMD. Spazia dai linked main frame computer alle grandi reti di micro-processori. Oltre ad una classificazione di Flynn, possiamo effettuare una classificazione in base al tipo di condivisione della memoria, cioè: Sistemi a memoria condivisa: in questo caso i processori coordinano la loro attività, accedendo ai dati e alle istruzioni in una memoria globale (shared memory) condivisa da tutti i processori (Fig.11). L accesso alla memoria è uniforme: i processori presentano lo stesso tempo di accesso per tutte le parole di memoria, mentre l interconnessione processore-memoria avviene tramite common bus, crossbar switch, o multistage network. Ogni processore può disporre di una cache locale, le periferiche sono condivise.i sistemi a memoria condivisa presentano un numero limitato di processori (da 2 a 32) molto potenti (possono presentare anche un architettura vettoriale). Questi multiprocessori sono chiamati tightly coupled systems per l alto grado di condivisione delle risorse. I sistemi Uniform Memory Access (UMA) e Symmetric Multi Processors (SMP) ne sono un esempio. Fra le architetture citiamo le ETA10, Cray 2, Cray C90, IBM 3090/600 VF, NEC SX-5. Sistemi a memoria distribuita: la memoria è distribuita fisicamente tra i processori (local memory), e tutte le memorie locali sono private e può accedervi solo il processore locale. La comunicazione tra i processori avviene tramite un protocollo di comunicazione a scambio di messaggi (message passing). In genere si tratta di sistemi che presentano un numero elevato di processori (da poche decine ad alcune migliaia), ma di potenza non troppo elevata, chiamati nodi di elaborazione. Il modello più utilizzato P M M P P M N P M M P Fig.12. Sistemi a memoria distribuita 6 P M P P M P P Fig.11. Sistemi a memoria condivisa è il Non Uniform Memory Access (NUMA), dove l insieme delle memorie locali forma uno spazio di indirizzi globale, accessibile da tutti i processori. Il tempo di accesso dal processore alla memoria non è uniforme, infatti, l accesso è più veloce se il processore accede alla propria memoria locale, mentre è più lento quando si accede alla memoria dei processori remoti e si ha un delay dovuto alla rete di interconnessione.

I moderni sistemi paralleli utilizzano memorie distribuite in cui nodi (SMP) sono costituiti da più processori (dual-core, quad-core, ecc.) e una memoria condivisa. Un altro fattore importante per il calcolo in parallelo sono le reti d interconnesione costituito dall insieme dei cavi che definiscono come i diversi processori di un calcolatore parallelo sono connessi tra loro e con le unità di memoria. Naturalmente il tempo richiesto per trasferire i dati dipende dal tipo di interconnessione e questo tempo prende il nome di communication time. La massima distanza che devono percorrere i dati scambiati tra due processori che comunicano incide sulle prestazioni della rete. Le caratteristiche di una rete di interconnessione sono: Bandwidth: identifica la quantità di dati che possono essere inviati per unità di tempo sulla rete, che deve essere massimizzata; Latency: identifica il tempo necessario per instradare un messaggio tra due processori. Si definisce anche come il tempo necessario per trasferire un messaggio di lunghezza nulla, e deve essere minimizzato. Possiamo inoltre definire l interconnesione, come: Interconnessione completa (ideale): ogni nodo può comunicare direttamente con tutti gli altri nodi (in parallelo), per esempio con n nodi si ha un'ampiezza di banda proporzionale a n 2 ed il costo cresce proporzionalmente a n 2 (Fig.13); Interconnessione indiretta (pratica): solo alcuni nodi sono connessi direttamente. Un percorso diretto o indiretto permette di raggiungere tutti i nodi. Il caso pessimo è costituito da un solo canale di comunicazione, condiviso da tutti i nodi (es. un cluster di workstation collegate da una LAN). Occorrono soluzioni intermedie in grado di bilanciare costi e prestazioni (mesh, albero, shuffleexchange, omega, ipercubo, ecc.). La connettività è più ristretta: soluzione appropriata perché molti algoritmi paralleli richiedono topologie di connessione più ristrette (locali), ad esempio la comunicazione tra primi vicini. Fra le interconnessioni indirette esistono varie topologie: Topologia Mesh: in una network a griglia, i nodi sono disposti secondo un reticolo a k dimensioni (k dimensional lattice) di ampiezza w, per un totale di w k nodi: se k=1 allora si parla di array lineare se k=2 si parla di 2D array La comunicazione diretta è consentita solo tra i nodi vicini (Fig.14), mentre i nodi interni comunicano direttamente con altri 2 k nodi. Topologia Toroidale: alcune varianti del modello a mesh presentano con- 7 Fig.13. Interconnessione completa

nessioni di tipo wrap-around fra i nodi ai bordi della mesh (Fig.15). Topologia Ipercubo: una topologia ipercubo (cubeconnected) è formata da n =2 k nodi connessi secondo i vertici di un cubo a k dimensioni. Ogni nodo è connesso direttamente a k altri nodi. Il degree di una topologia ipercubo è logaritmo di n ed anche il diametro è log n. Una network cube connected è una network butterfly le cui colonne sono collassate in nodi singoli. Topologia Tree: i processori sono i nodi terminali (foglie) di un albero. Il degree di una network con topologia ad albero con N nodi è log 2N e il diametro è 2log 2N - 2. Esistono due tipologie di tree: A. Fat Tree: la bandwidth della network au menta all aumentare del livello della rete (Fig.17a). B. Piramide: una network piramidale di ampiezza p è un albero quaternario completo con livelli, dove i nodi di ciascun livello sono collegati in una mesh 2D (Fig.17b). P P P P P P P P P Fig.14. Topologia Mesh 2D Fig.15. Topologia Toroidale Fig.16. Topologia Ipercubo Fig.17a. Topologia Fat Tree Fig.17b. Topologia Piramide 1.2 Architetture e modelli di programmazione parallela Cosa si intende per parallel computing o programmazione in parallelo? Il parallel computing è una tecnica di programmazione che coinvolge l utilizzo di più processori che operano insieme su un singolo problema. Il problema globale è suddiviso in parti, ciascuna delle quali viene eseguita da un diverso processore in parallelo. Inoltre un programma parallelo è composto di tasks (processi) 8

che comunicano tra loro per realizzare un obiettivo computazionale complessivo. L'esecuzione di processi di calcolo non sequenziali richiede: un calcolatore non sequenziale (in grado di eseguire un numero arbitrario di operazioni contemporaneamente); un linguaggio di programmazione che consenta di descrivere formalmente algoritmi non sequenziali. Inoltre, come già visto, un calcolatore parallelo è un sistema costituito da un insieme di processori in grado di comunicare e cooperare per risolvere grandi problemi computazionali in modo veloce. I calcolatori in parallelo nascono anche dall esigenza di evitare il von Neumann bottleneck, dove tutte le informazioni devono passare per il CPU serialmente. Un modello di programmazione è un insieme di astrazioni di programma che fornisce una visione semplificata e trasparente del sistema hardware e software nella sua globalità. I processori di un calcolatore parallelo comunicano tra loro secondo due schemi di comunicazione: & Shared memory: i processori comunicano accedendo a variabili condivise; & Message passing: i processori comunicano scambiandosi messaggi; Questi schemi identificano altrettanti paradigmi di programmazione parallela: $ paradigma a memoria condivisa o ad ambiente globale (Shared memory) dove i processi interagiscono esclusivamente operando su risorse comuni; $ paradigma a memoria locale o ad ambiente locale (Message passing) dove non esistono risorse comuni, i processi gestiscono solo informazioni locali e l'unica modalità di interazione è costituita dallo scambio di messaggi (message passing). Nella programmazione parallela occorre affrontare problematiche che non si presentano con la programmazione sequenziale. Occorre decidere: quali parti di codice costituiscono le sezioni parallele; quando iniziare l esecuzione delle diverse sezioni parallele; quando terminare l esecuzione delle sezioni parallele; quando e come effettuare la comunicazione fra le entità parallele; quando effettuare la sincronizzazione fra le entità parallele. Con il modello di programmazione Shared Memory, si fa affidamento sull indirizzamento globale della memoria, mentre con il modello a memoria distribuita è possibile solo la gestione locale della memoria, e quindi si può gestire solo uno spazio di indirizzamento locale. Ad esempio se volessimo calcolare la somma degli elementi di una matrice M[n, n], e cioè: s = i Se affrontassimo il problema affidandoci all indirizzamento globale della memoria dovremmo allocare la matrice M[n, n] nella memoria comune, e tutti i processori che intervengono nella computazione possono indirizzare tutti gli elementi di M (Fig.18). 9 j m ij

In contrapposizione un indirizzamento locale genera che ogni processore vede solo la sua memoria locale e vi alloca una fetta della matrice M[n, n] (Fig.19). P 0 s 0 P 0 M P 1 P 2 s 1 s 2 P 1 P 2 P 3 S s 0 s 1 s 2 s 3 s 3 S?? P 3 Fig.18. Schema di indirizzamento globale Ulteriormente possiamo schematizzare i paradigmi di programmazione in parallelo, così: Paradigma a memoria condivisa (Open-MP); Paradigma Message Passing (PVM, MPI); Paradigma Data Passing (Shmem, One Side Communication); Paradigma Data Parallel (HPF, HPF-Craft). Inoltre nell usare uno di questo schemi di programmazione possiamo tendere ad un parallelismo implicito, in cui il parallelismo non è visibile al programmatore, cioè è il compilatore il responsabile del parallelismo, ma in cui le perfomance non migliorano molto, oppure possiamo parlare di parallelismo esplicito, visibile al programmatore, in cui si adottano delle chiamate a librerie, e, in cui il miglioramento nelle perfomance è più vasto. Contemporaneamente possiamo adottare un parallelismo sui dati, in cui partizioniamo i dati (data parallelism), e ogni processo esegue lo stesso lavoro su un sottoinsieme dei dati. Le caratteristiche di questa tecnica sono: Il posizionamento dei dati ( data placement ) è critico; Più scalabile del parallelismo funzionale; Programmazione in Message-Passing o High Performance Fortran (HPF); Problema della gestione del contorno; Bilanciamento del carico. Oppure possiamo adottare un parallelismo sul controllo o funzionale, in cui vengono distribuite le funzioni e ogni processo esegue una diversa "funzione. In questo caso è fondamentale identificare le funzioni, e poi i data requirements. Infine tutti i linguaggi di programmazione parallela devono soddisfare dei requisiti fondamentali e devono possedere dei costrutti adatti, che sono: Costrutti per dichiarare entità parallele, cioè moduli di programma che 10 Fig.19. Schema di indirizzamento locale

devono essere eseguiti come processi sequenziali distinti, il che vuol dire che più processi possono svolgere lo stesso modulo di programma, operando su dati differenti; Costrutti per esprimere la concorrenza, cioè strumenti per specificare l'attivazione di un processo (quando deve iniziare l'esecuzione del modulo di programma che corrisponde a quel processo) e/o strumenti per specificare la terminazione di un processo; Costrutti per specificare le interazioni dinamiche fra processi; Costrutti linguistici per specificare la sincronizzazione e la comunicazione fra i processi che devono cooperare; Costrutti linguistici per garantire la mutua esclusione fra processi che competono (per il modello a memoria condivisa). Avendo tutti gli strumenti in mano per poter parallelizzare il nostro algoritmo, dobbiamo tenere a mente quali sono i nostri obiettivi e quali decisioni prendere, considerando che si deve: Assicurare lo speed-up e la scalabilità; Assegnare a ciascun processo una quantità unica di lavoro; Assegnare a ogni processo i dati necessari per il lavoro da svolgere; Minimizzare la replica dei dati e della computazione; Minimizzare la comunicazione tra i processi; Bilanciare il work load. Ed infine dobbiamo tenere ben in mente che per un problema esistono diverse soluzioni parallele, e che la miglior soluzione parallela non sempre deriva dalla miglior soluzione scalare. 1.3 Prospettive future In quest ultimo paragrafo vogliamo introdurre i progetti europei più importanti per la creazione l installazione di centri di calcolo HPC. Un progetto molto importane è DEISA, che è un'infrastruttura per il calcolo HPC distribuito, costituita dai principali centri di supercalcolo europei (CSC- Finlandia, CINECA-Italia, EPCC-Gran Bretagna, ECMWF-The European Centre for Medium-Range Weather Forecasts in cui sono coinvolti 31 stati europei, FZJ-Germania, IDRIS-Francia, RZG-Germania, SARA-Olanda, BSC-Spagna, HLRS-Germania, LRZ-Germania). L'infrastruttura, basata sul Grid Computing è sviluppata e mantenuta all'interno di uno speciale progetto finanziato dalla Comunità Europea. La missione di DEISA è di favorire la scoperta scientifica tramite un ampio spettro di strumenti scientifici e tecnologici, migliorando e rinforzando le capacità europee nell'area dell'hpc. A partire da maggio 2008 è stata lanciata una nuova iniziativa DEISA2. Questa iniziativa consiste nell identificazione, lo sviluppo e l attivazione di un numero molto piccolo di applicazioni di elevata rilevanza in tutte le aree della scienza e della tecnologia. I progetti selezionati devono avere la prerogativa di essere computazionalmente 11

complessi, non attivati presso DEISA, avere delle basi di innovazione potenziale, eccellenza e rilevanza scientifica. Tutti i progetti verranno poi valutati da u- na commissione scientifica formata da due paesi facenti parte al progetto DEI- SA2. Un altro progetto europeo è l HPC-Europa, che ha come obiettivo l'accesso transnazionale a sette infrastrutture di supercalcolo da parte dei ricercatori coinvolti in attività che necessitano di strumenti computazionali di alto livello. HPC-Europa si propone una maggiore integrazione fra le strutture di Supercalcolo a livello europeo e di contribuire alla creazione di un'area della ricerca europea, senza frontiere per la mobilità dei ricercatori, la conoscenza e le tecnologie innovative. Coinvolge BSC, CINECA, EPCC, HLRS, GENCI-Francia, SA- RA e CSC. Il progetto PRACE (Partnership for Advanced Computing in Europe) prevede di supportare l installazione di alcuni sistemi HPC di classe Pflop/s (Tier 0). Tali sistemi verranno poi integrati con i sistemi HPC presenti nelle singole nazioni; questi sistemi, che ci si aspetta essere dell ordine delle centinaia di Tflop/s, costituiscono il cosiddetto Tier 1 e a loro volta saranno integrati con sistemi HPC locali o regionali, meno potenti Tier 2, secondo un Fig.20. Installazione piramidale PRACE modello a piramide che definisce un preciso eco-sistema HPC a supporto della comunità scientifica europea. Il consorzio PRACE è costituito da istituti di ricerca di Austria, Finlandia, Francia, Germania, Grecia, Italia, Paesi Bassi, Norvegia, Polonia, Portogallo, Spagna, Svezia, Svizzera e Regno Unito. 12

2. Message Passing Interface (MPI) 2.1 Introduzione e sintassi essenziale MPI è il primo standard de iure per i linguaggi paralleli a scambio di messaggi, e definisce le specifiche sintattiche e semantiche, ma non l implementazione. E stato messo a punto in una serie di riunioni tenutesi tra novembre 1992 e gennaio 1994. La commissione MPI era costituita da membri provenienti da 40 i- stituti diversi (università, istituti di ricerca, industrie, enti governativi). Gli scopi per il quale è stato creato sono: Rendere portabile il software parallelo: Fornire agli sviluppatori di software un unico standard ben definito per la codifica di software libero; Fornire ai costruttori di architetture un unico insieme di primitive da implementare nel modo più efficiente per ciascuna architettura. Un programma MPI consiste di un programma seriale a cui si aggiungono delle chiamate a delle librerie che convertono il programma seriale in uno parallelo. Le chiamate possono essere principalmente divise in quattro classi: A. Chiamate usate per inizializzare, gestire e terminare le comunicazioni; B. Chiamate usate per comunicare tra coppie di processori (communication point to point); C. Chiamate usate per comunicare tra gruppi di processori (communication collective); D. Chiamate per creare data types. Ricordiamo che i linguaggi esistenti attualmente, cioè C, C++ e Fortran sono intrinsecamente seriali, e non concepiscono nei loro costrutti un programma in parallelo. Quando scriviamo un programma che deve essere implementato in parallelo, dobbiamo pensare in parallelo, tenendo bene a mente quali sono gli strumenti a nostra disposizione, piuttosto che fissare la nostra attenzione sulle librerie. Quando inizia un programma parallelo che contiene al suo interno alcune chiamate alle librerie MPI dobbiamo includere in testa al nostro programma l MPI header file: #include <mpi.h> (Sintassi del C); include mpif.h (Sintassi del Fortran) L header file contiene definizioni, macro e prototipi di funzioni necessarie per la compilazione di un programma MPI. La libreria MPI consiste di circa 125 funzioni, molte delle quali derivano dalla combinazione di un numero ridotto di concetti ortogonali, inoltre, molte di queste funzionalità possono essere ignorate fino a che non servono esplicitamente. Ci sono sei funzioni fondamentali che possono essere utilizzate per scrivere molti programmi paralleli, e sono: 13

MPI_INIT (Inizializza l ambiente MPI); MPI_COMM_SIZE (Determina quanti processi vi sono); MPI_COMM_RANK (Determina il numero rank del processo); MPI_SEND (Invia un messaggio); MPI_FINALIZE (Termina l ambiente MPI). Oltre a queste sei funzioni, ne esistono altre che possono aggiungere al programma: Flessibilità (tipi di dati); Efficienza (comunicazioni non bloccanti); Modularità (gruppi, comunicatori); Convenienza (operazioni/comunicazioni collettive, topologie virtuali). In MPI è possibile dividere il numero totale dei processi in gruppi, chiamati comunicatori. Il comunicatore è una variabile che identifica un gruppo di processi che hanno la facoltà di comunicare l uno con l altro. Il comunicatore che include tutti i processi è chiamato MPI_COMM_WORLD, che è anche il comunicatore automaticamente definito in default (Fig.21). Il programmatore può definire molti comunicatori allo stesso tempo, ed inoltre tutte le subroutine di comunicazione MPI hanno come Fig.21. MPI_COMM_WORLD argomento un comunicatore. La funzione MPI_INIT ha il compito di inizializzare l ambiente MPI, e deve seguire alcune regole: Deve essere chiamata prima di ogni altra funzione MPI; Non deve essere necessariamente la prima istruzione del programma; E la prima funzione che esegue tutti i processi; Esegue il setup del sistema che permette tutte le successive chiamate parallele; La sua sintassi è: MPI_Init(&argc, &argv) (C); MPI_INIT(IERR) INTEGER IERR (Fortran) La funzione MPI_FINALIZE finalizza l ambiente MPI, inoltre: E l ultima istruzione MPI; Viene chiamata da tutti i processi; Libera la memoria allocata da MPI; La sua sintassi è: MPI_Finalize() (C); CALL MPI_FINALIZE(IERR) (Fortran) La funzione MPI_COMM_SIZE determina quanti processi sono associati con 14

un comunicatore, e la sua sintassi è: MPI_Comm_size(MPI_Comm comm, int *size); INTEGER COMM, SIZE, IERR CALL MPI_COMM_SIZE(COMM, SIZE, IERR) Questa chiamata restituisce in output size, cioè il numero di processi associati a quel dato comunicatore. La funzione MPI_COMM_RANK identifica i processi all interno di un comunicatore e consente di conoscere l identificativo (ID) di un processore all interno di un gruppo; inoltre, può essere usata per trovare un rank non-global se i processi sono divisi in più comunicatori MPI. Il rank è un intero che identifica il processo all interno del comunicatore comm. Un processo può avere un diverso rank per ogni comunicatore a cui appartiene. La sintassi è: MPI_Comm_rank(MPI_Comm comm, int *rank); INTEGER COMM, RANK, IERR CALL MPI_COMM_RANK(COMM, RANK, IERR) A questo punto siamo in grado di scrivere il nostro primo programma in parallelo. Esempio: programma che stampa su terminale la parola Hello world, I m a proc X of total Y, e mette in coda queste stampe: #inlcude <stdlib.h> #include <stdio.h> #include "mpi.h" main(int argc, char** argv) { MPI_Init (&argc, &argv); int myrank; int mysize; MPI_Status status; MPI_Comm_rank( MPI_COMM_WORLD, &myrank); MPI_Comm_size( MPI_COMM_WORLD, &mysize); printf("hello world! I'm process %d of %d\n", myrank, mysize); MPI_Finalize(); } Tutte le librerie MPI utilizzano un comando che permette di richiamare le librerie e i path esatti per quelle librerie; così quando dobbiamo compilare il nostro programma dobbiamo usare i comandi: mpicc (C), mpicxx (C++); mpif77 (Fortran77), mpif90 (Fortran90) Quando si installa una libreria MPI, in automatico viene installato un program- 15

ma che permette di lanciare l eseguibile in parallelo, e questo è il suo comando: mpirun np <n. di processori> <nome dell eseguibile>; Questo programma una volta compilato ed eseguito scriverà sul terminale il seguente messaggio, ad esempio su quattro processori: Hello world! I m process 1 of 4 Hello world! I m process 2 of 4 Hello world! I m process 3 of 4 Hello world! I m process 4 of 4 2.2 Comunicazioni point to point Prima di introdurre le funzioni usate da MPI per mettere in comunicazione più processori, analizziamo quali sono le caratteristiche del paradigma Message Passing, introdotto nel capitolo precedente. In questo paradigma ogni attore (processo) che partecipa alla comunicazione dispone di proprie risorse locali esclusive, a differenza del modello memoria condivisa. Ogni processo opera in un proprio ambiente (spazi degli indirizzi logici disgiunti) e la comunicazione avviene attraverso scambi di messaggi, che possono essere istruzioni, dati o segnali di sincronizzazione. Lo schema a scambi di messaggi può essere implementato anche su un sistema a memoria condivisa, anche se il delay della comunicazione causato dallo scambio di messaggi è molto più lungo di quello che si ha quando si accede a variabili condivise in una memoria comune. Nella Fig.22 è schematizzato il paradigma di Message Passing, che è il connubio fra il data transfer e la sincronizzazione, dove abbiamo messo in evidenza la richiesta di cooperazione tra il processo mittente e quello destinatario. Il sistema di comunicazione fra processi deve consentire fondamentalmente due o- perazioni: send(message) e receive(message), che consentano a due processi di comunicare tra loro. Le comunicazioni tra i processi possono essere di due tipi: > Simmetrica: in questo caso i nomi dei processi vengono direttamente Processo 0 Data Posso inviare? Ascolta... Data Processo 1 Ascolta... Si!!! Data Tempo Fig.22. Schematizzazione paradigma Message Passing inseriti nelle operazioni di send e receive, ad esempio: send(p1, message) invia un messaggio al processo P1 16

receive(p0, message) riceve un messaggio dal processo P0 > Asimmetrica: in questo caso il mittente nomina esplicitamente il destinatario, invece il mittente non indica il nome del processo con cui vuole comunicare, ad esempio: send(p, message) invia un messaggio al processo P receive(id, message) riceve un messaggio da un processo qualsiasi Questo tipo di comunicazione è adatta per collegamenti di tipo clientserver, e può avere vari schemi di collegamento: - da molti a uno; - da uno a molti; - da molti a molti. Quindi la sincronizzazione e la comunicazione fra i processi avviene tramite i costrutti send e receive, inoltre la semantica di queste due primitive relativamente alla sincronizzazione può avvenire in due modalità: Modalità bloccante: il processo che è mittente si blocca e attende che l operazione richiesta dalla primitiva send giunga a compimento; il processo ricevente rimane bloccato finché sul canale da cui vuole ricevere non viene inviato il messaggio richiesto. Modalità non bloccante: in questo caso la comunicazione è separata in tre fasi: I fase: Initiate non blocking communication; II fase: Do some work; III fase: Wait for non-blocking communication to complete; l operazione non attende il suo completamento, il processo passa ad eseguire l istruzione successiva; il processo che emette una send non bloccante non attende che il messaggio spedito sia andato a buon fine, ma passa ad eseguire le istruzioni successive. Altre primitive poi permettono di controllare lo stato del messaggio inviato; il processo che emette una receive non bloccante consente di verificare lo stato del canale e restituisce il messaggio oppure un flag che indica che il messaggio non è ancora arrivato. Vediamo adesso come le funzioni di MPI realizzano la comunicazione fra processi. Iniziamo a vedere come avviene la comunicazione fra 2 processi. Concettualmente è molto semplice, per e- sempio il processo sorgente A manda un messaggio al destinatario processo B, e B riceve il messaggio da A. Naturalmente la comunicazione può avvenire all interno di un comunicatore, e la sorgente e il destinatario sono Fig.23. Schema di comunicazione point to point MPI 17

identificati dal loro rank nel comunicatore (Fig.23). Ma cosa sono i messaggi? Un messaggio è un array di elementi composto da un qualche data type MPI, ed è per questo motivo che generalmente bisogna dare ad ogni routine MPI il tipo di dato che bisogna passare. Questo permette ai programmi MPI di essere eseguiti in maniera automatica in ambienti eterogenei. Inoltre un messaggio deve contenere un certo numero di elementi di quel data type. MPI definisce un numero di costanti che corrispondono ai data types nei linguaggi C e Fortran. Quando una routine MPI è chiamata, il data type Fortran (o C) del dato che sta per passare deve incontrare la corrispondente costante intera MPI, ricordando che i tipi di C sono differenti da quelli Fortran. Gli MPI data types possono essere divisi in: Basic types; Derived types: questi possono essere costruiti a partire dai basic types; I data types definiti dall utente permettono ad MPI di allargare e restringere i dati da e verso buffer non contigui. Ogni messaggio è identificato dal suo Fig.24. Struttura di un messaggio envelope (Fig.24), e può essere ricevuto solo se il ricevente specifica il corretto envelope. Nella tabella 1 possiamo vedere quali sono i basic data types MPI corrispondenti al linguaggio Fortran. MPI Data type Fortran Data type MPI_INTEGER INTEGER MPI_REAL REAL MPI_DOUBLE_PRECISION DOUBLE PRECISION MPI_COMPLEX COMPLEX MPI_DOUBLE_COMPLEX DOUBLE COMPLEX MPI_LOGICAL LOGICAL MPI_CHARACTER CHARACTER(1) MPI_PACKED MPI_BYTE Tabella 1. Fortran - MPI basic data types 18

Nella tabella 2 possiamo vedere quali sono i basic data types MPI corrispondenti al linguaggio C. MPI Data type MPI_CHAR MPI_SHORT MPI_INT MPI_LONG MPI_UNSIGNED_CHAR MPI_UNSIGNED_SHORT MPI_UNSIGNED MPI_UNSIGNED_LONG MPI_FLOAT MPI_DOUBLE MPI_LONG_DOUBLE MPI_BYTE MPI_PACKED Tabella 2. C - MPI basic data types L ottica della comunicazione si divide in: Ottica globale, che può essere: A. Sincrona: il mittente sa se il messaggio è arrivato o meno; B. Asicrona: il mittente non sa se il messaggio è arrivato o meno; Ottica locale legata al buffer di trasmissione, e può essere: A. Bloccante: restituisce il controllo al processo che ha invocato la primitiva di comunicazione solo quando la primitiva di comunicazione è stata completata, cioè il buffer di uscita è stato svuotato. Il concetto di bloccante è diverso da quello di sincrona; B. Non bloccante: restituisce il controllo al processo che ha invocato la primitiva di comunicazione quando la primitiva di comunicazione è stata eseguita, senza controllo sull effettivo completamento, eventualmente da poter effettuare in seguito. Il processo invocante può nel frattempo passare ad eseguire altre operazioni. In MPI vi sono diverse combinazioni possibili tra SEND/RECEIVE sincrone e asincrone, bloccanti e non bloccanti. Cosa vuol dire completare o completamento di un comunicazione? Possiamo anche in questo caso separare un completamento locale da uno globale, e diremo, che: Una comunicazione è completata localmente su di un processo se il processo ha completato tutta la sua parte di operazioni relative alla comunicazione fino allo 19 C Data type signed char signed short int signed int signed long int unsigned char unsigned short int unsigned int unsigned long int float double long double

svuotamento del buffer di uscita. Dal punto di vista dell esecuzione del programma, completare localmente una comunicazione significa che il processo può eseguire l istruzione successiva alla SEND o RECEIVE, possibilmente un altra SEND. Una comunicazione è completata globalmente se tutti i processi coinvolti hanno completato tutte le rispettive operazioni relative alla comunicazione, inoltre è completata globalmente se e solo se è completata localmente su tutti i processi coinvolti. Come abbiamo già detto in precedenza le due primitive possono essere bloccanti e non bloccanti, nel caso della libreria MPI significa: SEND non bloccante: questa funzione ritorna immediatamente, e il buffer del messaggio non deve essere sovrascritto subito dopo il ritorno al processo chiamante, ma si deve controllare che la SEND sia completata localmente; SEND bloccante: questa funzione ritorna quando la SEND è completata localmente, e il buffer del messaggio può essere sovrascritto subito dopo il ritorno al processo chiamante; RECEIVE non bloccante: ritorna immediatamente, e il buffer del messaggio non deve essere letto subito dopo il ritorno al processo chiamante, ma si deve controllare che la RECEIVE sia completata localmente; RECEIVE bloccante: ritorna quando la RECEIVE è completata localmente, e il buffer del messaggio può essere letto subito dopo il ritorno. Inoltre, la modalità di comunicazione point to point deve specificare quando un operazione di SEND può iniziare a trasmettere e quando può ritenersi completata. La libreria MPI prevede quattro modalità, cioè: Modalità sincrona: la SEND può iniziare a trasmettere anche se la RE- CEIVE corrispondente non è iniziata. Tuttavia, la SEND è completata solo quando si ha garanzia che il processo destinatario ha eseguito e completato la RECEIVE; Modalità buffered: simile alla modalità sincrona per l inizio della SEND. Tuttavia, il completamento è sempre indipendente dall esecuzione della RECEIVE; Modalità standard: la SEND può iniziare a trasmettere anche se la RE- CEIVE corrispondente non è iniziata. La semantica del completamento è sincrona o buffered; Modalità ready: la SEND può iniziare a trasmettere assumendo che la corrispondente RECEIVE sia iniziata. Riassumendo il tutto, l operazione di SEND possiede quattro modalità di comunicazione (sincrona, standard, buffered, ready) e due modalità di blocking (bloccante, non bloccante), mentre l operazione di RECEIVE ha una modalità di comunicazione (standard) e le stesse modalità bloccanti della SEND. Inoltre una qualsiasi operazione di RECEIVE può essere utilizzata per ricevere mes- 20

saggi da una qualsiasi SEND. In questa relazione ci occuperemo esclusivamente di SEND e RECEIVE standard, lasciando al lettore ulteriori approfondimenti per quanto riguarda le altre modalità. Iniziamo a descrivere la sintassi di queste due funzioni, partendo dalla modalità bloccante per poi passare a quella non bloccante. La sintassi di queste due funzioni è: int MPI_Send (void *buf, int count, MPI_Datatype type, int dest, int tag, MPI_Comm comm); int MPI_Recv (void *buf, int count, MPI_Datatype type, int dest, int tag, MPI_Comm comm, MPI_Status *status); MPI_SEND (buf, count, type, dest, tag, comm, ierr) MPI_RECV (buf, count, type, dest, tag, comm, status, ierr) Spieghiamo cosa contengono queste funzioni. Innanzitutto possiamo separare le variabili presenti all interno della parentesi, seguendo lo schema visto in Fig.24, in message body (buf, count, type) ed envelope (dest, tag, comm). Il termine buf indica dove inserire il messaggio ricevuto o inviato. Count, che deve essere un intero, indica il numero di elementi presenti nel buf che devo inviare. Type è il tipo di variabile presente nel buf da inviare, fra quelli presenti nelle tabelle 1 e 2, ed è questo parametro che determina quanta memoria allocare. Dest, che deve essere un numero intero, indica il rank del processo destinatario. Tag, anch esso un intero, identifica il numero del messaggio. Comm identifica il comunicatore dei mittenti e dei destinatari. Status è un array di grandezza MPI_STATUS_SIZE che contiene le informazioni sullo stato della comunicazione, e può essere di tre tipi: status.mpi_source: contiene il rank del mittente; status.mpi_tag: contiene il tag del messaggio; status.mpi_get_count: contiene il numero di item ricevuti. La sintassi per queste tre funzioni è: status.mpi_source; status.mpi_tag; int MPI_Get_Count (MPI_Status status, MPI_Datatype datatype, int *count); status (MPI_SOURCE) status (MPI_TAG) MPI_GET_COUNT (status, datatype, count, ierror) INTEGER status (mpi_status_size), datatype, count, ierror Sia in Fortran che in C la funzione RECV accetta delle wildcard per ricevere da ogni sorgente (MPI_ANY_SOURCE) e da ogni tag (MPI_ANY_TAG), seguendo l ordine con cui le varie sorgenti vengono chiamate all interno del programma. Per una comunicazione che avvenga con successo bisogna rispettare delle regole, cioè: I. Il mittente deve specificare un rank per il destinatario valido; II. Il destinatario deve specificare un rank per il mittente valido; III. Devono avere lo stesso comunicatore; IV. I tag devono coincidere; 21

V. Il tipo di messaggio deve coincidere; VI. Il buffer del ricevitore deve essere abbastanza grande da contenere per intero il messaggio. Dovrebbe essere una volta e mezzo più grande del buffer del mittente. Facciamo un esempio in cui utilizziamo le funzioni SEND e RECEIVE non bloccanti. Scriviamo un programma in C in cui il processo 0 invia il proprio rank al processo 1, e il processo 1 invia il proprio rank al processo 0, ed infine ciascun processo stampa su terminale il proprio rank e quello ricevuto: #include <stdio.h> #include<stdlib.h> #include "mpi.h" main(int argc, char** argv) { MPI_Init (&argc, &argv); int myrank; int mysize; int yourrank; MPI_Status status; MPI_Comm_rank( MPI_COMM_WORLD, &myrank); MPI_Comm_size( MPI_COMM_WORLD, &mysize); if (mysize!=2) { if (myrank==0) printf("this program need exactly 2 processors! \n"); return 1; } if (myrank==0) { int yourrank; MPI_Send(&myrank, 1, MPI_INTEGER, 1, 0, MPI_COMM_WORLD); MPI_Recv(&yourrank, 1, MPI_INTEGER, 1, 1, MPI_COMM_WORLD,& status); printf("hello I'M PROCESS %d AND I HAVE RECEIVED RANK %d \n", myrank, yourrank); } else { MPI_Recv(&yourrank, 1, MPI_INTEGER, 0, 0, MPI_COMM_WORLD,& 22

status); MPI_Send(&myrank, 1, MPI_INTEGER, 0, 1, MPI_COMM_WORLD); printf("hello I'M PROCESS %d AND I HAVE RECEIVED RANK %d \n", myrank, yourrank); } MPI_Finalize(); } Questo programma scriverà sul terminale: HELLO I'M PROCESS 0 AND I HAVE RECEIVED RANK 1 HELLO I'M PROCESS 1 AND I HAVE RECEIVED RANK 0 Un problema legato agli scambi dei messaggi è il Deadlock (Fig.25) che avviene quando due processi sono bloccati ed ognuno sta aspettando l altro P 0 a = 100; send (&a, 1, 1); a = 0; modifico il valore di a uno solo invio il dato contenuto nel buffer a al proceso processo 11 P 1 receive (&a, 1, 0); printf ( %d\n, a); Fig.26. Esempio di sovrapposizione di dati per andare avanti. Inoltre come abbiamo visto in precedenza la funzione SEND esce dal programma una volta che ha copiato per intero i propri dati nella memoria dell altro processo, a prescindere dal fatto che una RECEIVE sia stata chiamata. Vogliamo illustrare attraverso un esempio un problema che poteva accadere nel programma precedente. Nella Fig.26 schematizziamo un esempio di possibile problema: il processo 0 invia il dato di una variabile al processo 1, ma prima che il processo 0 riceva questa variabile, all interno del programma il valore di questa variabile cambia. Quale valore riceve il buffer destinatario? La semantica dell operazione di send richiede che il valore ricevuto da P 1 sia 100. 23 Fig.25. Deadlock uno solo memorizzo il dato ricevuto in a ricevuto dal processo 0

Tuttavia se la piattaforma hardware supporta l accesso diretto alla memoria, cioè il dato viene copiato da una memoria all altra, e il trasferimento asincrono dei messaggi, il processo P 1 potrebbe ricevere il valore 0 invece di 100. L operazione di SEND deve bloccare fino a quando la semantica dell operazione non è garantita. Se la SEND sblocca, cioè permette l esecuzione del resto del programma prima che P 1 abbia ricevuto il dato, P 1 può ricevere il dato sbagliato. E per questo motivo che abbiamo usato in precedenza una SEND bloccante, in cui l operazione di send non termina finché il processo ricevente non ha avuto il messaggio, vi è un handshake tra il processo mittente e destinatario che permette l inizio dell operazione di trasferimento. Ma supponiamo che l operazione di RECEIVE sia all interno del programma molto lontana dall operazione di SEND, e che quindi il programma resti bloccato per molto tempo prima di svolgere tutte le funzioni successive alla SEND. In questo caso il processo di comunicazione diventa obsoleto, e il tempo impiegato per la comunicazione diventa maggiore del tempo di calcolo. In molte implementazioni MPI i dati da inviare sono copiati in un buffer interno, in maniera tale che quando il processo mittente deve eseguire una SEND mette il messaggio nel buffer e termina dopo che la copia nel buffer è completata, e può continuare con le altre operazioni. A questo punto i dati trasferiti vengono memorizzati in un buffer del processo ricevente. Quando il processo destinatario incontra un operazione di RECEIVE verifica che i dati siano nel suo buffer. In questo modo riusciamo a velocizzare di molto le operazioni, ma, purtroppo, la grandezza di questo buffer è circa 2MB, quindi se inviamo, ad esempio un vettore o una matrice più grandi di 2MB, corriamo il rischio di non inviare niente e di mandare il programma in hang. In questo caso ci corrono in aiuto le funzioni SEND e RECEIVE non bloccanti. Le comunicazioni non bloccanti permettono la separazione tra l inizio della comunicazione e il completamento, cioè terminano prima che sia assicurata la loro correttezza semantica. Il vantaggio principale è legato al fatto che il programma può fare altro a livello computazionale mentre effettua la comunicazione, cioè possiamo costruire un overlapping fra la comunicazione e il calcolo. Lo svantaggio principale è legato al fatto che il programmatore deve inserire dei codici aggiuntivi che indichino se la semantica di un trasferimento precedente è stata violata o non violata, e il controllo avviene in una fase successiva. La sintassi di queste due funzioni è: int MPI_Isend (void *buf, int count, MPI_Datatype type, int dest, int tag, MPI_Comm comm, MPI_Request *req); int MPI_Irecv (void *buf, int count, MPI_Datatype type, int dest, int tag, MPI_Comm comm, MPI_Request *req); MPI_ISEND (buf, count, type, dest, tag, comm, req ierr) MPI_IRECV (buf, count, type, dest, tag, comm, req, ierr) Req è una variabile intera, e identifica quale comunicazione sta avvenendo. Se usiamo queste due funzioni ci assicuriamo un miglioramento nelle performance, ma abbiamo perso il controllo sulla comunicazione. Per questo motivo, as- 24

sociate a queste due funzioni, esistono due ulteriori primitive che hanno il compito di controllare se la SEND ha finito di inviare e se la RECEIVE ha ricevuto quello che la SEND ha inviato. La sintassi di queste due nuove funzioni è: int MPI_Wait (MPI_Request *req, MPI_Status *status); MPI_WAIT (req, status, ierr) Req abbiamo già visto cosa sia, e deve essere iniziato da una SEND o da una RECEIVE. Anche status sappiamo già cosa sia, e se req è stato associato ad u- na RECEIVE, allora status deve contenere le informazioni sui messaggi ricevuti, altrimenti status può contenere un errore. La sintassi dell altra funzione è: int MPI_Test (MPI_Request *req, int *flag, MPI_Status *status); MPI_TEST (req, flag, status, ierr) Flag è una variabile logica che restituisce il valore vero se la comunicazione req è completata, altrimenti restituisce falso. Vediamo un esempio di comunicazione con funzioni non bloccanti, in cui aumentiamo la dimensione del messaggio fino al punto in cui se utilizzassimo primitive bloccanti il nostro programma andrebbe in hang. Il programma è simile a quello precedente solo che in questo caso la comunicazione fra i due processi è incrociata, e cioè il processo 0 invia al processo 1, e contemporaneamente deve ricevere dal processo 1: #include "stdlib.h" #include "stdio.h" #include "mpi.h" main(int argc, char** argv) { MPI_Init (&argc, &argv); int myrank; int mysize; int Y = 30000; int * message_to_send; int * message_to_recv; MPI_Status status; MPI_Request req1, req2; MPI_Comm_rank( MPI_COMM_WORLD, &myrank); MPI_Comm_size( MPI_COMM_WORLD, &mysize); if (mysize!=2) { if (myrank==0) printf("this program need exactly 2 processors! \n"); return 1; } 25

for (int i = 1; i <= Y; i++) { message_to_send = (int *) malloc(i* sizeof(int)); message_to_recv = (int *) malloc(i* sizeof(int)); if (myrank==0) { MPI_Isend(message_to_send, i, MPI_INTEGER, 1, 0, MPI_COMM_WORLD, &req1); MPI_Irecv(message_to_send, i, MPI_INTEGER, 1, 1, MPI_COMM_WORLD, &req2); MPI_Wait(&req2, &status); MPI_Wait(&req1, &status); printf("hello I'M PROCESS %d AND I HAVE RECEIVED RANK %d \n", myrank, i); } else { MPI_Isend(message_to_send, i, MPI_INTEGER, 0, 1, MPI_COMM_WORLD, &req1); MPI_Irecv(message_to_send, i, MPI_INTEGER, 0, 0, MPI_COMM_WORLD, &req2); MPI_Wait(&req1, &status); MPI_Wait(&req2, &status); printf("hello I'M PROCESS %d AND I HAVE RECEIVED RANK %d \n", myrank, i);} free (message_to_send); free (message_to_recv); } MPI_Finalize(); } Questo programma stamperà su terminale per 30000 volte il messaggio: HELLO I'M PROCESS 0 AND I HAVE RECEIVED RANK 1 HELLO I'M PROCESS 1 AND I HAVE RECEIVED RANK 0 In questo caso il calcolo parallelo consiste di un numero di processi, ognuno dei quali lavora solo sui dati locali, ed ogni processo ha delle variabili puramente locali, cioè non ha accesso alla memoria remota e la condivisione dei dati è sostituita dallo scambio dei messaggi 2.3 Comunicazioni collettive Le comunicazioni collettive permettono di scambiare messaggi fra gruppi di processi, e devono essere chiamate da tutti i processi che appartengono a quel comunicatore, che specifica quali processi sono coinvolti nella comunicazione. 26

Le caratteristiche principali delle comunicazioni collettive sono: Non interferiscono con le comunicazioni point to point e viceversa; Tutti i processi devono chiamare la routine collettiva; Non esistono comunicazioni collettive non bloccanti; Non esistono tag; I buffer delle RECEIVE devono essere della giusta grandezza. MPI possiede cinque tipi di comunicazione collettive (Fig.27), e sono: A. One-to-Many (multicast); B. One-to-All (broadcast); C. Many(All)-to-One (gather); D. Many-to-Many; E. All-to-All. process data P 0 P 1 A One-to-All (broadcast) A A P 2 A P 3 A P 0 A A B C D P 1 B Many-to-One (gatter) (gather) P 2 P 3 C D One-to-Many (scatter) P 0 A A B C D P 1 B Many-to-Many(allgatter) (allgather) A B C D P 2 C A B C D P 3 D A B C D P 0 P 1 P 2 P 3 A A A A B B B B C C C C D D D D All-to-All Fig.27. Tipi di comunicazione 27 A B C D A B C D A B C D A B C D

Nella Tabella 3 possiamo trovare le principali funzioni MPI che permettono la comunicazione collettiva in tutte le sue forme: Funzione MPI_BCAST MPI_SCATTER MPI_GATHER MPI_ALLGATHER MPI_REDUCE MPI_ALLREDUCE MPI_BARRIER Tabella 3. Principali funzioni MPI per le comunicazioni collettive La sintassi della funzione MPI_BCAST è: int MPI_Bcast (void *buf, int count, MPI_Datatype datatype, int root, MPI_Comm comm); INTEGER count, type, root, comm, ierr CALL MPI_BCAST (buf, count, type, root, comm, ierr) Root è il rank del processore che esegue il broadcast. Tutti i processi devono specificare la stessa root, rank e comm. La sintassi della funzione MPI_SCATTER è: int MPI_Scatter (void *sendbuf, int sendcount, MPI_Datatype sendtype, void *recvbuf, int recvcount, int root, MPI_Comm comm) INTEGER sendcount, recvcount, root, comm, ierr CALL MPI_SCATTER (sendbuf, sendcount, sendtype, recvbuf, recvcount, recvtype, root, comm, ierr) Sendbuf è l indirizzo del dato inviato, sendcounts è il numero di elementi inviati ad ogni processo che devono essere moltiplicati per il numero di processi nel comunicatore, sendtype il tipo di dato inviato, recvbuf l indirizzo del dato ricevuto, recvcount il numero di elementi ricevuti, recvtype è il tipo di dato ricevuto, root il processore che esegue l operazione. Esiste una versione alternativa di MPI_SCATTER ed è la funzione MPI_SCATTERV, in cui i messaggi individuali vengono distribuiti dal root ad ogni processo nel comunicatore, e la dimensione dei messaggi può essere differente. Tutte le routines il cui nome termina con v consentono di avere grossi tronconi di dati di dimensione diversa, dove l argomento della chiamata è un vettore e non uno scalare. La sintassi è 28 Operazione Un processore spedisce dati ad altri processi appartenenti ad un gruppo. Il processore root comunica i dati a tutti i processi appartenenti ad un gruppo. Vengono comunicati ad uno o a tutti i processi appartenenti ad un gruppo i dati posseduti da diversi processi. Il risultato di una operazione eseguita sui dati posseduti da diversi processi viene comunicata ad uno o a tutti i processi appartenenti ad un gruppo. L esecuzione di ogni processore appartenente ad un gruppo viene messa in pausa fino a che tutti i processi di quel gruppo non sono giunti a questa istruzione.

praticamente uguale alla funzione MPI_SCATTER, solo che dopo int *sendcount, bisogna inserire la variabile int *displs, che è un vettore di interi di lunghezza pari alla dimensione del gruppo che contiene i displacement nell array. La funzione MPI_GATHER trasmette da tutti i processi appartenenti ad un comunicatore ad un singolo processo denominato ricevente. La sintassi di questa funzione è: int MPI_Gather (void *sendbuf, int sendcount, MPI_Datatype sendtype, void *recvbuf, int recvcount, int root, MPI_Comm comm); INTEGER sendcount, recvcount, root, comm, ierr CALL MPI_GATHER (sendbuf, sendcount, sendtype, recvbuf, recvcount, recvtype, root, comm, ierr) Anche per la funzione MPI_GATHER esiste la funzione MPI_GATHERV, e si fanno le stessa considerazioni viste già per la funzione MPI_SCATTER solo che stavolta i messaggi hanno senso invertito. Esiste inoltre la funzione MPI_ALLGATHER in cui semplicemente sparisce il processo ricevente poichè tutti i processi del comunicatore ricevono il messaggio. La funzione MPI_REDUCE agisce con una particolare operazione su dati appartenenti a differenti processi per consegnarne il risultato finale ad un particolare processo (a tutti i processi nel caso della funzione MPI_ALLREDUCE), inoltre permette di: Collegare dati da ogni processo; Ridurre i dati ad un singolo valore; Depositare il risultato sui processi root; Depositare il risultato su tutti i processi; Overlapping della comunicazione e del calcolo. La sintassi di questa funzione è: int MPI_Reduce (void *sendbuf, int count, MPI_Datatype type, void *recvbuf, MPI_Op op, int root, MPI_Comm comm); INTEGER root, comm, ierr CALL MPI_GATHER (sendbuf, type, recvbuf, root, comm, ierr) Op si riferisce alla particolare operazione (appendice 2) che il processo ricevente deve eseguire sui dati a lui comunicati. Nella MPI_ALLREDUCE manca il root, poiché i risultati sono immagazzinati su tutti i processi. La funzione MPI_BARRIER (Fig.28) blocca tutti i processi appartenenti ad un gruppo fino a che tutti i processi che appartengono a questo gruppo hanno chiamato questa funzione. MPI_BARRIER viene quindi usata per sincronizzare tutti i processi appartenenti ad un Fig.28. Esempio funzione MPI_BARRIER 29

particolare gruppo identificato dal comunicatore presente nella chiamata. La sintassi di questa funzione è: int MPI_Barrier (MPI_Comm comm); INTEGER comm, ierr CALL MPI_GATHER (comm, ierr) L ultima funzione collettiva di cui ci occupiamo è MPI_ALLTOALL, in cui tutti i processi inviano a tutti i processi e la sua sintassi è: int MPI_Alltoall (void *sendbuf, int sendcount, MPI_Datatype sendtype, void *recvbuf, int recvcount, MPI_Comm comm); INTEGER sendcount, recvcount, comm, ierr CALL MPI_ALLTOALL (sendbuf, sendcount, sendtype, recvbuf, recvcount, recvtype, comm, ierr) Questa funzione è particolarmente utile quando vogliamo trasporre i dati. Un esempio in cui usiamo le funzioni collettive e risparmiamo molto tempo per le comunicazioni è il prodotto matrice per vettore, in questo caso vediamo un e- sempio scritto in Fortran90: program pmv implicit none include 'mpif.h' integer, dimension (MPI_STATUS_SIZE) :: STATUS integer :: IE,RANGO,NP,N,BS,I,J real, dimension(:,:), allocatable :: A,B real, dimension(:), allocatable :: X,Y,Z real :: c! Inizializziamo le routine MPI call MPI_INIT (IE) call MPI_COMM_RANK (MPI_COMM_WORLD, RANGO, IE) call MPI_COMM_SIZE (MPI_COMM_WORLD, NP, IE)! Indicizziamo e allochiamo in memoria la matrice if(rango.eq.0) then write(*,*) 'dai l'ordine della matrice A' read(*,*) N if(np*(n/np).ne.n) goto 10 BS=N/NP allocate (A(N,N), X(N), Y(N), B(N,BS), Z(BS)) call random_number(a) call random_number(x) A=int(100*A) X=int(100*X) write(*,*) (DOT_PRODUCT(A(I,1:N),X),I=1,N)! Viene trasposta la matrice A do I=1,N do J=1,I-1 30

C=A(I,J) A(I,J)=A(J,I) A(J,I)=C end do end do endif! Chiamiamo la funzione MPI che invia BS a tutti i processori del gruppo call MPI_BCAST (BS, 1, MPI_INTEGER, 0, MPI_COMM_WORLD, IE) if (RANGO.ne.0) then N=BS*NP allocate (B(N, BS), X(N), Z(BS)) endif! Allochiamo lo spazio per il vettore X(N) su tutti i processori del gruppo call MPI_BCAST (X, N, MPI_REAL, 0, MPI_COMM_WORLD, IE)! Inviamo gli elementi del vettore dal root ai processori del gruppo call MPI_SCATTER (A, BS*N, MPI_REAL, B, BS*N, MPI_REAL, 0, MPI_COMM_WORLD, IE) do i=1,bs Z(I)=DOT_PRODUCT(B(1:N,I),X) end do! Inviamo i nuovi elementi del vettore da tutti i processori al root call MPI_GATHER (Z, BS, MPI_REAL, Y,BS, MPI_REAL, 0, MPI_COMM_WORLD, IE) if (RANGO.eq.0) then Write(*,*) Y(1:N) endif 10 call MPI_FINALIZE (IE) end 2.4 Ottimizzazione dei codici Quando affrontiamo un problema computazionale si analizza il problema e si fanno, per prima cosa, delle scelte architetturali che condizionano il resto della vita del progetto. Queste scelte architetturali si esprimono, successivamente, attraverso il design dell applicazione parallela. Nella fase di design, le opzioni di implementazione alternative vengono confrontate e vengono scelte quelle che sono stimate essere le più convenienti in base alla perfomance, alla flessibilità, alla semplicità di implementazione, ecc. E onere del programmatore: A. Identificare il potenziale parallelismo ; B. Distribuire i dati; C. Gestire le comunicazioni e la sincronizzazione tra i task paralleli. Per prima cosa quindi dobbiamo identificare gli hotspot dell applicazione: 31

Generalmente nelle applicazioni tecniche-scientifiche la maggior parte del tempo di calcolo è speso in poche sezioni di codice (hotspot), e spesso sono dei loop. In secondo luogo dobbiamo analizzare il grado di parallelismo degli hotspot: Se l hotspot consiste in uno o più loop con iterazioni strettamente indipendenti, il grado di parallelismo è massimo; Se esiste una dipendenza di qualche tipo tra le varie iterazioni del loop, potrebbe essere ancora possibile parallelizzare il loop trattando in modo specifico le dipendenze (sincronizzazione e comunicazione tra processi; Qualora la dipendenza tra le iterazioni del loop precluda il parallelismo sarà necessario valutare algoritmi alternativi, più paralleli o tenersi la versione seriale! L identificazione del parallelismo è legata alla stima e all ottimizzazione del bilanciamento del carico tra i processi: L efficienza computazionale di un applicazione parallela dipende dal tempo impiegato dal task più lento; Se la quantità di lavoro che devono svolgere i processi è sistematicamente squilibrata, uno o più task saranno significativamente più lenti degli altri, riducendo l efficienza dell applicazione; In quest ultimo caso si dovrà disegnare un algoritmo di bilanciamento del carico. Per quanto riguarda la distribuzione dei dati, dobbiamo: Decidere come decomporre il problema (almeno inizialmente) secondo un criterio di parallelizzazione degli hotspot, ignorando le sezioni del programma poco CPU intensive; Distribuire i dati tra i vari processi come richiesto dall algoritmo parallelo, cercando di ottimizzare l algoritmo affinché si accedano preferibilmente dati locali al processo, cioè ridurre la comunicazione. Infine, per quanto riguarda il design delle applicazioni parallele dobbiamo decomporre il problema ed esistono due tipi di decomposizione, domain decomposition: I dati da elaborare (il dominio) sono divisi in porzioni omogenee; Ogni porzione è mappata su un processore diverso; Ogni processore lavora solo sulla porzione di dati assegnata. Se necessario, i processori comunicano periodicamente per scambiare dati; Questa strategia di decomposizione del problema è particolarmente indicata nei casi in cui il set di dati da elaborare è statico e le operazioni da compiere locali. La seconda è functional decomposition: 32

Quando il problema consiste di un set di operazioni distinte ed indipendenti che possono essere svolte parallelamente, possiamo assegnare ad o- gni processo la responsabilità di svolgere una di queste operazioni; Solitamente, al termine del calcolo delle varie operazioni, un processo riprende il controllo dell esecuzione e svolge la restante parte (seriale) del codice: master-slave. Naturalmente l invio di messaggi e la sincronizzazione introducono nel programma parallelo un costo computazionale assente nel caso seriale, che prende il nome di overhead parallelo o overhead di comunicazione. Quando si pianifica il pattern di comunicazione tra task, e quando si disegnano in dettaglio le comunicazioni, ci sono diversi fattori da tenere in considerazione, in particolare: Latency e bandwidth di comunicazione; Sincronizzazione e modalità di comunicazione. La latency è il tempo necessario per lo scambio di un messaggio di dimensioni minime (0 byte) tra due processi, ma è anche il tempo necessario per attivare la comunicazione tra due processi. Un valore tipico di latency è 1-5 μs. La bandwidth è la quantità di dati che possono essere comunicati nell unità di tempo. Un valore tipico di bandwidth è 1-2 GB/s. Si può facilmente comprendere che inviare molti piccoli messaggi può comportare un overhead di comunicazione dovuto alla latency. Un altro problema da ottimizzare è la sincronizzazione che in molti casi, esplicitamente o implicitamente, è richiesta dalle operazioni di comunicazione. La sincronizzazione induce un ulteriore overhead perché prima di procedere sarà necessario aspettare che il task più lento giunga all istruzione di sincronizzazione. Per questo motivo bisogna scegliere la modalità giusta di comunicazione per il problema dato in maniera tale da minimizzare l overhead di comunicazione. Le comunicazioni, come già visto, possono essere sincrone: Richiedono un qualche tipo di sincronizzazione tra i task che condividono dati e tale operazione implica un overhead; Sono più semplici da programmare e più sicure. Oppure asincrone: Consentono ad un task di trasferire dati, indipendentemente dallo stato del task che li deve ricevere; Sono più complesse da programmare. La possibilità di sovrapporre il calcolo con la comunicazione è l unico vero vantaggio nell utilizzo di comunicazioni asincrone. Un altro problema della sincronizzazione è legato al fatto, che, se la quantità di lavoro svolta dai vari processi è sbilanciata, alcuni raggiungono il punto di sincronizzazione in anticipo e debbono attendere gli altri (load imbalance). Per questo motivo bilanciare il carico di lavoro tra i task significa minimizzare il tempo in cui ogni processo non svolge lavoro (idle time), ottimizzando così le prestazioni. Certe classi di problemi consentono di dividere in modo omogeneo il lavoro fra i processi, attraverso una distribuzione omogenea delle strutture 33

dati tra i processi. Altre classi di problemi non consentono di ottenere un bilanciamento omogeneo in modo semplice, e sono: Loop triangolari; Problemi con array sparsi; Metodi con griglie adattive. Gli schemi di comunicazione, sincronizzazione e load balancing debbono essere ottimizzati globalmente per ottenere le massime performance parallele, cioè: min (comunicazione/sincronizzazione + sbilanciamento) Questa formula indica il modus operandi che un programmatore dovrebbe tenere a mente nel costruire il proprio algoritmo, cioè, cercare di ridurre al minimo il rapporto tra comunicazione e sincronizzazione sommato agli sbilanciamento, che rappresentano gli sbilanciamenti che si generano quando parallelizziamo un codice dovuto ai differenti carichi di lavoro distribuiti sui processori. In alcuni casi, minimizzare il costo della comunicazione e sincronizzazione richiede scelte in contrasto con quelle che permetterebbero la minimizzazione dello sbilanciamento, e perciò possiamo minimizzare solo uno dei due elementi presenti nella somma, generando due differenti modi di parallelizzare : parallelismo fine grain: in questo caso minimizziamo lo sbilanciamento aumentando le fasi di sincronizzazione e comunicazione per la distribuzione di nuovi quanti di lavoro; parallelismo coarse grain: minimizziamo il rapporto tra comunicazione e sincronizzazione generando solo pochi messaggi grandi. La granularità ottimale dipende sia dall applicazione che dal hardware a disposizione, e può accadere che: Quando l algoritmo parallelo implica un forte sbilanciamento e la latenza dell interconnessione di rete è bassa, la soluzione migliore sarà un parallelismo fine grain. Quando l algoritmo parallelo è naturalmente bilanciato la soluzione migliore sarà un parallelismo coarse grain. Quando l algoritmo parallelo implica un forte sbilanciamento e la latenza dell interconnessione di rete è alta, la soluzione sarà intermedia ; sarà necessario stimare il costo della comunicazione/sincronizzazione e del sbilanciamento prima di decidere il grado di granularità. Nella fase di design è necessario stimare e misurare le perfomance di un algoritmo parallelo. Successivamente alla fase di realizzazione, dovranno essere stimate le perfomance ottenute con la corrente implementazione. Qualora la stima e la misura delle perfomance siano fortemente diverse, sarà necessario migliorare l implementazione. Ma come possiamo stimare e misurare le prestazioni parallele? Ci aspettiamo che lanciando il nostro programma seriale su più processori, supponendo di aver parallelizzato nel modo migliore possibile, il tempo di esecuzione via via si dimezzi. Purtroppo non è così. Innanzitutto come misuriamo i tempi di esecuzione di un programma? Esiste in linux il comando time ls <nome programma>, che ci permette di misurare i tempi di ese- 34

cuzione di un programma seriale. Purtroppo questo comando non è valido per i programmi in parallelo. Inoltre per misurare realmente i vantaggi ottenuti dalla parallelizzazione, dovremmo calcolare i tempi solo delle parti di calcolo vero e proprio, e non considerare nella nostra stima le fasi, per esempio, delle allocazioni di memoria. Per fortuna MPI possiede una funzione che permette di misurare i tempi di esecuzione di un programma, che permette anche di separarne le varie parti e misurarne i tempi in modo separato, per poi avere una stima reale del tempo di calcolo parallelo. La funzione è MPI_WTIME, e la sua sintassi è: double MPI_Wtime (void); DOUBLE PRECISION MPI_WTIME() Questa funzione ci dà in uscita un tempo, misurato in secondi, a partire da un tempo arbitrario nel passato. A questo punto l idea più semplice che ci viene in mente è quella di inserire questa funzione in vari parti del programma, per e- sempio inserirne una subito sotto la fase iniziale di allocazione della memoria, dove non esiste parallelizzazione, una all interno del loop parallelo, e semplicemente fare la differenza fra le due per calcolare i tempi di esecuzione del calcolo in parallelo. Ma come facciamo a misurare all interno di un programma seriale i tempi di esecuzione delle varie parti? Sia le librerie del C che del Fortran possiedono due funzioni che permettono di misurare i tempi di vari blocchi del programma, e sono: int gettimeofday (struct timeval *restrict tp, void *restrict tzp); CALL SYSTEM_CLOCK (COUNT=IC, COUNT_RATE=IR, COUNT_MAX=IM) Non entriamo nel dettaglio di queste due funzioni, ci basta sapere che permettono di misurare i tempi di esecuzione seriale di un qualsiasi pezzo di codice. A questo punto siamo in grado di confrontare i tempi seriali con quelli paralleli. Eliminiamo il background, relativo alle operazioni di allocazione, e ci aspettiamo che i tempi di calcolo si dimezzino all aumentare del numero di processori. Purtroppo non é così. Infatti, oltre alle parti, iniziali e finali di un codice, può accadere che anche all interno del loop parallelizzato esistano delle parti non parallelizzabili. Questa parte prende il nome di frazione scalare, e siamo in grado di scrivere la legge di Amdahl (Fig.29), la quale afferma che se fs è la frazione di un calcolo che è sequenziale (cioè che non può beneficiare del parallelismo ), e (1 fs) è 35 Fig.29. Legge di Amdahl

la frazione che può essere parallelizzata, allora il tempo da noi misurato u- sando n processori è: t(n) = t(1) * [fs + (1 - fs)/n] Questa legge afferma che il tempo di calcolo non scala in maniera lineare, a causa delle parti scalari, e come si evince dal grafico, oltre una certa soglia si raggiunge un valore limite, che rende inutile l aumento del numero di processori. Anche in questo caso le parti scalari possono essere stimate facendo un confronto con i tempi ottenuti quando eseguiamo il programma seriale. Purtroppo la frazione scalare riduce di molto le perfomance. Inoltre dobbiamo considerare le parti MPI aggiunte al codice, che prima non erano presenti, cioè: Tempi di comunicazione/sincronizzazione; Tempi di eventuali idle time dovuto a load imbalance; Tempi legati al possibile miglioramento dell uso della gerarchia di memoria al crescere del numero di processori; Calcoli aggiuntivi. I primi tre dipendono esplicitamente dal numero di processi. Ciò vuol dire che se adesso calcoliamo come il tempo varia al variare del numero di processi, otteniamo un grafico (Fig.30) in cui è ben evidente che all aumentare del numero di processori rischiamo, oltre a non aver più nessun vantaggio, di aumentare progressivamente i tempi di calcolo. Aumentando ancora il numero di processori potremmo addirittura ottenere un punto in cui i tempi di calcolo sono maggiori di quelli ottenuti con la medesima esecuzione seriale. E evidente che più processori si usano maggiore è il tempo che serve per la comunicazione, ma non solo, anche il fattore di sbilanciamento è legato inevitabilmente all aumentare del numero di processori. La stima che è stata fatta nel grafico è molto negativa, infatti abbiamo supposto che la comunicazione e la sincronizzazione vadano come la radice quadrata di n, mentre lo sbilanciamento Fig.30. Stima reale dei tempi di calcolo 36 va come n elevato 0.7. Nella maggior parte dei casi, quando la parallelizzazione del codice è stata effettuata in maniera ottimale, questa stima migliora di molto, ma non elimina totalmente questi tempi, che influenzano il nostro calcolo. Sta al programmatore trovare quel punto nella curva di Fig.30 in cui il tempo di calcolo e il numero di processori sono ottimali. In aiuto dei programmatori viene una metrica conforme che permette di trovare questo punto di convergenza fra efficienza (tempi di calcolo) e costi computazionali (numero di processori). Questa metrica è la SPEEDUP, che è uguale a:

3. La GRID 3.1 GRID computing Fig.31. Schema di una Grid 37 Il termine Grid computing (letteralmente, "calcolo a griglia") sta ad indicare un paradigma del calcolo distribuito, di recente introduzione, costituito da un'infrastruttura altamente decentralizzata e di natura variegata in grado di consentire ad un vasto numero di utenti l'utilizzo di risorse (prevalentemente CPU e storage) provenienti da un numero indistinto di calcolatori (anche e soprattutto di potenza non particolarmente elevata) interconnessi da una rete (solitamente, ma non necessariamente, Internet). Nel campo delle architetture di calcolo parallelo, il Grid Computing rappresenta oggi, senza alcun dubbio, la frontiera delle attività di ricerca. Tale fatto è testimoniato sia dal numero di progetti di ricerca dedicati a tale parola chiave, sia dal numero di grandi ditte di sistemi di calcolo, software e informatica in generale, che si sono dotate di una loro soluzione Grid, sia dalle iniziative nazionali o sovranazionali connesse alla parola chiave Grid. Tuttavia, questo termine, oggi così attuale, è, se si considerano i progetti di ricerca e l evoluzione delle tecnologie informatiche nel loro complesso, solo la punta di un iceberg. Il termine Grid (Fig.31), infatti, rappresenta la formulazione particolarmente fortunata di un idea, quella della condivisione delle risorse di calcolo, sviluppatasi faticosamente negli ultimi tre decenni a seguito della rapida e- voluzione delle tecnologie informatiche. Negli ultimi dieci anni, poi, a partire dai primi esperimenti di macchine parallele virtuali realizzate su reti locali sino a giungere a concezioni avveniristiche quali la potenza di calcolo su richiesta o l integrazione dinamica di componenti simulativi sviluppati indipendentemente, un filo conduttore può essere individuato nella necessità di superare il problema dell eterogeneità, sia a livello hardware che a livello software, al fine di fornire un immagine integrata del sistema. Questo problema dell eterogeneità, tuttavia, si presenta in nuove forme ogni qualvolta, trovata una soluzione accettabile per dominare il livello di eterogeneità precedentemente affrontato, si voglia superare la concezione precedente per aprire a nuove idee e possi-