Scuola Politecnica e delle Scienze di Base Corso di Laurea in Ingegneria Informatica Elaborato finale in Programmazione I Modelli di programmazione per il calcolo parallelo Parallel programming models Anno Accademico 2015/16 candidato Michele Caggiano matr. N46001601 "Ora non è il momento di pensare a quello che non hai. Pensa a quello che puoi fare con quello che hai." ­ Ernest Hemingway Alla mia famiglia, ai miei compagni di viaggio, a chi mi ha supportato e a chi mi sopporta. 2 Indice Indice....................................................................................................................................................3 Introduzione..........................................................................................................................................4 Capitolo 1: L'evoluzione del modello architetturale............................................................................5 1.1 Dal sequenziale al parallelo........................................................................................................5 1.2 Lo sviluppo tecnologico: il general purpose many core.............................................................6 Capitolo 2: I modelli per la programmazione parallela........................................................................8 2.1 Il modello pipeline......................................................................................................................8 2.2 Il modello master-slave..............................................................................................................9 Capitolo 3: Il mondo della GP-GPU..................................................................................................10 3.1 La tassonomia di Flynn.............................................................................................................10 3.2 L'architettura di una GPU.........................................................................................................11 3.3 Lo scontro generazionale: GP-GPU vs CPU............................................................................13 3.4 I modelli di programmazione su GP-GPU...............................................................................14 Capitolo 4: CUDA..............................................................................................................................15 4.1 La gestione dei thread in CUDA..............................................................................................15 4.2 L'organizzazione gerarchica della memoria in CUDA.............................................................16 4.3 La struttura di un codice CUDA...............................................................................................18 4.4 Implementazione di CUDA in C..............................................................................................19 4.5 Esempio di codice CUDA C.....................................................................................................21 4.5.1 provaCUDA.cu..................................................................................................................22 4.5.2 Analisi del codice..............................................................................................................26 4.5.3 Esecuzione su Nsight.........................................................................................................29 4.5.4 Profiling.............................................................................................................................29 4.5.5 Confronto con codice sequenziale di funzionalità equivalente.........................................32 Conclusioni.........................................................................................................................................35 Sviluppi Futuri....................................................................................................................................37 Bibliografia.........................................................................................................................................38 3 Introduzione Questo elaborato è stato redatto al fine di presentare la natura architetturale ed il recente sviluppo dei modelli di programmazione per il calcolo parallelo. Quest'ultima è una tecnica informatica nata dall'evoluzione della classica programmazione sequenziale e basata sull'esecuzione simultanea e concorrente del codice sorgente di uno o più programmi, allo scopo di aumentare le prestazioni del calcolo attraverso l'ottimizzazione dell'uso delle risorse a disposizione e del relativo tempo di esecuzione, pur necessitando di una maggiore capacità del programmatore nel dividere e specificamente adattare su più microprocessori o più core dello stesso processore le istruzioni da eseguire. Il processo evolutivo a cui si fa riferimento nel primo capitolo di questa tesi è molto evidente nell'emblematico passaggio dalla centralizzazione dell'elaborazione sulla CPU (“Central Processing Unit”) alle tecniche di co-processing tra CPU e GPU (“Graphic Processing Unit”) che permette netti aumenti delle prestazioni di computing grazie allo sfruttamento della potenza di calcolo delle GPU. Questo lavoro di tesi, inoltre, presenta un'analisi generale dei principali modelli di programmazione parallela definiti nel corso degli anni all'interno del settore della ricerca informatica e volge particolare attenzione ad una delle più recenti piattaforme di elaborazione in parallelo introdotte dalla NVIDIA Corporation e denominata CUDA (“Compute Unified Device Architecture”) che sfrutta linguaggi di programmazione ad alto livello ed aumenta esponenzialmente le performance di calcolo di sistemi di svariata natura (scientifica, grafica, finanziaria, etc.). Nelle ultime pagine di questo lavoro, infine, si evidenziano i vantaggi esecutivi effettivi e le potenzialità future più importanti dell'applicazione del modello di programmazione parallela in confronto con il tradizionale codice sequenziale attraverso un esempio di implementazione applicativa basato sul linguaggio C. 4 Capitolo 1: L'evoluzione del modello architetturale I modelli di programmazione si sono evoluti nel corso del tempo di pari passo con la scienza informatica della quale fanno parte. Tali sviluppi si sono resi evidenti con le mutazioni che i modelli architetturali, ovvero l'insieme di caratteristiche che determinano le funzionalità dei vari componenti informatici, hanno subito. Tra tutti il processore è sicuramente quello di maggiore interesse e, al fine di comprendere in maniera migliore l'efficacia dei modelli, c'è bisogno di conoscere i passaggi evolutivi che hanno interessato l'architettura di questo componente nevralgico nell'economia di un sistema informatico. 1.1 Dal sequenziale al parallelo Fino ai primi anni 2000 il modello architetturale del processore è rimasto pressoché invariato: si trattava, infatti, di un modello sequenziale nel quale un insieme di componenti hardware era in grado di eseguire le istruzioni di un unico programma alla volta. Tale modello architetturale si è evoluto grazie alla sempre maggiore densità di componenti elettronici sul silicio di cui è composto, in concordanza con la legge di Moore, formulata negli anni '60 da Gordon Moore e rispettata tuttora, che stabilisce che il numero di componenti su una certa superficie di silicio raddoppia ogni due anni per via dei miglioramenti delle tecnologie di produzione. Questi miglioramenti, però, non hanno portato esclusivamente ad una costruzione di modelli architetturali che occupassero porzioni di chip sempre maggiori, ma anche ad una tendenza alla miniaturizzazione dei componenti e alla possibilità di lavorare a frequenze di clock sempre più elevate. Questo andamento ha subito un brusco rallentamento proprio nei primi anni 2000 per due ragioni fondamentali: • non si è stati più in grado di trovare miglioramenti architetturali che portassero ad un incremento delle prestazioni proporzionale all'area di silicio occupata per introdurli; • l'utilizzo di frequenze di clock sempre più elevate ha introdotto notevoli problemi 5 di consumo e di dissipazione termica. A questo punto i produttori si sono trovati in una situazione che vedeva da una parte la possibilità di realizzare componenti di calcolo sempre più complessi su un singolo chip e dall'altra parte la difficoltà oggettiva a realizzarli all'interno del modello architetturale sequenziale utilizzato fino a quel momento. Di fronte a questo criticismo si è delineata l'idea di accantonare il modello sequenziale stesso: non si è cercato, quindi, di produrre componenti che eseguissero le istruzioni di un singolo programma sempre più velocemente, ma si sono sfruttate le potenzialità della tecnologia a disposizione per produrre sullo stesso chip più core, ossia più componenti, che, operando in maniera combinata e sfruttando un sottosistema di memoria comune, fossero in grado di eseguire più istruzioni parallelamente. Figura 1.1: confronto tra modello sequenziale e paralello 1.2 Lo sviluppo tecnologico: il general purpose many core Viene così segnato un nuovo solco particolarmente profondo nell'approccio al codice da parte del programmatore: quest'ultimo, infatti, non tende più a concepire il proprio programma come una serie di istruzioni eseguite sequenzialmente, ma deve pensare ad esso come ad un insieme di attività da coordinare esplicitamente ed in grado di essere eseguite in modo autonomo. Questa metodologia di programmazione diventa obbligatoria nel momento in cui il progresso tecnologico di produzione dei chip porta all'eliminazione dall'area di silicio di tutti quei componenti che introducevano miglioramenti minimi a 6 fronte dello spazio occupato, al fine di far posto ad un numero maggiore di core sulla superficie a disposizione. Si determina così la nascita dei cosiddetti general purpose many core, che dispongono di decine o addirittura centinaia di core ottimizzati per l'esecuzione parallela di più flussi di controllo. È importante notare che, eseguendo un programma implementato appositamente per un processore single core su uno dei core di un processore many core, si otterrebbero prestazioni leggermente inferiori. L'avanzamento tecnologico non si ferma, però, solo a queste innovazioni architetturali, ma include anche la diffusione di nuove componenti che si adattano perfettamente alle esigenze della programmazione parallela. Queste novità sono: • le GPU (Graphics Processing Unit), ovvero acceleratori in grado di processare molto velocemente tutte le operazioni tipiche della grafica computazionale, che utilizzano migliaia di componenti capaci di eseguire ciascuno semplici operazioni su un singolo pixel dell'immagine; • le FPGA (Field Programmable Gate Array), ovvero dispositivi programmabili via software formati da migliaia di porte logiche che permettono l'implementazione di funzioni logiche anche abbastanza complesse. La loro programmazione è realizzata attraverso linguaggi descrittivi come VHDL o Verilog, i quali richiedono una profonda conoscenza del dispositivo stesso. Entrambe queste tecnologie erano inizialmente messe a disposizione come componenti che interagivano con la CPU attraverso il bus del processore, ma successivamente ci si è resi conto che, soprattutto in riferimento alle GPUs, potessero essere più efficaci se programmate ad-hoc per eseguire particolari tipi di computazioni, anche non esclusivamente grafiche. Nacquero così le cosiddette GP-GPU (General Purpose-GPU), che tutt'oggi sono molto impiegate nei sistemi informatici più all'avanguardia. 7 Capitolo 2: I modelli per la programmazione parallela Fin dagli anni '90 ci si è resi conto che molte applicazioni parallele in realtà sfruttavano modelli computazionali profondamente simili tra loro. Si è reputato utile, quindi, definire alcuni pattern, ossia schemi prestabiliti per la risoluzione di algoritmi specifici, che organizzassero le modalità di creazione ed interazione delle varie azioni svolte parallelamente e che ora andiamo brevemente ad analizzare. 2.1 Il modello pipeline Il modello pipeline è basato su stadi che si susseguono e che hanno il compito di calcolare risultati parziali che si avvicinano sempre più al risultato finale dell'operazione da effettuare e che siano posti in ingresso allo stadio immediatamente successivo. In termini di programmazione parallela, l'idea è di assegnare ciascuno stadio ad una unità di elaborazione e provvedere ad implementare un meccanismo per il quale ogni stadio possa interagire con lo stadio successivo attraverso un invio di dati. Questo modello è particolarmente utilizzato nelle applicazioni di calcolo matematico e nel video processing o comunque risulta molto valido se gli stadi della pipeline sono tutti computazionalmente equivalenti, altrimenti stadi meno onerosi potrebbero dare vita ad effetti “a collo di bottiglia”. Figura 2.1: operazioni di un modello pipeline 8 2.2 Il modello master­slave Nel modello master-slave un'attività, che prende il nome di master, distribuisce l'esecuzione dei vari task ad un insieme di più attività, ciascuna denominata slave, che restituiranno il risultato dell'elaborazione al master. Questo pattern è particolarmente utilizzato nell'ambito di applicazioni che prevedono una serie di elaborazioni che possono essere eseguite indipendentemente l'una dall'altra e in maniera parallela, come per esempio il calcolo della moltiplicazione tra due matrici. Figura 2.2: operazioni di un modello master-slave 9 Capitolo 3: Il mondo della GP-GPU La GPU è particolarmente efficace nel calcolo di tutte quelle operazioni che richiedono l'esecuzione di una singola funzione su tutti gli elementi di un vettore o di una matrice, grazie all'elevata potenza di calcolo dovuta alla presenza di migliaia di componenti su un unico chip. Ben presto questa unità ha abbandonato la semplice elaborazione grafica per essere destinata ad ambiti più generali, evolvendosi in GP-GPU, ovvero un nuovo settore della ricerca informatica che ha come scopo l'utilizzo del processore della scheda grafica in modo da accelerare notevolmente l'esecuzione di calcoli data parallel, ovvero calcoli il cui risultato dipende da un insieme di risultati parziali ottenuti attraverso computazioni indipendenti eseguite in maniera altamente parallela su partizioni del dato in input. Prima di analizzare nel dettaglio l'architettura di una GPU, può essere sicuramente utile chiarire la distinzione tra le varie tipologie di architetture dei calcolatori esistenti. Tale classificazione prende il nome di tassonomia di Flynn. 3.1 La tassonomia di Flynn Nel 1966 Michael J. Flynn ha classificato i sistemi di calcolo a seconda della molteplicità del flusso di istruzioni e del flusso dei dati che possono gestire. Nella tassonomia di Flynn sono presenti quattro diverse categorie: • SISD (Single Instruction, Single Data): in un sistema SISD (e.g., macchina di Von Neumann) un singolo stream di istruzioni processa un singolo flusso di dati senza alcun tipo di parallelismo; • SIMD (Single Instruction, Multiple Data): un sistema SIMD (e.g., supercomputer vettoriali) prevede che un singolo stream di istruzioni venga trasmesso contemporaneamente a più unità di elaborazione, ognuna caratterizzata da un proprio flusso di dati. Tale sistema è molto utilizzato per applicazioni con parallelismo a grana fine, ossia gestito in maniera del tutto trasparente al programmatore, o con semplici comunicazioni tra processi; 10 • MISD (Multiple Instruction, Single Data): un sistema MISD è basato su più flussi di istruzioni che lavorano contemporaneamente su un singolo stream di dati. Tale sistema non ha ancora trovato un'implementazione pratica; • MIMD (Multiple Instruction, Multiple Data): in un sistema MIMD (e.g., cluster di computer) più flussi di istruzioni vengono eseguiti contemporaneamente su più flussi di dati. L'architettura della maggior parte dei più moderni sistemi paralleli richiama questo schema e si è soliti suddividere questa categoria in sotto-categorie discriminando l'architettura della memoria a disposizione. Figura 3.1: tassonomia di Flynn 3.2 L'architettura di una GPU Ma perché la GPU ha sostituito la CPU come processore di riferimento nell'ambito della programmazione parallela? La risposta a questa domanda è racchiusa all'interno dell'architettura della GPU stessa: l'unità di elaborazione grafica, infatti, al contrario della CPU, presenta un numero molto elevato di processori relativamente semplici che basano la propria efficienza sull'esecuzione parallela di moltissimi thread, anziché sull'esecuzione ottimizzata di un unico processo. Tale architettura è basata sulla struttura definita SIMT (Single Instruction, Multiple Thread) che non è altro che un'estensione del concetto precedentemente discusso di SIMD. Il funzionamento di una GPU è legato all'esecuzione fisica in parallelo di blocchi di thread, chiamati warp, da parte di più SM (Streaming Multiprocessor). Se ciascun SM deve eseguire più blocchi di thread, un componente interno chiamato warp scheduler li partiziona in singoli warp di grandezza fissa in base all'ID dei thread, iniziando dal thread con ID 0 e proseguendo consecutivamente. Ciascuno 11 di questi SM racchiude al suo interno tre tipologie di unità di esecuzione: • otto processori scalari (SP) che eseguono operazioni aritmetico-logiche in virgola mobile e virgola fissa; • due unità funzionali speciali (SFU) che eseguono varie funzioni matematiche oltre alla moltiplicazione in virgola mobile; • un'unità di doppia precisione (DPU) che esegue operazioni aritmetiche su operandi in virgola mobile di 64 bit. Gruppi di SM appartengono a Thread Processing Clusters (TPC) che contengono anche altre risorse hardware tipiche delle applicazioni grafiche e in genere non visibili al programmatore. Figura 3.2: visione di dettaglio dell'architettura di una GPU La GPU è costituita dall'insieme dei TPC, dalla rete di interconnessione e dai controllori della memoria esterna. Figura 3.3: visione globale dell'architettura di una GPU 12 3.3 Lo scontro generazionale: GP­GPU vs CPU Dopo aver analizzato il processore grafico nel dettaglio a livello architetturale passiamo a confrontarlo con la CPU a livello di performance. Facendo riferimento al grafico sottostante, possiamo facilmente intuire che, in termini di Gflops/s, ossia il numero di operazioni in virgola mobile eseguite al secondo, il divario tra CPUs e GPUs è in costante crescita, sebbene entrambe le tecnologie siano segnate da un processo evolutivo in costante ascesa. Figura 3.4: confronto delle performance tra GPUs e CPUs Il divario tra le diverse architetture e, di conseguenza, tra le performance delle due tipologie di processori è ancora più netto se andiamo a confrontare due modelli di processore pressoché coetanei come l'Intel Core 2 Extreme QX9650 (CPU, 2009) e la NVIDIA GeForce GTX 280 (GPU, 2008). La scheda sottostante ci mette in risalto come la differenza architetturale basata sulla maggiore quantità ma minore complessità dei componenti presenti sulla GPU sia decisiva anche a livello prestazionale. Fattori come il minor numero di context switch, la minore latenza o i livelli di memoria cache a disposizione hanno decretato la preferenza riguardo l'uso delle GPU come processori a tutto tondo nell'ambito di una programmazione fortemente parallela. 13 Figura 3.5: confronto tra modelli specifici di GPU e CPU 3.4 I modelli di programmazione su GP­GPU Anche se, come abbiamo constatato nei paragrafi precedenti, i processori grafici sono nettamente più efficienti delle CPU, l'approccio odierno è comunque un approccio ibrido e congiunto tra le due unità di elaborazione principali. L'interazione tra le due componenti è possibile attraverso lo sfruttamento di un'area di memoria condivisa che permette di scambiare esclusivamente i riferimenti agli oggetti e non intere copie degli stessi, evitando la latenza dovuta ai trasferimenti. Tutto questo è possibile attraverso la Application Programming Interface (API) messa a disposizione dal modello di programmazione. Le piattaforme più recenti ed efficienti su GP-GPU sono OpenCL e CUDA: il primo è un framework multi-piattaforma basato sul linguaggio C o C++ portato a compimento dal consorzio no-profit Khronos Group; il secondo è una piattaforma ideata e sviluppata completamente da NVIDIA Corporation e destinata all'uso esclusivo su schede grafiche prodotte dall'azienda statunitense stessa. Oltre ad offrire un nuovo modello architetturale orientato all'alto tasso di parallelismo, CUDA permette di utilizzare una API e diversi linguaggi di programmazione ad alto livello, per sfruttare il processore grafico a tutto tondo. 14 Capitolo 4: CUDA Nel novembre 2006 la NVIDIA Corporation ha presentato CUDA, una piattaforma di calcolo parallelo general purpose e modello di programmazione che permette di demandare la risoluzione di problemi computazionali di natura altamente parallela al processore posto all'interno della GPU NVIDIA. Tale piattaforma fornisce una serie di strumenti per facilitarne la programmazione, tra i quali un supporto software, scaricabile all'interno del CUDA Toolkit dal sito web ufficiale della casa produttrice statunitense, che comprende un ambiente di sviluppo denominato Nsight, il quale permette anche di analizzare dettagliatamente il proprio programma attraverso un visual profiler, e un compilatore apposito (nvcc) che permette di programmare sia direttamente a livello di architettura, sia con linguaggi di programmazione ad alto livello. CUDA introduce un set di istruzioni assembly a più alto livello, denominato Parallel Thread Execution (PTX), che esula anche dallo specifico dispositivo utilizzato e semplifica il processo compilativo. In questo capitolo, infine, verrà valutato un esempio di codice in CUDA C, ossia un'estensione del classico linguaggio C con una API studiata appositamente da NVIDIA, che verrà analizzato nei suoi aspetti più significativi e confrontato con un codice C sequenziale. 4.1 La gestione dei thread in CUDA Sebbene la piattaforma CUDA sia esclusivamente destinata a prodotti NVIDIA, le architetture delle varie generazioni di schede grafiche cambiano costantemente, soprattutto per quanto riguarda il numero di microprocessori operanti, rispettando l'andamento pronosticato dalla legge di Moore. Per fronteggiare questo processo evolutivo senza troppi sforzi per il compilatore, CUDA prevede la suddivisione del programma multithread in una serie di blocchi indipendenti fra loro (blocks) che risiedono tutti sullo stesso multiprocessore ma ognuno eseguito da un singolo SM e dotato di una porzione di memoria propria. L'insieme di blocks è organizzato logicamente in griglie (grids) e 15 ciascun blocco contiene un numero limitato di thread a seconda delle dimensioni del multiprocessore, ma comunque non superiore a 1024 thread. Nei capitoli precedenti si è già discusso approfonditamente dell'architettura di una GPU generica: in questo caso, ogni multiprocessore di una GPU CUDA-Enabled gestisce warp composti da 32 thread che iniziano fisicamente l'esecuzione in parallelo. L'intero contesto di esecuzione (program counters, registri, etc.) di ogni warp è mantenuto in memoria per l'intero ciclo di vita del warp stesso, ottimizzando il numero e il costo dei context switch. Ad ogni iterazione il warp scheduler seleziona il warp che ha thread pronti ad eseguire la prossima istruzione tra i cosiddetti active threads. In particolare, ogni microprocessore ha un set di registri a 32 bit da dividere tra i warp e una porzione di memoria condivisa (shared memory) tra i blocchi di thread alla quale questi ultimi possono accedere venendo sincronizzati esplicitamente dal programmatore. Figura 4.1: esempio di gestione bidimensionale dei thread in CUDA 4.2 L'organizzazione gerarchica della memoria in CUDA L'elevata efficienza dell'architettura CUDA, però, non è dovuta esclusivamente alla gestione ottimizzata dei thread. Le performance di un codice CUDA sono ulteriormente migliorate dall'organizzazione gerarchica della memoria a disposizione, che fornisce una notevole flessibilità di utilizzo e permette di ottimizzare la velocità di accesso ai dati ed il 16 transfer rate tra memoria e processore, contribuendo anche a minimizzare l'effetto bottleneck nei trasferimenti di dati. Su un'architettura CUDA, infatti, sono disponibili le seguenti aree di memoria: • local memory: memoria a rapido accesso locale ai singoli thread; • registers: insieme di registri a rapidissimo accesso senza latenza locali ai singoli thread; • shared memory: memoria a rapido accesso con visibilità globale ai thread di un blocco e locale a ciascun blocco e con durata pari. Viene principalmente utilizzata per operazioni di lettura/scrittura comuni a più thread appartenenti ad un singolo blocco e la sua dimensione è diversa a seconda della versione di compute capability del dispositivo che esegue il programma, passando, infatti, dai 16 kB montati su ogni core dei chip versione 1.x ai 96 kB equipaggiati da quelli versione 5.2; • global memory: memoria con visibilità globale a tutti i thread dell'applicazione e all'host, il quale ne gestisce allocazione e de-allocazione; • constant memory e texture memory: memorie a sola lettura accessibili da tutti i thread dell'applicazione. Figura 4.2: interazione tra memorie e processori in CUDA 17 L'unica porzione di memoria che può essere acceduta e modificata sia dall'host che dal device è la global memory. Questa viene utilizzata svariate volte all'interno di un programma ed è, quindi, necessario minimizzare il bandwidth. Le performance ottimali si ottengono quando la GPU, nell'esecuzione della funzione kernel, appronta il cosiddetto coalescing degli accessi alla memoria globale: gli accessi da parte di un thread di un warp nella memoria globale sono agglomerati dal device nel numero minore possibile di transazioni, permettendo una esecuzione più efficiente e fluida. Le tecniche ed i requisiti per gestire il coalescing variano a seconda della compute capability dell'architettura in uso: per le GPUs precedenti la compute capability 2.0, infatti, le transazioni sono unite per un numero di thread pari alla metà di un warp anziché pari alla totalità dell'insieme. 4.3 La struttura di un codice CUDA Il modello CUDA prevede l'utilizzo della CPU, identificata come host, coadiuvato dalla GPU, identificata come device. Un codice CUDA alterna porzioni di codice sequenziale, eseguite dall'host, a porzioni di codice parallelo, eseguite dal device. La sequenza di istruzioni eseguite dalla CPU segue fondamentalmente tre passi: I. caricamento dei dati nella GPU; II. chiamata della funzione kernel; III. recupero dei risultati dell'elaborazione della GPU. La funzione kernel rappresenta il modo di implementare l'esecuzione parallela di una porzione di codice da parte introdotto da CUDA: questa, infatti, permette di eseguire una funzione più volte in parallelo su più thread, al contrario della singola esecuzione a cui fa riferimento una tradizionale funzione C. La GPU esegue un solo kernel alla volta. Per operare sui singoli thread o sui singoli blocchi, CUDA mette a disposizione degli identificatori, la cui natura è approfondita nel paragrafo successivo. Ogni kernel è suddiviso in grids di blocks, ma è l'hardware a gestire in maniera totalmente autonoma la distribuzione dei blocchi su ogni multiprocessore. Così facendo, CUDA si conferma come una piattaforma efficiente, altamente scalare e per lo più trasparente al programmatore per quanto concerne i passaggi generalmente più delicati di un'applicazione parallela. 18 Figura 4.3: interazione tra host e device in un codice CUDA 4.4 Implementazione di CUDA in C CUDA C estende il linguaggio ad alto livello C e permette, ovviamente, la gestione di porzioni di codice destinate alla CPU congiuntamente a porzioni di codice destinate al coprocessore GPU. L'estensione CUDA C introduce alcuni qualificatori del tipo della funzione che vanno a completare la firma tradizionale del linguaggio C, quali: • __device__: si inserisce se un metodo deve essere eseguito e richiamato dalla GPU; • __host__: si inserisce se un metodo deve essere eseguito e richiamato dalla CPU; • __global__: si inserisce per identificare un kernel, metodo chiamato dall'host e demandato alla GPU. Deve avere come tipo di ritorno un parametro void. Per quanto concerne i tipi e le variabili disponibili, sono stati introdotte diverse novità, quali: • __device__: qualificatore di tipo che definisce una variabile che risiede nella memoria globale, che ha ciclo di vita pari all'applicazione; • __constant__: qualificatore di tipo che definisce una variabile che risiede nella constant memory, che ha ciclo di vita pari all'applicazione; 19 • __shared__: qualificatore di tipo che definisce una variabile che risiede nella memoria condivisa di un blocco di thread; • dim3: struttura che identifica una tripla di interi basato su un uint3 (intero senza segno tridimensionale), il cui valore di default è 1, usato per specificare le dimensioni di una griglia o un blocco; • gridDim: variabile di tipo dim3 che contiene le dimensioni di una griglia; • blockIdx: variabile di tipo uint3 che contiene l'identificativo del singolo blocco all'interno della griglia; • blockDim: variabile di tipo dim3 che contiene le dimensioni del blocco; • threadIdx: variabile di tipo uint3 che contiene l'identificativo del singolo thread all'interno del blocco e permette l'accesso alle singole dimensioni tramite i suffissi .x, .y e .z; • warpSize: variabile di tipo int che contiene il numero di thread all'interno del blocco; • cudaError_t: tipo di errore introdotto da CUDA C. Inoltre CUDA C estende i singoli tipi primitivi del tradizionale C includendo anche tipi multidimensionali fino a tre dimensioni (e.g., uint3). La sintassi di invocazione dell'esecuzione di un kernel è totalmente nuova ed è la seguente: __global__ void KernelFuncion(int a, int b, int c) { … } int main() { … // Invocazione di un kernel su un unico blocco // di N*N*1 threads int numBlocks = 1; dim3 threadsPerBlock(N, N); KernelFunction<<<numBlocks, threadsPerBlock>>>(a, b, c); … } 20 Conseguentemente alle modifiche architetturali approntate dalla piattaforma CUDA, l'API CUDA C include anche nuovi metodi principalmente volti alla gestione della memoria e alla sincronizzazione, quali: • cudaMalloc(void** pointer, size_t nbytes): alloca un oggetto all'interno della memoria globale. Riceve come parametri di input l'indirizzo del puntatore all'oggetto allocato e la dimensione dello spazio da allocare; • cudaFree(void* pointer): libera lo spazio di memoria precedentemente allocato. Riceve in ingresso il puntatore all'oggetto da rimuovere; • cudaGetErrorString(cudaError_t err): ritorna i dettagli dell'errore passatogli in ingresso; • cudaMemcpy(void* dst, void* src, size_t nbytes, cudaMemcpyKind direction): gestisce il trasferimento dall'host al device e viceversa. Riceve in ingresso il puntatore alla destinazione, il puntatore alla sorgente, il numero di bytes da trasferire e un tipo di trasferimento. Quest'ultimo è solitamente una costante simbolica che può assumere un valore tra cudaMemcpyHostToDevice, cudaMemcpyDeviceToHost oppure cudaMemcpyDeviceToDevice; • cudaThreadSynchronize(): blocca l'esecuzione fino al momento in cui il device ha completato tutti i task richiesti. Se uno dei task fallisce, restituisce un errore; • __syncthreads(): utilizzato per coordinare la comunicazione tra thread dello stesso blocco, funziona come una barriera alla quale tutti i thread all'interno del blocco devono attendere fintantoché ognuno sia autorizzato a procedere. 4.5 Esempio di codice CUDA C Il modo migliore per evidenziare le capacità del linguaggio CUDA C è sicuramente attraverso l'analisi di un esempio di programma che fornisca una panoramica completa ed intuitiva sulle funzionalità della piattaforma: il seguente codice, ottenuto a partire da quello riportato come esempio nel paragrafo 3.2.3 della CUDA C Programming Guide di NVIDIA Corporation, calcola il prodotto tra due matrici A e B, rispettivamente di dimensioni 80x144 e 144x96, entrambe contenenti numeri interi compresi tra 0 e 5, e ne 21 riporta il risultato in una terza matrice C di dimensioni 80x96. Il listato in CUDA C implementato di seguito prevede l'utilizzo della memoria condivisa e stampa a video la durata dell'esecuzione della funzione kernel. 4.5.1 provaCUDA.cu #include <stdio.h> #include <stdlib.h> #include <cuda.h> // Grandezza del singolo blocco di thread #define BLOCK_SIZE 16 cudaEvent_t start, stop; typedef struct { // Numero di righe e colonne int width; int height; // Passo che coinciderà con il numero di colonne per // un rapido accesso agli elementi ordinati nell'array int stride; // Puntatore all'array contenente gli elementi int* elements; } Matrix; // Ritorna un elemento di una matrice in // posizione(row, col) __device__ int GetElement(const Matrix A, int row, int col) { return (A.elements[row * A.stride + col]); } // Set di un elemento in una matrice in posizione (row, col) __device__ void SetElement(Matrix A, int row, int col, int value) { A.elements[row * A.stride + col] = value; } // Ritorna la sotto-matrice Asub di dimensioni // BLOCK_SIZExBLOCK_SIZE di A che è posizionata a col // sotto-matrici di distanza a destra e a row sotto-matrici // di distanza in basso rispetto al primo elemento in alto // a sinistra __device__ Matrix GetSubMatrix(const Matrix A, int row, int col) { Matrix Asub; 22 Asub.width = BLOCK_SIZE; Asub.height = BLOCK_SIZE; Asub.stride = A.stride; Asub.elements = &A.elements[A.stride * BLOCK_SIZE * row + BLOCK_SIZE * col]; return Asub; } // Implementazione funzione kernel __global__ void MatMulKernel(const Matrix A, const Matrix B, Matrix C) { // Assegnazione riga e colonna su cui costruire la // sotto-matrice corrispondente al blocco di thread int blockRow = blockIdx.y; int blockCol = blockIdx.x; // Ogni blocco opera su una sotto-matrice Csub di C Matrix Csub = GetSubMatrix(C, blockRow, blockCol); // Ogni thread calcola un elemento di Csub // salvandolo in Cvalue int Cvalue = 0; // Assegnazione riga e colonna corrispondente al thread int row = threadIdx.y; int col = threadIdx.x; // Scorri tutte le sotto-matrici di A e B necessarie per // calcolare Csub. Moltiplica ogni coppia di // sotto-matrici e somma il risultato for (int m = 0; m < (A.width / BLOCK_SIZE); ++m) { // Estrazione della sotto-matrice Asub of A Matrix Asub = GetSubMatrix(A, blockRow, m); // Estrazione della sotto-matrice Bsub of B Matrix Bsub = GetSubMatrix(B, m, blockCol); // Assegna spazio in shared memory per Asub e Bsub __shared__ int As[BLOCK_SIZE][BLOCK_SIZE]; __shared__ int Bs[BLOCK_SIZE][BLOCK_SIZE]; // Carica Asub e Bsub da device a shared memory // Ogni thread carica un singolo elemento As[row][col] = GetElement(Asub, row, col); Bs[row][col] = GetElement(Bsub, row, col); // Sincronizzazione delle operazioni per essere // sicuri che le sotto-matrici siano state caricate // prima delle operazioni __syncthreads(); // Moltiplica Asub e Bsub for (int e = 0; e < BLOCK_SIZE; ++e) Cvalue += As[row][e] * Bs[e][col]; 23 // Sincronizzazione necessaria per essere certi che // le operazioni precedenti siano state completate // prima di caricare le prossime sotto-matrici __syncthreads(); } // Scrivi Csub sulla device memory // Ogni thread scrive un elemento SetElement(Csub, row, col, Cvalue); } // Le dimensioni delle matrici sono multiple di BLOCK_SIZE float MatMul(const Matrix A, const Matrix B, Matrix C) { // Creazione checkpoint per il calcolo del // tempo di esecuzione del kernel cudaEventCreate(&start); cudaEventCreate(&stop); // Definizione e caricamento delle matrici A e B // in device memory Matrix d_A; d_A.width = d_A.stride = A.width; d_A.height = A.height; size_t size = A.width * A.height * sizeof(int); cudaMalloc(&d_A.elements, size); cudaMemcpy(d_A.elements, A.elements, size, cudaMemcpyHostToDevice); Matrix d_B; d_B.width = d_B.stride = B.width; d_B.height = B.height; size = B.width * B.height * sizeof(int); cudaMalloc(&d_B.elements, size); cudaMemcpy(d_B.elements, B.elements, size, cudaMemcpyHostToDevice); // Definizione e allocazione della matrice C in device // memory Matrix d_C; d_C.width = d_C.stride = C.width; d_C.height = C.height; size = C.width * C.height * sizeof(int); cudaMalloc(&d_C.elements, size); // Invocazione funzione kernel dim3 dimBlock(BLOCK_SIZE, BLOCK_SIZE); dim3 dimGrid((B.width / dimBlock.x), (A.height / dimBlock.y)); cudaEventRecord(start, 0); MatMulKernel<<<dimGrid, dimBlock>>>(d_A, d_B, d_C); 24 cudaThreadSynchronize(); cudaEventRecord(stop, 0); cudaEventSynchronize(stop); // Lettura della matrice C dalla device memory cudaMemcpy(C.elements, d_C.elements, size, cudaMemcpyDeviceToHost); // De-allocazione della device memory cudaFree(d_A.elements); cudaFree(d_B.elements); cudaFree(d_C.elements); float el_time = 0.0; cudaEventElapsedTime(&el_time, start, stop); cudaEventDestroy(start); cudaEventDestroy(stop); return el_time; } int main(int argc, char* argv[]){ Matrix A, B, C; int a1, a2, b1, b2; float duration; srand(time(NULL)); // a1 // a2 // b1 // b2 Numero = 80; Numero = 144; Numero = a2; Numero = 96; di righe di A di colonne di A di righe di B di colonne di B A.height = a1; A.width = a2; // Alloca spazio necessario nella memoria dell'host A.elements = (int*)malloc(A.width * A.height * sizeof(int)); B.height = b1; B.width = b2; // Alloca spazio necessario nella memoria dell'host B.elements = (int*)malloc(B.width * B.height * sizeof(int)); C.height = A.height; C.width = B.width; // Alloca spazio necessario nella memoria dell'host 25 C.elements = (int*)malloc(C.width * C.height * sizeof(int)); // Riempimento matrici A e B for(int i = 0; i < A.height; i++) for(int j = 0; j < A.width; j++) A.elements[i * A.width + j] = rand() %6); for(int i = 0; i < B.height; i++) for(int j = 0; j < B.width; j++) B.elements[i * B.width + j] = rand() %6; // Lancio funzione di prodotto duration = MatMul(A, B, C); // Stampa a video le matrici for(int i = 0; i < A.height; i++){ for(int j = 0; j < A.width; j++) printf("%f ", A.elements[i * A.width + j]); printf("\n"); } printf("\n"); for(int i = 0; i < B.height; i++){ for(int j = 0; j < B.width; j++) printf("%f ", B.elements[i * B.width + j]); printf("\n"); } printf("\n"); for(int i = 0; i < C.height; i++){ for(int j = 0; j < C.width; j++) printf("%f ", C.elements[i * C.width + j]); printf("\n"); } printf("\n"); printf("\nDurata calcolo: %f millisecondi", duration); return 0; } 4.5.2 Analisi del codice Come detto all'inizio del paragrafo, il codice precedentemente riportato calcola il prodotto di due matrici A e B, salvandone il risultato su una terza matrice C. Le matrici trattate sono variabili il cui tipo Matrix è definito all'interno del codice stesso come una struttura dati contenente le seguenti variabili: 26 • width, un intero che rappresenta il numero di colonne della matrice; • height, un intero che rappresenta il numero di righe della matrice; • stride, un intero che rappresenta il passo con cui scorrere l'array in cui sono salvati gli elementi della matrice stessa per minimizzare il bandwidth; • elements, un puntatore agli elementi contenuti nella matrice che sono salvati in un array monodimensionale e disposti per numero di riga crescente. Il filo conduttore di questo modello di programmazione parallela in CUDA C è quello di implementare il prodotto matriciale delegando il calcolo di ogni elemento di una sottomatrice quadrata Csub della matrice C ad un singolo thread di un blocco. C sub è ottenuta dal prodotto di due matrici rettangolari: una sotto-matrice di A di dimensioni (A.width, BLOCK_SIZE), che ha lo stesso numero di righe di C sub, e una sotto-matrice di B di dimensioni (BLOCK_SIZE, B.height), che ha lo stesso numero di colonne di Csub. Per adattarsi alle dimensioni del blocco di thread del device, le due sotto-matrici vengono suddivise in tante matrici quadrate di dimensione BLOCK_SIZE quante necessario affinché non ci siano elementi in comune tra loro e Csub viene calcolata come la somma dei prodotti delle matrici quadrate costruite. Ognuno di questi prodotti è ottenuto caricando le matrici quadrate utilizzate dalla memoria globale a quella condivisa, utilizzando ogni thread per calcolare un singolo elemento della matrice prodotto. Ciascun thread somma il proprio risultato a quello degli altri e lo salva in una variabile di appoggio comune (Cvalue) che contiene l'elemento da calcolare. Al termine dell'esecuzione di tutti i thread, il risultato definitivo viene scritto in memoria globale nella posizione che rappresenta all'interno della matrice Csub. Il codice è ottimizzato per l'utilizzo della memoria condivisa da parte di host e device, al fine di non dover accedere in memoria un numero eccessivo di volte rispetto a quante sono realmente necessarie. Idealmente, il contesto migliore su cui operare sarebbe quello di accedere una sola volta in memoria, avendo precedentemente caricato entrambe le intere matrici A e B necessarie per il calcolo. Se, però, operassimo su matrici di dimensioni cospicue, questo metodo comporterebbe un calo di efficienza nell'uso della shared memory dato che quest'ultima è di grandezza limitata e potrebbe non riuscire ad ospitare la 27 totalità dei dati. Il codice mostrato precedentemente implementa una modalità di sfruttamento di questa risorsa che rappresenta un giusto compromesso tra praticità d'uso e gestione dello spazio disponibile: attraverso la suddivisione in blocchi, infatti, si sfrutta la velocità di accesso alla memoria condivisa e si ottimizza l'uso della memoria globale, dal momento che la matrice A viene letta esclusivamente (B.width / BLOCK_SIZE) volte e la matrice B solo (A.height / BLOCK_SIZE) volte. L'utilizzo della shared memory permette di minimizzare il bandwidth, sfruttando anche la tecnica di coalescing della memoria globale attraverso un accesso regolato dalla variabile membro stride della struttura dati Matrix. L'applicazione di questa tecnica per il trasferimento di più dati dalla memoria globale nel numero minimo di transazioni permette di sfruttare una memoria, quale è quella condivisa, con un bandwidth molto alto, una latenza molto bassa e che non soffre la gestione di vettori multidimensionali. Gli elementi delle sotto-matrici, infatti, sono prelevati dalla memoria globale, nella quale risiedono come array monodimensionali ordinati per righe, e immagazzinati nella shared memory, dove tutti i thread in esecuzione possono estrapolarne i dati necessari alle proprie operazioni con trasferimenti nettamente più veloci. Figura 4.4: prodotto matriciale con memoria condivisa 28 4.5.3 Esecuzione su Nsight Non resta che implementare il listato precedentemente riportato all'interno di Nsight, l'ambiente di programmazione incluso nel CUDA Toolkit che permette di interagire con la piattaforma CUDA. Per l'esecuzione di questo codice si è sfruttata una scheda video NVIDIA GeForce GT 330M, dotata di architettura Tesla 2.0, con compute capability 1.2 montata su una macchina fornita di sistema operativo Linux Ubuntu 14.04 LTS a 64 bit. Per interagire con la GPU è stato scaricato ed installato il CUDA Toolkit versione 6.5, release massima per l'architettura a disposizione, che include il compilatore nvcc release 5.5 e l'estensione Nsight per l'ambiente di sviluppo Eclipse. L'output ottenuto ha evidenziato che l'esecuzione della sola porzione di codice che include la funzione kernel, la quale rappresenta il fulcro del calcolo parallelo adottato nell'esempio, ha impiegato circa 6 millisecondi. Figura 4.5: output dell'esecuzione di provaCUDA.cu 4.5.4 Profiling CUDA mette a disposizione il software NVIDIA Visual Profiler (nvvp) che fornisce gli strumenti di profiling per visualizzare ed analizzare le performance dell'applicazione, senza distinzione riguardo il linguaggio di programmazione adottato. È possibile utilizzare anche un applicativo, incluso sempre nel toolkit, per l'analisi direttamente da terminale 29 (nvprof), previo precedente spostamento all'interno della directory contenente il binario dell'applicazione da analizzare. Sebbene l'esempio riportato sia abbastanza basilare e presenti una sola funzione kernel che comporta un utilizzo della GPU per meno del 20% del tempo complessivo di esecuzione del programma, è possibile notare alcune ottimizzazioni nel codice evidenti dai dati estrapolati attraverso il profiler. Lanciando l'applicazione da terminale attraverso la riga di comando nvprof ./myApp si può ottenere un breve riepilogo delle funzioni kernel e delle operazioni sulla memoria del device. Figura 4.6: nvprof ./provaCUDA Una vista più dettagliata sull'esecuzione del kernel e sulle operazioni in memoria possono essere reperite lanciando la riga di comando nvprof --print-gpu-trace ./myApp. Figura 4.7: nvprof --print-gpu-trace ./provaCUDA I dati più interessanti e significativi nella fase di profiling dell'applicazione sono sicuramente gli events e le metrics. Un event è un'attività, un'azione o un'occorrenza numerabile sul device che corrisponde ad un singolo valore accumulato durante l'esecuzione del kernel. Una metric è una caratteristica dell'applicazione che è calcolata a partire da uno o più valori di events del kernel. Per tracciare events e metrics relative ad una determinata applicazione possiamo lanciare da terminare i comandi --events all ./myApp e nvprof --metrics all ./myApp. 30 nvprof Figura 4.8: nvprof --events all ./provaCUDA Gli events più significativi sono quelli legati alle operazioni di lettura/scrittura in memoria globale. Dalla figura sovrastante è desumibile, attraverso l'osservazione dei valori relativi al gld_incoherent e gst_incoherent che stabiliscono il numero di operazioni rispettivamente di load e store in global memory e che risultano in questo caso nulli, che il kernel implementato nell'applicazione di esempio assicura il coalescing della totalità delle operazioni in memoria. Questo è sicuramente un vantaggio ed un dato indicativo della bontà del codice parallelo implementato, permettendo di ottimizzare l'uso delle risorse che la GPU offre. Figura 4.9: nvprof --metrics all ./provaCUDA Per quanto riguarda le metrics ottenute dall'esecuzione del kernel, si può definire più che soddisfacente il throughput raggiunto per le operazioni in memoria in relazione al device a disposizione. Notevole è sicuramente la percentuale di raggiunta: tutti i salti all'interno del codice sono non divergenti. 31 branch_efficiency 4.5.5 Confronto con codice sequenziale di funzionalità equivalente Ma quanto è remunerativa l'applicazione di un modello di programmazione parallelo rispetto al tradizionale modello sequenziale? Ovviamente non può essere data una risposta di carattere generale, poiché i vantaggi dell'esecuzione parallela rispetto a quella sequenziale non sono assoluti. È possibile, per esempio, valutare se la scelta di far intervenire la GPU nell'esecuzione del programma riportato nel paragrafo precedente, assegnandole mansioni di calcolo parallelo, sia stata una scelta efficiente. L'indice più attendibile e sicuramente immediatamente più indicativo è quello relativo al tempo di esecuzione del programma: se, eseguendo il codice parallelo e quello sequenziale in successione, si ottiene un tempo di esecuzione minore per l'implementazione con CUDA, allora si può certamente identificare la scelta del modello parallelo come la più corretta per tale applicazione, almeno dal punto di vista temporale. Un esempio di codice sequenziale in C da me appositamente implementato per effettuare il confronto può essere il seguente (matrixMul.c): #include <stdio.h> #include <stdlib.h> #include <time.h> int main() { srand(time(NULL)); int a1, a2, b1, b2; int** A; int** B; int** C; int i, j, k; float TempoInMillisecondi; double TempoInSecondi; clock_t c_start, c_end; // a1 // a2 // b1 // b2 Numero = 80; Numero = 144; Numero = a2; Numero = 96; di righe di A di colonne di A di righe di B di colonne di B // Alloca spazio necessario nella memoria A = (int**)malloc(a1 * sizeof(int*)); 32 for(i = 0; i < a1; i++) A[i] = (int*)malloc(a2 * sizeof(int)); B = (int**)malloc(b1 * sizeof(int*)); for(i = 0; i < b1; i++) B[i] = (int*)malloc(b2 * sizeof(int)); C = (int**)malloc(a1 * sizeof(int*)); for(i = 0; i < a1; i++) C[i] = (int*)malloc(b2 * sizeof(int)); // Riempimento matrici A e B for(i = 0; i < a1; i++) for(j = 0; j < a2; j++) A[i][j] = rand() %6; for(i = 0; i < b1; i++) for(j = 0; j < b2; j++) B[i][j] = rand() %6; c_start = clock(); // Calcolo prodotto matriciale for(i = 0; i < a1; i++){ for(j = 0; j < b2; j++){ C[i][j] = 0; for(k = 0; k < a2; k++){ C[i][j] += A[i][k] * B[k][j]; } } } c_end = clock(); // Stampa a video le matrici for(i = 0; i < a1; i++){ for(j = 0; j < a2; j++) printf("%d ", A[i][j]); printf("\n"); } printf("\n"); for(i = 0; i < b1; i++){ for(j = 0; j < b2; j++) printf("%d ", B[i][j]); printf("\n"); } printf("\n"); for(i = 0; i < a1; i++){ for(j = 0; j < b2; j++) printf("%d ", C[i][j]); 33 printf("\n"); } printf("\n"); TempoInSecondi = (c_end - c_start) / (double) CLOCKS_PER_SEC; TempoInMillisecondi = TempoInSecondi * 1000; printf("\nDurata calcolo: %f millisecondi\n", TempoInMillisecondi); // De-allocazione delle matrici for(i = 0; i < a1; i++) free(C[i]); free(C); for(i = 0; i < b1; i++) free(B[i]); free(B); for(i = 0; i < a1; i++) free(A[i]); free(A); return 0; } Dall'output riportato nella figura sottostante è facile verificare che il tempo di esecuzione della porzione di codice sequenziale relativa al solo calcolo del prodotto matriciale ha impiegato circa 16 millisecondi di tempo, ossia più del doppio del tempo di esecuzione relativo alla stessa funzionalità implementata attraverso il modello parallelo precedente. Figura 4.10: output dell'esecuzione di matrixMul.c in Nsight 34 Conclusioni Così come la programmazione orientata agli oggetti ha fatto con il metodo non strutturale in voga fino agli anni '80, si può senza dubbio affermare che la programmazione parallela sta rivoluzionando il modo di concepire e pensare le applicazioni. Questo processo innovativo, a mio avviso, è di portata maggiore rispetto alle novità annunciate come rivoluzionarie per il settore informatico negli anni passati, dato che la programmazione parallela apre ad innumerevoli nuove opportunità di ricerca nei più svariati ambiti di interesse, non esclusivamente scientifici. Il GPU-Computing, in particolare, è il fulcro di svariati progetti che hanno segnato un deciso progresso nei rispettivi settori: dal progetto COSMO che studia e prevede i cambiamenti climatici globali al progetto AMBER14 che simula i movimenti molecolari delle biomolecole, dall'applicazione Smilart Platform che riconosce in tempo reale i volti delle persone in qualsiasi scenario a quella PowerGrid che applica la trasformata discreta di Fourier nell'ambito del medical imaging. Inoltre, se all'inizio l'approccio parallelo veniva accantonato nei programmi a causa della gestione troppo elaborata e dettagliata degli strumenti necessari a renderlo efficiente, ora sta prendendo sempre più piede tra gli addetti ai lavori grazie ai notevoli sviluppi architetturali dei processori, di cui si è trattato approfonditamente nel presente elaborato. In questo senso, la piattaforma CUDA rappresenta il primo esempio di applicazione del modello parallelo a tutti gli aspetti della computazione, dall'architettura del processore all'esecuzione del codice, senza impegnare il programmatore con complesse operazioni di sincronizzazione o di gestione della memoria. La parte nevralgica e, dal mio punto di vista, più interessante e coinvolgente di questo lavoro di tesi è stata quella di analizzare in prima persona le grandi potenzialità di applicazione di CUDA, permettendomi di allargare le mie conoscenze grazie ad una prima completa esperienza di programmazione parallela. Partendo da un codice di esempio della CUDA C Programming Guide, ho sviluppato ed analizzato un'intera applicazione che racchiudesse tutte le caratteristiche per cui la piattaforma CUDA è considerata altamente 35 innovativa. I vantaggi dell'esecuzione parallela si sono rivelati palesemente evidenti nel confronto con il rispettivo modello sequenziale riportato nell'ultimo capitolo di questo elaborato. L'esecuzione del primo codice, infatti, ha richiesto meno della metà del tempo di esecuzione delle stesse funzionalità eseguite in maniera sequenziale dal secondo codice. Inoltre, bisogna ricordare che i benefici della programmazione parallela, sia in termini di tempo di esecuzione che di sfruttamento delle risorse messe a disposizione dall'architettura, sono tanto maggiori quanto è più grande la mole di dati indipendenti da trattare. Alla luce della trattazione complessiva emersa da questa tesi, la piattaforma CUDA e i modelli di programmazione per il calcolo parallelo in generale possono essere considerati, a ragion veduta, il presente ed il futuro della tecnologia applicata ai sistemi informatici di tutto il mondo, dato che il progresso tecnologico richiede sempre maggiore capacità computazionale in tempo reale e sempre maggiore velocità di esecuzione in tutti gli ambiti di applicazione. 36 Sviluppi Futuri Nonostante le tante innovazioni già operative ed analizzate all'interno di questo elaborato, i modelli di programmazione per il calcolo parallelo sono destinati a svilupparsi e a sviluppare il settore informatico in maniera ancora più decisa e rivoluzionaria già nel prossimo futuro. In particolare, la NVIDIA Corporation sta introducendo nuove migliorie alla propria piattaforma CUDA con il lancio di nuove generazioni di supporti video. Dopo la Tesla, infatti, l'ultima novità architetturale dell'azienda statunitense, che porta il nome del fisico italiano Fermi, presenta migliorie sotto l'aspetto delle operazioni in virgola mobile a doppia precisione, del supporto ECC, della shared memory e della velocità dei context switch e delle operazioni atomiche. Tutto questo grazie a 512 CUDA core divisi in 16 SM, porzioni di memorie condivise configurabili, un livello di cache aggiuntivo e un doppio warp scheduler. Il supporto Error Correcting Code (ECC) assicura, inoltre, l'integrità dei dati in memoria, e risulta utile soprattutto durante le esecuzioni di sistemi che trattano dati sensibili, come in ambito medico. La tecnologia ECC rileva e corregge errori su singoli bit dovuti a naturali radiazioni accidentali prima che danneggino il sistema e procurino errori indesiderati e indelebili. La sempre maggiore affidabilità ed efficienza dei sistemi paralleli consente di segnalare questi modelli come i più avveniristici ed interessanti per il futuro del settore informatico. Figura 19: evoluzione dell'architettura di una GPU dal 2006 ad oggi 37 Bibliografia [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [11] M. Danelutto, Programmazione parallela: evoluzione e nuove sfide, Mondo Digitale, Aprile 2015 NVIDIA Corporation, CUDA C Programming Guide, PG-02829-001_v7.5, Settembre 2015 T. G. Mattson, B. A. Sanders, B. L. Massingill, Patterns for parallel programming, Addison Wesley, 2013 NVIDIA Corporation, http://www.nvidia.it/object/gpu-computing-it.html, 7/6/2016 NVIDIA Corporation, http://www.nvidia.it/content/EMEAI/images/tesla/tesla-servergpus/nvidia-gpu-application-catalog-eu.pdf, 10/6/2016 NVIDIA Corporation, http://www.nvidia.it/content/PDF/fermi_white_papers/NVIDIA_Fermi_Compute_Architecture_ Whitepaper.pdf, 10/6/2016 NVIDIA Corporation, http://www.nvidia.it/object/cuda-parallel-computing-it.html, 9/6/2016 NVIDIA Corporation, https://devblogs.nvidia.com/parallelforall/how-access-global-memoryefficiently-cuda-c-kernels/, 13/6/2016 NVIDIA GeForce GT330M, http://www.geforce.com/hardware/notebook-gpus/geforce-gt330m/specifications, 13/6/2016 NVIDIA CUDA Toolkit 6.5, http://developer.download.nvidia.com/compute/cuda/6_5/rel/docs/CUDA_Toolkit_Release_Not es.pdf, 13/6/2016 CUDA Pro Tip: nvprof is Your Handy Universal GPU Profiler, https://devblogs.nvidia.com/parallelforall/cuda-pro-tip-nvprof-your-handy-universal-gpuprofiler/, 18/7/2016