Valutazione delle architetture GPGPU per l`esperimento
Transcript
Valutazione delle architetture GPGPU per l`esperimento
Università degli Studi di Napoli Federico II Facoltà di Scienze MM.FF.NN. Corso di Laurea in Informatica Tesi sperimentale di Laurea Triennale Valutazione delle architetture GPGPU per l'esperimento SuperB Relatori Candidato Prof. Guido Russo Dr. Guglielmo De Nardo Dr. Silvio Pardi Vincenzo Bruscino matr. 566/2763 Anno Accademico 2010-2011 Ai giorni andati ed a quelli che verranno, alla voglia di scoprire, alla musica, ai veri amici ma soprattutto alla mia famiglia e a te... "There is a light that never goes out..." - The Smiths Vincenzo Bruscino 566/2763 Pagina 2 di 101 Indice generale Indice delle illustrazioni............................................................................................5 Indice delle tabelle.....................................................................................................7 Introduzione...............................................................................................................8 1. Il progetto SuperB...............................................................................................10 1.1. L'acceleratore SuperB e le esigenze di calcolo.............................................12 2. La GPU come coprocessore matematico.............................................................16 2.1. Architettura GPU e modello GPGPU...........................................................20 2.2. nVIDIA Tesla S2050 Computing System.....................................................24 3. CUDA C e modello di programmazione..............................................................29 3.1. Interfaccia di programmazione.....................................................................35 3.1.1. Device Memory.....................................................................................35 3.1.2. Funzioni................................................................................................37 3.1.3. Tipi di dato predefiniti...........................................................................40 3.1.4. Operazioni atomiche.............................................................................41 3.1.5. Funzioni di errore..................................................................................41 3.1.6. Eventi....................................................................................................42 3.1.7. Sistema Multi-Device............................................................................43 3.2. Coalescenza..................................................................................................44 4. Analisi e valutazioni dell'ambiente di calcolo parallelo.......................................48 4.1. Overhead delle funzioni basilari...................................................................48 4.1.1. Test di performance cudaMalloc( ).......................................................49 4.1.2. Test di performance cudaMemcpy( ).....................................................51 4.1.3. Test di performance kernel function......................................................54 Vincenzo Bruscino 566/2763 Pagina 3 di 101 4.1.4. Bandwidth.............................................................................................56 4.2. Tuning dell'ambiente di calcolo parallelo.....................................................58 5. Un algoritmo parallelo per la ricostruzione dei decadimenti del mesone B........60 5.1. Strategia sequenziale....................................................................................62 5.2. Strategia parallela.........................................................................................64 5.3. Le diverse tipologie di testing......................................................................67 5.3.1. Test dell'allocazione di memoria...........................................................69 5.3.2. Test della ricostruzione dei π0..............................................................70 5.3.3. Test della computazione totale degli algoritmi......................................72 Conclusioni.............................................................................................................. 75 Appendice................................................................................................................77 A.1. Installazione e verifica del framework CUDA su sistemi Linux..................77 A.2. Il compilatore nvcc......................................................................................81 A.3. Il CUDA GPU Occupancy Calculator.........................................................84 A.4. Codice.........................................................................................................88 A.4.1. Sorgente sequenziale “se.cu”................................................................88 A.4.2. Sorgente parallelo “par.cu”..................................................................91 A.4.3. Sorgente “testMalloc.cu”......................................................................97 A.4.4. Sorgente “testCpy.cu”..........................................................................98 A.4.5. Sorgente “testKernel.cu”......................................................................99 Bibliografia............................................................................................................101 Vincenzo Bruscino 566/2763 Pagina 4 di 101 Indice delle illustrazioni Illustrazione 1: L’acceleratore SuperB....................................................................10 Illustrazione 2: Area destinata ad ospitare l’acceleratore SuperB............................13 Illustrazione 3: Differenze strutturali tra CPU e GPU.............................................16 Illustrazione 4: Operazioni floating-point al secondo tra CPU e GPU.....................17 Illustrazione 5: Memory bandwidth tra CPU e GPU...............................................18 Illustrazione 6: Architettura nVIDIA Tesla.............................................................20 Illustrazione 7: Gerarchia delle memorie di una GPU.............................................21 Illustrazione 8: 1U system rack Tesla S2050...........................................................23 Illustrazione 9: Architettura del sistema Tesla S2050..............................................25 Illustrazione 10: Connessione ad un singolo host....................................................26 Illustrazione 11: Connessione a due sistemi host.....................................................26 Illustrazione 12: Cavo PCI Express.........................................................................27 Illustrazione 13: Scalabilità automatica...................................................................29 Illustrazione 14: Schema generale di un programma CUDA C...............................30 Illustrazione 15: Gerarchia dei threads....................................................................32 Illustrazione 16: Configurazione di esecuzione di una griglia 2x2 con blocchi da 4x2x2=16 threads....................................................................................................37 Illustrazione 17: Esempi di nessun conflitto nella shared memory..........................44 Illustrazione 18: Esempi di bank conflicts nella shared memory, rispettivamente 1 e 7 cicli di clock in più richiesti..................................................................................45 Illustrazione 19: Grafico dei tempi di esecuzione della funzione cudaMalloc(...).. .50 Illustrazione 20: Grafico dei tempi di computazione della funzione cudaMemcpy(...)......................................................................................................52 Illustrazione 21: Grafico dei tempi di una kernel function al variare del numero dei thread.......................................................................................................................54 Vincenzo Bruscino 566/2763 Pagina 5 di 101 Illustrazione 22: Capacità di trasferimento dati CPU-GPU......................................56 Illustrazione 23: I quattro stadi della ricostruzione completa del mesone B............59 Illustrazione 24: Parte del primo stadio della ricostruzione completa del mesone B. ................................................................................................................................. 60 Illustrazione 25: 1U server rack PowerEdge R510..................................................66 Illustrazione 26: Grafico dei tempi di allocazione per le due strategie implementative........................................................................................................68 Illustrazione 27: Grafico dei tempi di esecuzione della funzione createPI0(...).......70 Illustrazione 28: Tempi di esecuzione totali degli algoritmi sequenziale e parallelo. ................................................................................................................................. 72 Illustrazione 29: Risultati validi per il programma deviceQuery.............................77 Illustrazione 30: Risultati validi per il programma bandwidthTest..........................78 Illustrazione 31: Le varie fasi di compilazione mediante nvcc................................79 Illustrazione 32: Sfruttamento dei multiprocessori al variare della grandezza dei blocchi.....................................................................................................................83 Illustrazione 33: Impatto al variare della shared memory per blocco.......................84 Vincenzo Bruscino 566/2763 Pagina 6 di 101 Indice delle tabelle Tabella 1: Test di lancio della funzione cudaMalloc(...)..........................................49 Tabella 2: Test di esecuzione della funzione cudaMemcpy(...)...............................51 Tabella 3: capacità di trasferimento dati CPU-GPU................................................55 Tabella 4: Tempi di allocazione memoria al variare del numero di fotoni in input..67 Tabella 5: Tempi di esecuzione della funzione createPI0(...) in millisecondi..........69 Tabella 6: Tempi di esecuzione dei due algoritmi in millisecondi...........................71 Tabella 7: Valori di occupazione della GPU............................................................82 Tabella 8: Limiti fisici per una GPU con Compute Capability 2.0..........................82 Tabella 9: Risorse allocabili per una GPU con Compute Capability 2.0.................83 Vincenzo Bruscino 566/2763 Pagina 7 di 101 Introduzione Il presente lavoro di tesi si propone di illustrare le attività di tirocinio svolte presso il data center SCoPE dell’Università degli Studi di Napoli “Federico II”, il cui obbiettivo principale verte sull'uso e la valutazione delle architetture GPGPU (General Purpose Graphic Processing Unit) per il calcolo scientifico ad alte prestazioni. Più precisamente, lo scopo della tesi è quello di verificare se l'utilizzo di questi nuovi tipi di hardware possa portare o meno ottimizzazioni nelle performance di calcolo per quanto concerne l'esperimento SuperB, un progetto che interessa lo studio degli eventi naturali quali materia ed antimateria così com’erano 14 miliardi di anni fa, ossia dopo il Big Bang. Nelle pagine che verranno, mostreremo come utilizzare la potenza di calcolo delle GPU per scopi differenti dalla grafica virtuale, il tutto mediante l'impiego di una nuova tecnologia sviluppata da nVIDIA per tali scopi, ossia l'architettura CUDA (Compute Unified Device Architecture). La tesi, quindi, è un progetto pilota per verificare l'efficacia del GPGPU computing per il calcolo e l'elaborazione dei dati che il futuro acceleratore di particelle SuperB ricaverà dalle collisioni tra elettroni e positroni. Va sottolineato che attualmente esistono già applicazioni che, ricevendo in input grosse moli di dati da acceleratori quali BaBar e LHC, lavorano, ad esempio, al processo di ricostruzione dei decadimenti delle particelle, in particolar modo a quello del mesone B. Tali software devono essere di altissima precisione, quindi devono valutare tutte le possibili combinazioni di decadimento avvenute; questo implica una grande “ripetitività” delle operazioni da svolgere su un numero molto grande di dati. Sarà quindi altro obbiettivo di tesi valutare se CUDA, grazie alle caratteristiche di massively parallel di cui è predisposta, riuscirà a sfruttare la notevole potenza computazione che offrono le GPU per poter assolvere al meglio (ed ovviamente nel minor tempo possibile) i futuri problemi di calcolo che verranno affrontati nei laboratori del progetto SuperB. Vincenzo Bruscino 566/2763 Pagina 8 di 101 La tesi è composta dai seguenti capitoli: • Capitolo 1: viene esposta una panoramica al progetto SuperB, illustrando brevemente l'entità dell'esperimento, l'acceleratore di particelle e le esigenze di calcolo. • Capitolo 2: si mostra come i chip grafici possano essere adoperati quali coprocessori matematici, si analizza l'architettura GPU confrontandola con quella della CPU e si discute del nuovo modello GPGPU. • Capitolo 3: descrizione tecnica del CUDA C e del modello di programmazione parallelo, analizzando più nel dettaglio i nodi cardine di questo nuovo linguaggio di programmazione. • Capitolo 4: analizzeremo e valuteremo l'ambiente di calcolo parallelo tramite l'utilizzo di alcuni tool implementati, al fine di valutare gli overhead minimi delle funzioni basilari per un applicativo scritto in CUDA C. • Capitolo 5: illustreremo l'algoritmo utilizzato per valutare i miglioramenti ricavati dall'utilizzo della GPU quale coprocessore matematico, mostreremo dapprima la strategia sequenziale standard arrivando infine all'algoritmo riscritto per girare sul nuovo framework CUDA. Infine verranno mostrati tutti i casi di test effettuati su entrambi i sorgenti. • Appendice: varie informazioni risultate utili al completamento del lavoro di tesi, partendo da come installare e verificare il toolkit CUDA su ambienti Linux, l'utilizzo del compilatore nvcc, il CUDA Occupancy Calculator ed infine tutti i codici implementati. Vincenzo Bruscino 566/2763 Pagina 9 di 101 1. Il progetto SuperB Il progetto SuperB consiste in una collaborazione internazionale che ha come obiettivo la costruzione di un nuovo acceleratore di particelle che verrà realizzato nell’area dell’Università degli Studi di Roma di Tor Vergata. L’esperimento vero e proprio interessa lo studio degli eventi naturali e, grazie ai principi della fisica quantistica, si potranno vedere materia ed antimateria così com’erano 14 miliardi di anni fa, ossia dopo il Big Bang. L’esperimento SuperB quindi produrrà una mole enorme di dati che servirà a far comprendere meglio l’universo primordiale. La realizzazione di tale programma è stata promossa dall’INFN ed è diventata una dei “progetti bandiera” dell’Italia, con un finanziamento diretto da parte del Ministero dell’Istruzione, Università e Ricerca nell’ambito di un impiego pluriennale. Attualmente vede la partecipazione di oltre 250 fisici ed ingegneri di 9 nazioni impegnati in vari compiti: • Progettazione e realizzazione dell’acceleratore; • Realizzazione dell’apparato sperimentale; • Acquisizione ed elaborazione dei dati prodotti dalle collisioni. L’ultimo punto è di particolare rilevanza visto che, tramite collisioni di elettroni e positroni, verrà prodotto un gran numero di particelle pesanti, contenenti quark di diverso sapore. Il programma scientifico di SuperB si focalizza appunto sulla Nuova Fisica e, nella fattispecie, sullo studio della Fisica del Sapore. La Fisica del Sapore è quella branca della fisica che studia il sapore, ossia un numero quantico delle particelle elementari correlato alle loro interazioni deboli, che si da ai quark per poterne scrivere un modello della realtà (attualmente è in vigore il Modello Standard) che sia il più simmetrico possibile. Vincenzo Bruscino 566/2763 Pagina 10 di 101 Il progetto SuperB sarà basato su idee sviluppate e sperimentate dalla divisione acceleratori dei Laboratori Nazionali di Frascati dell’INFN. L’acceleratore sarà in grado di moltiplicare di 100 volte il numero delle collisioni prodotte rispetto al limite attuale, facendo in modo di studiare processi rari di decadimento delle particelle e di evidenziare effetti non previsti dalle odierne teorie. L’esperimento fornirà misure complementari a quelle che saranno fatte al CERN con LHC (Large Hadron Collider) ed, in futuro, con ILC (International Linear Collider). LHC è anch’esso un acceleratore di particelle ed è utilizzato per ricerche sperimentali nel campo della fisica delle particelle; ILC invece è ancora in fase di realizzazione ed il suo lavoro sarà complementare a quello dell’LHC. Illustrazione 1: L’acceleratore SuperB. Gli studi che verranno svolti potrebbero quindi portare alla scoperta di nuovi tipi di interazione tra particelle, nuove strutture spazio-temporali e nuovi stati della materia. La complementazione tra LHC e SuperB può essere vista nel seguente modo: se in natura si verifica la teoria della Nuova Fisica chiamata “Supersimmetria”, allora LHC produrrà direttamente le particelle supersimmetriche fino ad una massa Vincenzo Bruscino 566/2763 Pagina 11 di 101 connessa direttamente alla massima energia raggiungibile con l’acceleratore; d’altro canto SuperB valuterà anche i contributi virtuali di tali particelle andando a sfruttare le fluttuazioni quantistiche permesse dal principio di indeterminazione. Così facendo, la sensibilità di SuperB sarà molto maggiore dell’energia raggiungibile con LHC. La tecnologia di accelerazione per far collidere i “nano-beams” di elettroni (materia) ed anti-elettroni (anti-materia) rappresenta un’evoluzione nelle tecniche di accelerazione che permetteranno di migliorare il successore di LHC, il succitato ILC, che sarà basato sulla stessa tipologia di annichilazione materia / anti-materia di SuperB. Molti sono gli ambiti di applicazione e di interesse di tali studi: i fasci dell’acceleratore e la tecnica dei nano-beams possono essere utilizzati per ottenere immagini tridimensionali dalla risoluzione dell’ordine della scala atomica. Tale applicazione è di estremo interesse in molti campi di ricerca, quali la biomedicina (e gli altri rami delle biotecnologie), la scienza dei materiali, le tecniche avanzate di simulazione di processi complessi, la metrologia nanometrica. 1.1. L'acceleratore SuperB e le esigenze di calcolo La fase di progettazione e l’attività scientifica dell’esperimento SuperB richiedono una grande quantità di calcolo e di storage dei dati, sia per la parte simulativa che per quella progettuale, per definire i parametri dell’apparato sperimentale e per memorizzare tutte le informazioni relative agli eventi e all’analisi fisica dei risultati derivati dalle collisioni. Il progetto SuperB ha come punto di partenza i successi raggiunti dagli esperimenti BABAR negli Stati Uniti e Belle in Giappone, che hanno lavorato su delle flavor Vincenzo Bruscino 566/2763 Pagina 12 di 101 factory con parametro di luminosità pari a L = 1034 cm‐2s‐1. Il nuovo acceleratore si spingerà ben 100 volte al di sopra, ad una luminosità di L = 1036 cm‐2s‐1, con una mole di dati paragonabile a quella prodotta dagli esperimenti ATLAS e CMS al CERN di Ginevra. Oggi le stime prevedono che, per ogni anno di attività, occorreranno risorse computazionali due volte superiori ai requisiti degli attuali esperimenti che si svolgono al CERN. Si parla di una mole di risorse consistente: • 50 PB annui per il data storage; • 1700 KHep-Spec06 di potenza computazionale. Con l’ultimo punto succitato si intende un lavoro di calcolo superiore di circa 100.000 volte quello svolto dagli attuali CPU-core per svolgere attività quali la ricostruzione di eventi, il data skimming, la simulazione e l’analisi fisica. Il sito geografico che ospiterà il SuperB collider è Tor Vergata, presso il campus universitario di Roma II. La scelta è ricaduta su questa location per varie ragioni: • Distanza di appena 3 Km dai laboratori LNF di Frascati; • Enorme spazio libero a disposizione, privo di reperti archeologici; • Luogo privo di vibrazioni rilevanti, neanche la vicina autostrada A1 Milano – Napoli influisce sul rilevamento dei dati. Vincenzo Bruscino 566/2763 Pagina 13 di 101 Illustrazione 2: Area destinata ad ospitare l’acceleratore SuperB. Lo schema del SuperB è simile a quello di altri acceleratori (o collisori, poiché fanno continuamente scontrare particelle elementari per studiarne le loro proprietà) per la ricerca. La componentistica principale è caratterizzata da: • Un iniettore lineare (LINAC) dove vengono prodotti elettroni e positroni, nell’ordine dei cinquanta miliardi al secondo; • Un accumulatore; • Due anelli. Il LINAC manda continuamente pacchetti di elettroni e positroni nell’accumulatore dove, dopo milioni di giri, vengono estratti ed inviati ai due anelli sovrapposti per farli collidere. Nel punto esatto dove avviene la collisione si trova un rilevatore che Vincenzo Bruscino 566/2763 Pagina 14 di 101 costantemente acquisisce e analizza, con potenti calcolatori, gli eventi prodotti durante l’interazione. Grazie alla tecnica del “Crab Waist”, sviluppata presso i Laboratori Nazionali di Frascati, sarà possibile “comprimere” (nel punto in cui collidono) elettroni e positroni a delle dimensioni mai raggiunte prima, circa 36 milionesimi di millimetro. Le caratteristiche tecniche e le prestazioni del progetto SuperB sono il massimo che l’uomo può pensare e progettare al giorno d’oggi e quindi rappresenta uno dei fiori all’occhiello della fisica moderna. Vincenzo Bruscino 566/2763 Pagina 15 di 101 2. La GPU come coprocessore matematico Nel capitolo precedente è stato riassunto il progetto SuperB, la progettazione dell’acceleratore stesso e le esigenze di calcolo dei vari esperimenti che verranno svolti. Proprio la grossa mole di dati che scaturirà dalle sperimentazioni ci ha fatto affermare che, per le varie attività di ricostruzione eventi, data skimming, simulazione ed analisi fisica, occorrerà un lavoro di calcolo dell’ordine di circa 100.000 volte superiore quello degli attuali CPU-core. L’intera collaborazione facente parte dell’esperimento SuperB mette a disposizione un potenziale di oltre 10.000 CPU su base multi-core, il tutto interconnesso infiniband a 10Gbit/s in grado di ospitare sia applicazioni high throughput che applicazioni con codici paralleli. Nel data center SCoPE presso l’Università degli Studi di Napoli “Federico II”, ad esempio, si trovano armadietti rack di espansione cablati, raffreddati a liquido e già pronti per essere messi in funzione. Il tutto è controllato da sistemi di altissima affidabilità e di monitoraggio tenuti sotto osservazione presso la Control Room. Come si può intuire, però, il carico di lavoro è di una grandezza tale da dover far analizzare anche vie alternative per lo svolgersi dei numerosi calcoli computazionali. Negli ultimi anni infatti si sta affiancando al lavoro delle moderne CPU quello delle GPU (Graphics Processing Unit), caratterizzate quest’ultime da una notevole potenza computazionale nell’ambito del massively parallel e da una più accurata precisione nell’ambito dei calcoli con numeri in virgola mobile. Negli ultimi anni sono stati i microprocessori a svolgere l’esecuzione delle istruzioni e delle operazioni aritmetiche che solitamente caratterizzano un comune programma. Tuttavia un primo cambiamento è avvenuto nel 2003 quando, a causa di problematiche quali l’eccessivo consumo d’energia e quello di dissipazione del calore, si è dovuto limitare l’incremento della frequenza di clock e delle attività Vincenzo Bruscino 566/2763 Pagina 16 di 101 svolte in ogni singolo ciclo di clock su di una sola CPU. Da qui si è intrapresa una nuova strada, portando alla nascita delle unità di elaborazione multiple chiamate processor cores. Avendo quindi a che fare con processori multi-core, la “corrente” di programmazione parallela ha cominciato a prendere il sopravvento. Tale tecnica consiste nel far cooperare threads multipli per far completare più velocemente il lavoro di computazione. La differenza principale tra CPU e GPU consiste nella diversa strada che hanno imboccato nella composizione hardware. Ad oggi sono due le tecniche principali per lo sviluppo di microprocessori: • multi-core; • many-core. I multi-core si basano sul mantenimento della velocità dei programmi sequenziali mentre vengono eseguiti su core multipli. Un esempio sono tutti i processori dualcore, quad-core ecc. I many-core si basano sull’esecuzione del throughput, ovvero di un certo numero di operazioni per unità di tempo degli algoritmi paralleli. Un esempio sono tutte le schede video nVIDIA dalla serie GeForce 8 in poi. Illustrazione 3: Differenze strutturali tra CPU e GPU. Vincenzo Bruscino 566/2763 Pagina 17 di 101 Da come si può intuire dall’Illustrazione 3, la struttura di una CPU è ottimizzata più per le applicazioni sequenziali, infatti è dotata di unità aritmetiche e logiche più sofisticate e di una memoria cache molto ampia per ridurre la latenza di accesso alle istruzioni ed ai dati da parte dei programmi. I chip grafici hanno dalla loro una larghezza di banda di memoria molto più ampia, nell’ordine di circa dieci volte maggiore di più chips CPU avviati contemporaneamente. Ad esempio, una ormai datata scheda GeForce serie 8 può trasferire dati da se stessa alla Dynamic Random Access Memory (DRAM) con una velocità di 85 GB/s. I grafici seguenti possono far comprende ancor di più il gap tra le moderne CPU e GPU. Illustrazione 4: Operazioni floating-point al secondo tra CPU e GPU. Vincenzo Bruscino 566/2763 Pagina 18 di 101 Illustrazione 5: Memory bandwidth tra CPU e GPU. Come si può evincere, la potenza computazionale delle GPU sta crescendo in maniera considerevole, dato che sono nate per la grafica, dove migliaia di operazioni vengono eseguite in parallelo senza la necessità di spendere transistor per la gestione della logica di controllo. La maggioranza delle operazioni è, in genere, molto ripetitiva e quindi ciò giustifica questa scelta implementativa dell’hardware di una GPU. Oggigiorno possiamo quindi affermare che nelle moderne CPU, con un numero di core che arriva generalmente ad 8, la maggior parte dei transistor è spesa per la parte della logica di controllo e per la cache, mentre nelle GPU i core sono molto più semplici e quasi tutta l’area è spesa per implementarli. Altra nota dolente per quanto riguarda le CPU è il dover soddisfare contemporaneamente anche le richieste dei processi del sistema operativo e di tutti i dispositivi di I/O, mentre le GPU, avendo pochi vincoli ad esse legati, dispongono di una notevole larghezza di banda di memoria potendo favorire quindi il parallelismo. Vincenzo Bruscino 566/2763 Pagina 19 di 101 Da queste differenze strutturali si può evincere che, se fatte “coesistere”, CPU e GPU possono dar luogo ad una potenza di calcolo non sottovalutabile. Non dobbiamo infine dimenticare che non tutti gli algoritmi possono essere scritti in parallelo, quindi se per alcune problematiche le GPU possono dare un contributo massiccio in termini di velocità di calcolo, per altre le CPU possono svolgere meglio lo stesso compito a differenza dei chip grafici. Per questo la collaborazione CPU-GPU negli ultimi anni sta riscuotendo sempre più successo, non solo in campo ludico come era in passato (videogame, film d’animazione ecc.) ma anche in campo scientifico con l’introduzione della nuova serie di schede Tesla di nVIDIA che svolge calcoli floating point con altissima precisione. Tale collaborazione è l’obiettivo di CUDA (Compute Unified Device Architecture), un nuovo modello computazionale implementato sempre da nVIDIA, di cui parleremo in seguito. 2.1. Architettura GPU e modello GPGPU In questo paragrafo verranno descritti dapprima una tipica architettura GPU e poi, più nello specifico, il modello computazione scelto per il lavoro di tesi. Nella figura seguente possiamo notare l’architettura di una modera GPU (Tesla). Vincenzo Bruscino 566/2763 Pagina 20 di 101 Illustrazione 6: Architettura nVIDIA Tesla. In breve, l’architettura è composta da array di multiprocessori SM (Streaming Multiprocessor) ognuno dei quali è costituito da una serie di SP (Streaming Processor) che formano la logica di controllo e condividono le istruzioni in cache. Una GPU dispone poi di una memoria dedicata, la GDDR (Graphics Double Data Rate), anche indicata col nome di global memory. Si affiancano a questo tipo di memoria altri due tipi rilevanti, la shared memory e la local memory. La shared memory è una memoria condivisa dai threads durante l’esecuzione sul device di un’applicazione. Tale memoria ha una capienza di circa 64 KB (in media) tra le varie schede della nVIDIA e serve a ridurre i trasferimenti di dati dalla memoria globale ai vari SP. La local memory è una memoria privata per ogni thread in esecuzione e serve per gestire i registri e lo stack di esecuzione. Vincenzo Bruscino 566/2763 Pagina 21 di 101 Illustrazione 7: Gerarchia delle memorie di una GPU. Avendo quindi a che fare con questi nuovi tipi di hardware, negli ultimi anni si è andato a formare un nuovo campo di ricerca informatico, noto con il nome di GPGPU (General Purpose computing on Graphics Processing Unit), che ha come scopo principale quello di far girare sui chip grafici applicazioni differenti da quelle con grafica 3D tradizionale. Le GPU, quindi, sono usate anche come acceleratori di calcoli nelle applicazioni scientifiche, specializzate nel far eseguire algoritmi “data parallel” essendo i più adatti per le caratteristiche di questi tipi di hardware: • Possibilità di memorizzare grandi array di dati; • Bassa latenza per operazioni floating point; Vincenzo Bruscino 566/2763 Pagina 22 di 101 • Parallelismo a “grana fine” di tipo SIMD (Single Instruction Multiple Data). Per essere più precisi, il tipo di parallelismo usato dalle GPU è il modello SIMT (Single Instruction Multiple Thread), visto che sono appunto i thread le unità fondamentali che vengono eseguite dai vari SP. Tale modello crea e gestisce i thread in gruppi di 32, chiamati warp; questi gruppi di thread vengono lanciati insieme dalla stessa riga di codice, anche se però possono “gestire” parti dell’algoritmo differenti. L’introduzione del concetto del warp è una scelta implementativa del modello SIMT e non è modificabile da parte del programmatore; tale tecnica, comunque, garantisce una massima efficienza con il minor utilizzo di thread possibile. Negli ultimi anni le principali aziende produttrici di chip grafici si sono specializzate nella realizzazione di componenti hardware dedicati al calcolo scientifico. nVIDIA, con la serie Tesla, ha trovato una soluzione ad hoc con rack che montano più schede video pensate specificatamente per applicazioni GPGPU tramite CUDA. Vincenzo Bruscino 566/2763 Pagina 23 di 101 Illustrazione 8: 1U system rack Tesla S2050. Negli ultimi mesi il data center SCoPE ha messo in funzione due server, entrambi equipaggiati con una nVIDIA Tesla S2050, che verranno utilizzati anch’essi per il progetto SuperB. Il seguente lavoro di tesi è stato svolto su queste due macchine e, in particolare, verte proprio sull’utilizzo del suddetto sistema di GPU. Nel prossimo paragrafo verranno analizzate, più nel dettaglio, le specifiche tecniche e le caratteristiche dell’hardware utilizzato. 2.2. nVIDIA Tesla S2050 Computing System Il sistema di calcolo nVIDIA Tesla S2050 è un sistema a montaggio su rack di un’unità con quattro processori di calcolo nVIDIA Fermi. Questo sistema connette uno o due sistemi host tramite uno o due cavi PCI Express. Una HIC (Host Interface Card) è usata per connettere ogni cavo PCI Express all’host. Le HIC sono Vincenzo Bruscino 566/2763 Pagina 24 di 101 compatibili con entrambi le generazioni di PCI Express, la Gen1 e la Gen2. La Tesla S2050 include 12 GB di memoria GDDR5. Nel dettaglio, le specifiche tecniche di una nVIDIA Fermi sono: • Numero di core: 448; • Processor core clock: 1.15 GHz; • Memory clock: 1.546 GHz; • Interfaccia di memoria: 384 bit. Le specifiche tecniche dell’intero sistema Tesla S2050 sono: • Quattro GPU Fermi; • 12.0 GB di memoria GDDR5, 3.0 GB per ognuna delle 4 GPU. Quando la memoria ECC è attivata, la memoria disponibile è ~10.5 GB. • Consumo di corrente: 900W. La Tesla S2050 può essere collegata ad un singolo sistema host tramite due connessioni PCI Express, oppure a due sistemi host separati tramite una connessione PCI Express per ognuno di essi. Vincenzo Bruscino 566/2763 Pagina 25 di 101 Illustrazione 9: Architettura del sistema Tesla S2050. Ogni switch (ed il corrispettivo cavo PCI Express) connette due delle quattro GPU; se solo un cavo è connesso al sistema, sono usate solo due GPU. Per connettere tutte le GPU ad un singolo host, quest’ultimo deve avere due slot PCI Express avviabili ed essere connesso con due cavi come mostrato dall’Illustrazione 10. Illustrazione 10: Connessione ad un singolo host. Vincenzo Bruscino 566/2763 Pagina 26 di 101 La Tesla S2050 può essere connessa anche a due sistemi host, solo che questi potranno accedere soltanto a due GPU ognuno. Illustrazione 11: Connessione a due sistemi host. Le dimensioni dell’intero chassis sono di 4.34 cm di altezza x 44.26 cm di ampiezza x 72.39 cm di profondità, quindi conformi alle specifiche standard EIA 310E dei rack. La Tesla S2050 usa cavi PCI Express di 50 cm con un raggio di curvatura di 3.87 cm. Vincenzo Bruscino 566/2763 Pagina 27 di 101 Illustrazione 12: Cavo PCI Express. L’intero dispositivo garantisce performance ottimali, quando è operativo, a temperature comprese tra i 10° C e i 35° C, con umidità compresa tra il 10% e l’80% RH. Nel data center SCoPE, i sistemi di raffreddamento (sia a liquido che ad aria) garantiscono sempre queste condizioni e specifiche ambientali. Vincenzo Bruscino 566/2763 Pagina 28 di 101 3. CUDA C e modello di programmazione Per sfruttare in maniera flessibile il nuovo paradigma GPGPU, nVIDIA ha ideato il framework CUDA (Compute Unifed Device Architecture) sfruttabile da gran parte dei suoi chip grafici quali GeForce (dalla serie 8 in poi), Quadro e Tesla. CUDA è utilizzabile attraverso estensioni di linguaggi di programmazione come C, C++ e Fortran; nel lavoro di tesi è stato utilizzato il CUDA C. Negli ultimi anni vari ambiti di ricerca stanno utilizzando CUDA in modo sempre più massiccio, infatti algoritmi paralleli per l’elaborazione dei segnali, simulazioni fisiche, computazione finanziaria e biologica si sposano perfettamente con il paradigma GPGPU che è, appunto, specializzato in elaborazioni intensive ad alta computazione parallela. CUDA quindi è, insieme, un modello architetturale parallelo general purpose, una Application Programming Interface (API) per sfruttare tale architettura e una serie di estensioni al linguaggio C per descrivere algoritmi paralleli in grado di girare sulle GPU che adottano questo modello. Il suo cuore è costituito da tre astrazioni chiave che sono mostrati al programmatore come, appunto, un insieme minimo di estensioni del linguaggio: • Una gerarchia di gruppi di threads; • Memorie condivise; • Barriere di sincronizzazione. Queste astrazioni guidano il programmatore a partizionare il problema in sottoproblemi che possono essere risolti indipendentemente da blocchi di threads in parallelo. Questa decomposizione, allo stesso tempo, garantisce anche la scalabilità automatica, infatti ogni blocco di threads può essere schedulato su ogni tipo di Vincenzo Bruscino 566/2763 Pagina 29 di 101 core, in ogni ordine (concorrentemente o sequenzialmente), così che un programma compilato CUDA può essere eseguito su qualsiasi numero di core e soltanto il sistema a runtime ha bisogno di conoscere il numero fisico effettivo di processori. Quindi è il modello di programmazione scalabile che permette all’architettura CUDA di poter sfruttare varie architetture hardware anche molto differenti tra loro (GeForce e Tesla differiscono anche per centinaia di core). In breve, un programma multithreaded è partizionato in blocchi di threads che eseguono indipendentemente dagli altri, così che una GPU con più core automaticamente eseguirà il programma in meno tempo di una che ne ha meno, come è intuibile dall’Illustrazione 13. Illustrazione 13: Scalabilità automatica. Un programma scritto in CUDA C è composto da parti di codice che vengono processate normalmente dalla CPU (che prende il nome di host) e da altre che invece vengono gestite dalla GPU (chiamata device). Host e device accedono a memorie differenti ed, in genere, la gestione di queste viene trattata dalla CPU. Vincenzo Bruscino 566/2763 Pagina 30 di 101 Il listato dell’host normalmente è composto da tre punti: • Allocazione della memoria del device; • Trasferimento da e per la memoria del device; • Stampa dei risultati. Si può quindi intuire che le sezioni seriali (o poco parallele) stanno all’interno del codice della CPU, mentre quelle parallele massicce nel codice della GPU. Illustrazione 14: Schema generale di un programma CUDA C. La GPU viene vista come un coprocessore per la CPU, ha una propria memoria (la device memory) ed esegue molti threads in parallelo. Proprio quest’ultimi sono la chiave di volta di tutto, infatti differiscono da quelli della CPU per alcune caratteristiche: • Overhead di creazione molto inferiore; • Sono estremamente leggeri; • Per una piena efficienza ne sono richiesti un migliaio sulla GPU. Vincenzo Bruscino 566/2763 Pagina 31 di 101 I threads vengono lanciati sul device da apposite funzioni, chiamate kernel che, quando invocate, sono eseguite N volte in parallelo da N differenti CUDA threads. Un kernel è definito usando la dichiarazione __global__ ed il numero di CUDA threads che la esegue è specificato dalla chiamata di funzione usando la nuova sintassi di esecuzione <<<numBlocchi,numThreadsPerBlocco>>>. Ogni thread che esegue il kernel possiede un unico ID che è accessibile all’interno della funzione lanciata sulla GPU dalla variabile di sistema threadIdx. Questa è un vettore a tre dimensioni di un nuovo tipo, che introduce sempre CUDA C, dim3: ciò serve ad identificare, con più naturalezza, elementi quali vettori, matrici o volumi. Si può accedere alle varie dimensioni aggiungendo a threadIdx il suffisso .x, .y o .z. Questi primi elementi base ci permettono di introdurre il seguente esempio che somma due matrici A e B di dimensione NxN memorizzando il risultato nella matrice C: Possiamo quindi notare le prime differenze dal linguaggio C standard quali, appunto, la definizione del kernel, l’accesso a threadIdx, la definizione del nuovo tipo dim3 e l’invocazione alla funzione che girerà sulla GPU. I kernel sono eseguiti su una griglia composta da blocchi di threads: tali griglie possono essere monodimensionali o bidimensionali, con dimensione massima in Vincenzo Bruscino 566/2763 Pagina 32 di 101 ciascuna dimensione di 65535 blocchi, per un totale quindi di 4294836225 blocchi. Ogni blocco della griglia può avere una struttura monodimensionale, bidimensionale o tridimensionale, con dimensione massima di 1024 threads in totale sulle tre dimensioni, quindi sono accettate configurazioni quali (32,16,2) threads per blocco ma non come (32,32,32) threads per blocco. Illustrazione 15: Gerarchia dei threads. Ogni blocco all’interno della griglia può essere identificato dalla variabile di sistema blockIdx (anch’essa di tipo dim3) mentre con la variabile blockDim si può accedere alla propria dimensione. I threads, all’interno di un blocco, possono cooperare con dati condivisi attraverso la shared memory e, quindi, devono poter sincronizzare i propri accessi. Questo è Vincenzo Bruscino 566/2763 Pagina 33 di 101 possibile grazie alla procedura __syncthreads() che funge da barriera all’interno di un kernel. Una caratteristica importante che va introdotta è quella della Compute Capability di un device: questa è definita da due numeri di revisione, uno maggiore e l'altro minore. Il maggiore identifica il tipo di architettura dei core di una periferica grafica, quindi, ad esempio, due GPU con lo stesso numero di revisione maggiore avranno la stessa architettura dei core. Il numero di revisione maggiore di un device basato sull'architettura Fermi è 2 (quindi anche la Tesla S2050). Il minore, invece, corrisponde ai miglioramenti introdotti per quel tipo di architettura (identificata dal numero di revisione maggiore), possibilmente introducendo nuove caratteristiche. I kernel, oltre ad essere programmati in CUDA C, possono anche essere scritti in set di istruzioni dell'architettura, chiamate PTX. In entrambi i casi, i kernels devono essere compilati in codice binario dal compilatore nvcc, il quale riconosce file sorgenti che possono includere un mix di codice host (che gira in CPU) e codice device (che gira in GPU). L'operazione base del compilatore consiste nel separare il codice del device da quello dell'host e poi: • Compilare il codice del device in un tipo di assembly (il codice PTX appunto); • Modificare il codice dell'host rimpiazzando la sitassi <<<...>>> da chiamate di funzione CUDA C che riconoscono, lanciano ed eseguono il kernel compilato in codice PTX. Tutto il codice che si desidera compilare va memorizzato in file dall'estensione .cu, pena un errore di compilazione. Nvcc accetta anche opzioni di settaggio: tra le tante riscuote maggiore rilevanza -arch sm_xy, con la quale si imposta la compute capability della scheda video in possesso. Un esempio di compilazione sul sistema architetturale Tesla S2050 può essere il seguente: nvcc -arch sm_20 prog.cu -o Eseguibile Vincenzo Bruscino 566/2763 Pagina 34 di 101 dove con -arch sm_20 settiamo la compute capability 2.0 (se omessa nvcc compilerà in modalità 1.0), prog.cu è il sorgente ed Eseguibile è il nostro programma di output. 3.1. Interfaccia di programmazione In questo paragrafo parleremo, più nel dettaglio, di alcune funzioni dell'interfaccia di programmazione del linguaggio CUDA C che occorre conoscere per comprendere appieno il seguente lavoro di tesi. Per iniziare va detto che CUDA C non ha bisogno di esplicite funzioni di inizializzazione visto che ciò avviene alla prima chiamata di una funzione del device. Quando ciò avviene, a runtime si crea un contesto CUDA per ogni GPU presente nel sistema. Questo è il primary context (per ogni device) ed è condiviso fra tutti i threads dell'applicazione; invocando la funzione cudaDeviceReset() si distrugge il contesto primario corrente e, alla prossima chiamata di funzione del device, si andrà a creare un nuovo contesto primario per quella GPU. Analizziamo ora, nello specifico, gli aspetti principali dell'interfaccia CUDA C. 3.1.1. Device Memory Come menzionato in precedenza, il modello di programmazione CUDA assume un sistema composto da un host ed un device, ognuno di essi con la propria memoria dedicata. La funzione principale per allocare memoria lineare è cudaMalloc(). Questa alloca oggetti nella memoria globale accettando due parametri in ingresso: • L'indirizzo del puntatore all'oggetto allocato (void); • La dimensione dell'oggetto allocato. Per rilasciare la memoria allocata in precedenza sul device bisogna richiamare cudaFree(), passandogli come input il puntatore all'oggetto da deallocare. Vincenzo Bruscino 566/2763 Pagina 35 di 101 Esempio: float *A; int size = sizeof(float) * 5; cudaMalloc( (void**)&A, size); cudaFree(A); Il precedente listato altro non fa che allocare un array di float di 5 elementi, indirizzando tale area di memoria con il puntatore A. La chiamata a cudaFree(A) rilascerà poi lo spazio allocato. La memoria lineare può anche essere allocata attraverso cudaMallocPitch() e cudaMalloc3D(). Queste funzioni sono raccomandate per allocazioni di matrici e volumi visto che garantiscono le migliori performance di accesso agli indirizzi di memoria di strutture in due o tre dimensioni. Entrambe le funzioni restituiscono una dimensione di tipo size_t (chiamata pitch) che deve essere usata per gli accessi agli elementi della struttura allocata. Il seguente codice mostra come ciò avviene e come va gestita una matrice allocata con cudaMallocPitch() nel codice del device: Vincenzo Bruscino 566/2763 Pagina 36 di 101 I trasferimenti di dati tra CPU e GPU avvengono attraverso la funzione cudaMemcpy(). Questa invia i dati da e per la memoria globale, richiedendo quattro parametri in ingresso: • Puntatore alla destinazione; • Puntatore alla sorgente; • Numero di bytes trasferiti; • Tipo di trasferimento. Il tipo di trasferimento può, a sua volta, essere una delle seguenti costanti simboliche, in base alla direzione del trasferimento: • cudaMemcpyHostToDevice = Host to Device; • cudaMemcpyDeviceToHost = Device to Host; • cudaMemcpyDeviceToDevice = Device to Device. Il prossimo esempio mostra come trasferire un array di float di dimensione size dapprima dall'host al device e poi viceversa (H è nella memoria host e D è nella memoria device): cudaMemcpy(D, H, size, cudaMemcpyHostToDevice); cudaMemcpy(H, D, size, cudaMemcpyDeviceToHost); 3.1.2. Funzioni CUDA C introduce tre nuovi tipi di funzioni, ognuna con caratteristiche diverse: • __global__; • __host__; • __device__. Le funzioni __global__, come detto in precedenza, sono i kernel richiamati dall'host ed eseguiti sul device; esse devono essere sempre di tipo void. Quelle __host__ e __device__ definiscono le funzioni che devono essere eseguite Vincenzo Bruscino 566/2763 Pagina 37 di 101 rispettivamente sulla CPU e sulla GPU; le __host__ seguono le regole standard delle funzioni del linguaggio C, mentre le __device__ non possono essere ricorsive, non possono avere dichiarazioni di variabili statiche e non possono avere un numero variabile di argomenti in input. Una funzione kernel (quindi di tipo __global__), come abbiamo visto, deve essere eseguita con una configurazione di esecuzione. Una configurazione definisce, quindi, la griglia ed il numero di threads per blocco: Illustrazione 16: Configurazione di esecuzione di una griglia 2x2 con blocchi da 4x2x2=16 threads. L'illustrazione precedente corrisponde al seguente listato: __global__ void KernelFunc(...){ …} int main(...){ dim3 DimGrid(2,2); //Griglia da 4 blocchi dim3 DimBlock(4,2,2); //16 threads per blocco KernelFunc<<<DimGrid,DimBlock>>>(...); …} Il codice appena scritto altro non fa che lanciare il kernel KernelFunc su di una griglia composta da 4 blocchi ognuno contenente 16 threads; questo implica il lancio della funzione 4*16=64 volte. Vincenzo Bruscino 566/2763 Pagina 38 di 101 Nella logica di programmazione del CUDA C, ogni thread, di solito, ha bisogno di accedere ad un differente elemento della struttura dati su cui si sta lavorando. A tal fine, il supporto a runtime, come anticipato in precedenza, mette a disposizione di ogni thread le seguenti strutture dati predefinite (non è necessario dichiararle): • threadIdx.x – threadIdx.y – threadIdx.z, per identificare il thread ID dentro un blocco; • blockIdx.x – blockIdx.y, per identificare il block ID nella griglia; • blockDim.x – blockDim.y – blockDim.z, numero di threads nelle direzioni del blocco; • gridDim.x – gridDim.y, dimensioni della griglia in numero di blocchi. Com'è intuibile, con x si identifica l'asse orizzontale, con y quello verticale e con z quello della profondità. Per determinare l'indice di uno specifico thread all'interno di un kernel, la formuletta base, che in genere viene sempre usata all'interno dei programmi CUDA C, è: TID=threadIdx.x+threadIdx.y*blockDim.x+threadIdx.z*blockDim.y*blockDim.x; Un programma scritto in CUDA C può avere al proprio interno anche più di un kernel; questi vengono eseguiti sequenzialmente tra loro ma, una volta che la CPU lancia un kernel, l'host è libero di fare altre operazioni mentre la GPU lavora, andando quindi a creare un'altra sorta di parallelismo. La chiamata al kernel è, quindi, asincrona ed è possibile far eseguire alla CPU altre parti di codice in parallelo alla GPU. Se tuttavia si vuole mettere il processore in attesa della terminazione di tutte le operazioni in esecuzione sul device, si può usare, dopo la chiamata alla funzione __global__, la primitiva cudaThreadSynchronize() che renderà bloccante la chiamata alla funzione kernel. Vincenzo Bruscino 566/2763 Pagina 39 di 101 3.1.3. Tipi di dato predefiniti In CUDA C sono presenti alcuni tipi di dato la cui gestione ed elaborazione è ottimizzata. Questi possono essere usati sia nel codice GPU che in quello CPU. I data types possono essere di due tipi, scalari e vettoriali; gli scalari sono: • [u]char; • [u]short; • [u]int; • [u]long; • float; • double. Anteponendo il prefisso u davanti al tipo si indica la versione unsigned. Il double è presente dalla versione 3 in poi del Cuda Toolkit. I vector types presenti sono: • [u]char[1..4]; • [u]short[1..4]; • [u]int[1..4]; • [u]long[1..4]; • float[1..4]. Questi sono strutture dati preimpostate con al massimo quattro campi x, y, z e w. Il tipo dim3, introdotto in precedenza, è basato sull'uint3 ed è usato per specificare le dimensioni delle griglie e dei blocchi di threads. Altra differenza dal linguaggio C, per quanto concerne i tipi di dato, è l'introduzione delle Variable Qualifiers. Queste possono essere adoperate all'interno del codice GPU e altro non sono che prefissi di caratterizzazione delle variali. Esse sono: • __device__; • __shared__. Vincenzo Bruscino 566/2763 Pagina 40 di 101 Le variabili dichiarate anteponendo __device__ vengono allocate nella device memory (quindi con alta capacità di memoria, alta latenza e nessuna cache) tramite cudaMalloc(...), sono accessibili da tutti i threads ed hanno come lifetime quella dell'applicazione. Quelle __shared__ invece sono memorizzate nella shared memory (con una bassissima latenza), sono accessibili dai threads dello stesso blocco ed hanno come ciclo di vita quello del kernel di esecuzione. Se le variabili vengono dichiarate senza caratterizzazione, queste vengono memorizzate nei registri dei vari threads. Non va dimenticato che gli array di più di quattro elementi vengono allocati automaticamente nella device memory (per motivi di spazio). 3.1.4. Operazioni atomiche CUDA C permette anche l'utilizzo di operazioni atomiche (anche se però non consigliate dato che il concetto di operazione atomica va contro la filosofia del parallelismo) su interi (signed / unsigned) memorizzati nella global memory. Le più importanti sono add, sub, min, max, and, or, xor, increment, decrement, compare, swap e sono richiamabili dalla funzione atomicXXX(), sostituendo al posto delle XXX l'operazione che si vuole effettuare. Esempi: atomicAdd(), atomicSub(), atomicMin(), ecc. 3.1.5. Funzioni di errore Tutte le chiamate CUDA C, fatta eccezione per i lanci dei kernel, ritornano un codice di errore del tipo cudaError_t. Esiste una funzione che può essere sfruttata nel codice per riportare l'errore all'utente (in CPU) ed è cudaError_t cudaGetLastError(void). Vincenzo Bruscino 566/2763 Pagina 41 di 101 Questa ritorna il codice dell'ultimo errore (anche il “nessun errore” ha un codice) e quest'ultimo può essere usato per ottenere gli errori nell'esecuzione di un kernel. Combinandola con char* cudaGetErrorString(cudaError_t code) si può avere, come ritorno, una stringa di caratteri che descrive l'errore; inserendo tale funzione in un printf, si può fornire all'utente una descrizione più significativa del problema. Esempio: printf(“%s\n”, cudaGetErrorString(cudaGetLastError())). 3.1.6. Eventi CUDA C possiede delle funzioni per il calcolo dei progressi del device, nella fattispecie per il calcolo accurato del tempo di esecuzione dell'applicazione. Ciò avviene tramite la registrazione di eventi in determinati punti del programma con apposite funzioni. Il seguente esempio farà comprendere meglio quanto appena detto: cudaEvent_t start, stop; cudaEventCreate(&start); cudaEventCreate(&stop); cudaEventRecord(start, 0); ...codice di cui si vuole sapere il tempo di esecuzione... cudaEventRecord(stop, 0); cudaEventSynchronize(stop); float time; cudaEventElapsedTime(&time, start, stop); Dapprima vengono creati due eventi del tipo cudaEvent_t, start e stop, che poi vengono creati dall'apposita funzione cudaEventCreate(*cudaEvent_t); cudaEventRecord(cudaEvent_t, int) provvederà a registrare il tempo di inizio e Vincenzo Bruscino 566/2763 Pagina 42 di 101 fine, il tutto sincronizzato dalla chiamata a cudaEventSynchronize(cudaEvent_t). Infine cudaEventElapsedTime(*float, cudaEvent_t, cudaEvent_t) memorizzerà il tempo trascorso tra i due eventi in una variabile di tipo float. 3.1.7. Sistema Multi-Device Un sistema host può avere più di una GPU (è appunto il caso della Tesla S2050). Il seguente codice mostra come enumerare queste periferiche grafiche, mostrando le loro proprietà: La funzione cudaGetDeviceCount(*int) memorizza, in una variabile integer, il numero di device presenti nel sistema; cudaGetDeviceProperties(*cudaDeviceProp, int) preleva le caratteristiche del chip grafico (identificato dalla variabile intera) salvandole nella variabile a struttura di tipo cudaDeviceProp. L'host può settare, in qualunque momento, il device su cui andare ad operare grazie alla chiamata a funzione cudaSetDevice(). Le allocazioni sulla memoria del device, i kernel lanciati, i flussi di dati trasferiti e gli eventi creati saranno eseguiti sulla GPU selezionata; il chip grafico di default è quello 0. Vincenzo Bruscino 566/2763 Pagina 43 di 101 Esempio: 3.2. Coalescenza In CUDA C ci sono tre tecniche per ottimizzare gli accessi alla memoria: • Bank conflicts – ottimizzazione alla shared memory; • Texture memory – ottimizzazione accesso ai dati; • Coalescence – ottimizzazione alla global memory. La coalescenza degli accessi è la più importante delle tre; rendere possibili degli accessi coalescenti in memoria significa riuscire ad accorpare più accessi (letture o scritture) in un'unica transazione del controller di memoria. Devono essere rispettate le seguenti restrizioni: • L'indirizzo iniziale di una regione deve essere multiplo della grandezza della regione; • Il k-esimo thread di un half-warp (la metà di un warp) deve accedere al kesimo elemento di un blocco (letto o scritto). Gli accessi devono quindi essere perfettamente allineati tra i thread ad eccezione di un caso, ossia quando alcuni thread non partecipano alla lettura o alla scrittura in esame. Uno dei modi per rendere coalescenti gli accessi è quello di usare la shared memory come memoria di lavoro: questa è circa cento volte più veloce della global memory e quindi conviene sfruttarla per ridurre l'accesso alla global memory. La shared memory è divisa in banchi, ognuno dei quali può servire un thread per ciclo Vincenzo Bruscino 566/2763 Pagina 44 di 101 di clock. Un accesso simultaneo a tale memoria avviene quando i threads accedono a banchi differenti; quando invece accedono allo stesso banco si causa un bank conflict e l'accesso avviene quindi in modo serializzato. Illustrazione 17: Esempi di nessun conflitto nella shared memory. Vincenzo Bruscino 566/2763 Pagina 45 di 101 Illustrazione 18: Esempi di bank conflicts nella shared memory, rispettivamente 1 e 7 cicli di clock in più richiesti. Infine c'è la texture memory: la texture è un oggetto per lettura dati che presenta diversi vantaggi rispetto alla constant memory, tipo la presenza di cache, l'interpolazione hardware e la possibilità di accesso con coordinate integer. Essendo più veloci della costant memory, i kernel ottengono vantaggi dalle texture accedendo ad esse chiamando una fetch function (in base al tipo di struttura su cui si sta lavorando): tex1D(), tex2D(), tex3D(). Detto ciò ci sono quindi delle “regole d'oro” per sfruttare a pieno il parallelismo del CUDA C: • Accessi alla memoria coalescenti se possibile; • Ricavare vantaggi dalla shared memory; • Utilizzare altri spazi di memoria, tipo le texture e le constant memory; • Evitare i bank conflicts. Riassumendo, le caratteristiche principali di CUDA (“design goals”), che lo rendono uno degli ambienti più interessanti tra quelli introdotti negli ultimi anni, consistono in una scalabilità verso centinaia di cores (migliaia di threads paralleli), Vincenzo Bruscino 566/2763 Pagina 46 di 101 una gerarchia di memoria semplice e potente, il tutto in un'estensione minimale al linguaggio C con una veloce curva di apprendimento che permette ai programmatori di concentrarsi sugli algoritmi anziché sulle regole di programmazione parallela. Vincenzo Bruscino 566/2763 Pagina 47 di 101 4. Analisi e valutazioni dell'ambiente di calcolo parallelo L'obiettivo primario che si prefigge il seguente lavoro di tesi è testare l'intero ambiente di sviluppo utilizzato per poi applicare i miglioramenti riscontrati all'esperimento SuperB nell'ambito della ricostruzione dei decadimenti del mesone B. Nella fisica particellare con il nome di mesone si indica una famiglia di particelle subatomiche instabili composte da un quark e un antiquark. I mesoni B sono particolari particelle composte da un antiquark bottom e da un quark up (B+), down (B0), strange (B0S) o charm (B+C). L'acceleratore SuperB farà collidere i fasci di elettroni e di positroni tra loro ed il rilevatore, posto all'incrocio dei fasci, ricostruirà i mesoni a partire dal loro decadimento. I mesoni B possono però decadere in migliaia di modi diversi e quindi l'algoritmo di ricostruzione non è infallibile. Il lavoro di questa tesi comprende anche lo sviluppo di un software che gestirà la prima parte della ricostruzione del decadimento delle particelle succitate, in particolar modo la ricostruzione dei mesoni neutri π0, prodotti da decadimenti secondari, a partire dalla combinazione a coppie di fotoni dati in input rivelati dall'apparato. Come già detto in precedenza, per l'implementazione dell'applicativo è stato usato il framework CUDA, progettato per l'elaborazione di algoritmi paralleli ad elevate prestazioni studiati per poter lavorare su un numero molto ampio di dati. Nel primo paragrafo del seguente capitolo testeremo quindi le funzionalità principali del linguaggio CUDA C per poter avere una stima precisa dell'overhead di esecuzione di un'applicazione per GPU. 4.1. Overhead delle funzioni basilari Un programma scritto nel linguaggio CUDA C, e quindi contenente almeno una funzione che sia destinata ad essere eseguita sulla GPU, avrà all'interno del proprio listato sicuramente tre nuclei fondamentali: Vincenzo Bruscino 566/2763 Pagina 48 di 101 • Allocazione di memoria sul device; • Trasferimento di dati dalla CPU alla GPU e viceversa; • Lancio di una funzione kernel. Come è facile intuire, è di primaria importanza calcolare i tempi di lancio delle tre funzioni che gestiscono i punti succitati per avere una stima definitiva dell'overhead minimo che interessa una semplice applicazione scritta in CUDA C. Nel prosieguo analizzeremo quindi i tempi di esecuzione della funzione cudaMalloc(...), della cudaMemcpy(...) e del lancio di una funzione kernel tramite l'apposita sintassi. Verranno eseguiti vari test al variare della dimensione data in input e si analizzeranno i dati con varie considerazioni e grafici. 4.1.1. Test di performance cudaMalloc( ) La funzione cudaError_t cudaMalloc(void **devPtr, size_t size) alloca size byte di memoria lineare sul device e ritorna in *devPtr un puntatore alla memoria allocata. In caso di fallimento restituisce un errore di tipo cudaErrorMemoryAllocation. È in generale la prima funzione che si incontra in un codice scritto in CUDA C visto che la maggior parte degli algoritmi necessitano di memoria allocata sulla GPU. I test che sono stati eseguiti su questa funzione riguardano le allocazioni di memoria di varie quantità di elementi di tipo float passati in input e n'è stato calcolato il tempo di esecuzione tramite gli eventi del framework stesso. Nella seguente tabella sono riportati gli esperimenti effettuati: Vincenzo Bruscino 566/2763 Pagina 49 di 101 TEST CUDAMALLOC FLOAT TEMPO (ms) 0 0,008 1 0,139 10 0,139 100 0,139 1000 0,139 10000 0,139 100000 0,139 1000000 0,147 10000000 0,245 100000000 24,781 500000000 170,518 680000000 243,123 Tabella 1: Test di lancio della funzione cudaMalloc(...). Possiamo notare che la funzione cudaMalloc(...) impiega lo stesso tempo di esecuzione per allocare elementi fino alle 100000 unità, quindi ciò fa intuire che allocare pochi elementi sulla GPU è un'operazione abbastanza esosa in relazione alla quantità realmente allocata; conviene quindi, se è possibile e se necessario, allocare quanta più memoria utilizzando un'unica chiamata alla funzione cudaMalloc(...), visto che l'overhead può diventare consistente con più invocazioni. Fino ai 10000000 di elementi float, comunque, l'ordine di grandezza impiegata è sempre al di sotto del millisecondo; iniziamo a notare rallentamenti da parte della Vincenzo Bruscino 566/2763 Pagina 50 di 101 funzione dai 100000000 di float allocati, con peggioramenti nel tempo di esecuzione anche di decimi di secondo. La funzione di allocazione di memoria sulla GPU è stata “stressata” fino ai 680000000 di elementi float, ossia allocando 2,53 GB, essendo questa una quantità molto vicina al limite massimo di memoria su una singola GPU Fermi ( ~ 2,6 GB). Illustrazione 19: Grafico dei tempi di esecuzione della funzione cudaMalloc(...). Il precedente grafico rende ancor più l'idea di come l'allocare 1 o 10000000 di float sia pressoché identico in relazione al tempo impiegato; dai 100000000 di elementi in su si ha un “vistoso” picco di peggioramento dell'overhead della funzione. 4.1.2. Test di performance cudaMemcpy( ) La funzione cudaError_t cudaMemcpy(void *dst, const void *src, size_t count, enum cudaMemcpyKind kind) copia count bytes dalla memoria puntata da src Vincenzo Bruscino 566/2763 Pagina 51 di 101 all'aria di memoria puntata da dst, dove kind è la “direzione della copia” e può essere una tra cudaMemcpyHostToHost, cudaMemcpyHostToDevice, cudaMemcpyDeviceToHost o cudaMemcpyDeviceToDevice. Dopo aver allocato memoria sulla GPU, questa dev'essere “riempita” e quindi la funzione di trasferimento dati dalla CPU alla GPU (e viceversa) risulta anch'essa di primaria importanza in un codice scritto in CUDA C. Il test che è stato svolto su questa funzione riguarda il trasferimento di diverse quantità di bytes (fino al limite massimo di una singola scheda video) e, sempre con gli eventi del tempo forniti da CUDA, n'è stato calcolato il tempo in millisecondi. I test effettuati sono i seguenti: Vincenzo Bruscino 566/2763 Pagina 52 di 101 TEST CUDAMEMCPY BYTE TEMPO (ms) 8 0,051 16 0,051 24 0,053 32 0,053 800 0,053 8000 0,056 80000 0,125 100000 0,145 1000000 0,756 10000000 4,654 100000000 42,283 1000000000 482,838 2400000000 1136,858 2720000000 1178,041 2740715520 1332,238 Tabella 2: Test di esecuzione della funzione cudaMemcpy(...). Anche per la funzione cudaMemcpy(...) possono essere fatte le stesse considerazioni della funzione cudaMalloc(...): trasferire pochi byte alla volta non è conveniente, infatti dalle rilevazioni prese possiamo notare che il tempo di computazione per inviare 8 byte è pressoché identico a quello per inviarne 8000. Si sale di un ordine Vincenzo Bruscino 566/2763 Pagina 53 di 101 di grandezza dagli 80000 byte in su, fino ad arrivare anche ai 1332,238 ms per l'invio di 2,55 GB, limite massimo per una singola GPU Fermi. Illustrazione 20: Grafico dei tempi di computazione della funzione cudaMemcpy(...). Anche per la funzione cudaMemcpy(...) notiamo una stabilità dei tempi di overhead fino ai 100000 byte; dai 10000000 di byte iniziamo a notare un rallentamento dell'ordine dei millesimi di secondo, fino ad arrivare a tempi di computazione più rilevanti dal GB in su. 4.1.3. Test di performance kernel function Il cuore di un applicativo scritto in CUDA C è la funzione kernel, quindi è di fondamentale importanza valutare i tempi di lancio al variare del numero di blocchi e di thread per blocco. Ricordiamo che un kernel è identificato dalla sintassi a tripla parentesi angolare, dove al proprio interno troviamo i parametri di tipo dim3 che identificano la dimensione della griglia e quella dei blocchi; le griglie possono Vincenzo Bruscino 566/2763 Pagina 54 di 101 essere composte da massimo 65535 blocchi per le due dimensioni x e y, mentre ogni singolo blocco può essere formato da massimo 1024 thread in totale tra le tre dimensioni x, y e z. Il programma di test, che è stato scritto per calcolare i tempi di esecuzione di una kernel function vuota, riceve in input i parametri che settano il lancio della funzione __global__, quindi è stato possibile registrare i tempi di lancio con numerose configurazioni di partenza. Anche in questo caso i tempi sono stati rilevati tramite la funzione cudaEventRecord(...), essendo quest'ultima ad alta precisione. Tabella 3: Test di lancio di una kernel function. La prima cosa che salta all'occhio è lo stesso tempo di overhead, impiegato dalla funzione, sia per il lancio di un thread che di 512000: questo ci aiuta a comprendere che conviene lanciare una kernel function sempre con un numero consistente di thread e quindi sfruttare appieno il parallelismo tra di essi. Il tempo di esecuzione inizia a salire dai 50000000 di thread lanciati per un singolo kernel, fino ad arrivare a toccare anche il minuto per la configurazione da 4397912294400 thread, limite massimo per la Tesla S2050. Vincenzo Bruscino 566/2763 Pagina 55 di 101 Illustrazione 21: Grafico dei tempi di una kernel function al variare del numero dei thread. Il grafico sottolinea quanto detto in precedenza, ossia che la funzione kernel è performante con una configurazione di partenza nell'ordine dei milioni di thread; con l'aumentare di quest'ultimi, invece, notiamo un overhead per nulla trascurabile che supera il minuto per il solo lancio dei thread, tempo che andrà sommato poi all'effettiva computazione che la funzione __global__ svolgerà. Quindi ciò non va trascurato per non vanificare tutto il margine di guadagno di un algoritmo parallelo. 4.1.4. Bandwidth Un ultimo test che è stato effettuato è quello della capacità di trasmissione dei dati dalla CPU alla GPU: sono state inviate diverse quantità della struttura dati utilizzata nell'algoritmo della ricostruzione del mesone B, di cui parleremo nel prosieguo. Gli esperimenti sono stati svolti sulle quantità di 1, 10, 100, 1000, 2000 e 2600 MB e hanno dato i seguenti risultati: Vincenzo Bruscino 566/2763 Pagina 56 di 101 BANDWIDTH MB GB/sec 1 1,292 10 2,098 100 2,309 1000 2,022 2000 1,718 2600 1,905 Tabella 4: capacità di trasferimento dati CPU-GPU. Dai dati in tabella possiamo evincere che il trasferimento da e verso la GPU è molto veloce, andando anche a toccare il picco di 2,3 GB/sec; ciò è anche reso possibile dal collegamento tramite PCI Express x16 di seconda generazione, che garantisce una velocità di banda elevatissima. Illustrazione 22: Capacità di trasferimento dati CPU-GPU. Vincenzo Bruscino 566/2763 Pagina 57 di 101 Possiamo notare che la velocità di banda si accosta intorno ad una media di 1,89 GB/sec, con punto di minimo di 1,292 GB/sec per trasferire un megabyte e punto di massimo di 2,309 GB/sec per 100 megabyte. Anche per questo test non è stato possibile superare i 2600 MB di trasferimento, e quindi di memoria allocata, essendo questo il limite massimo di memoria disponibile per una singola nVIDIA Fermi. 4.2. Tuning dell'ambiente di calcolo parallelo Durante gli esperimenti svolti sulla Tesla S2050 è stato rilevato, ad ogni lancio di un applicativo scritto in CUDA C, un overhead iniziale di ~2 secondi che gravava sul tempo complessivo di computazione dell'algoritmo in esecuzione. Più precisamente, si riscontrava questo ritardo nel tempo di calcolo appena veniva invocata, all'interno del sorgente, la prima funzione che richiedeva attività da parte della GPU. Tutto ciò è comunque normale, visto che è una feature del driver nVIDIA: in pratica, il “context”, ossia tutta la macchineria di supporto al toolkit CUDA, viene creata ondemand (in modalità lazily) e viene liberata appena risulta inutilizzata. Per eliminare tale overhead, sono state trovate due soluzioni, differenti in base alla versione del toolkit di CUDA installato sulla macchina: 1. In caso di versione antecedente alla CUDA 4, tenere in background un processo dummy che tiene sempre attivo il driver; aprendo un terminale dobbiamo quindi digitare: nvidia-smi -l 60 -f /tmp/bu.log 2. Dalla versione di CUDA 4 in poi, i driver dell'intero toolkit sono diventati molto più personalizzabili, infatti si usa l'opzione -pm del comando nvidiasmi per tenere sempre attivo il contex della GPU. Per -pm si identifica, appunto, il –persistence-mode ed accetta due possibili parametri: 0 per disattivarlo, 1 per attivarlo. Esempi: Vincenzo Bruscino 566/2763 Pagina 58 di 101 [root@pc08 ~]# nvidia-smi -pm 1 Enabled peristence mode for GPU 0000:01:00.0. oppure per disattivare [root@pc08 ~]# nvidia-smi -pm 0 Disabled peristence mode for GPU 0000:01:00.0. Per avere tutte le opzioni disponibili per la modifica delle caratteristiche del driver nVIDIA, basta lanciare dal terminale di sistema il comando nvidia-smi -h. Vincenzo Bruscino 566/2763 Pagina 59 di 101 5. Un algoritmo parallelo per la ricostruzione dei decadimenti del mesone B Arrivati a questo punto, abbiamo a nostra disposizione tutte le conoscenze tecniche per poter valutare appieno la potenza computazionale del calcolo parallelo mediante la GPGPU computing. Per valutare l'architettura nVIDIA Tesla S2050, come anticipato in precedenza, è stato scritto un algoritmo per la ricostruzione del decadimento dei mesoni neutri π0 a partire dalla combinazione a coppie di fotoni dati in input per quanto concerne l'esperimento SuperB. Dapprima è stato scritto un normale codice sequenziale in linguaggio C, successivamente si è valutato quali parti del codice potevano essere parallelizzate per essere implementate nel linguaggio CUDA C. La logica implementativa è stata appunto questa: le parti di listato “poco ripetitive” sono state gestite dalla CPU, mentre quando bisognava svolgere sempre le stesse operazioni su una mole enorme di dati (parallelismo SIMD) si è lasciato il compito della computazione alla GPU. Introduciamo dapprima, per sommi capi, la natura del problema della ricostruzione dei decadimenti del mesone B, tecnica che può essere vista come un sistema gerarchico formato da quattro parti, ognuna caratterizzata dalla presenza di determinate particelle coinvolte: Tabella 5: I quattro stadi del sistema gerarchico. Vincenzo Bruscino 566/2763 Pagina 60 di 101 Tale gerarchia è stata introdotta dato che non è possibile poter considerare tutti i migliaia di “canali” di decadimento per la ricostruzione del mesone B, motivo per il quale si è scelto l'approccio gerarchico visto in tabella e mostrato più dettagliatamente nell'immagine che segue: Illustrazione 23: I quattro stadi della ricostruzione completa del mesone B. Uno degli scopi della ricostruzione è ovviamente ottenere la massima efficienza; ciò potrebbe essere fatto, in teoria, ricostruendo ogni possibile candidato, in tutte le quattro fasi, per poi scegliere il miglior candidato del mesone B. Ma poiché il calcolo necessario per perseguire questa strategia di massima efficienza non è disponibile (computazionalmente parlando), è necessario eseguire tagli durante il processo di selezione e di ricostruzione. Il lavoro della seguente tesi esula dalla scrittura dell'intero algoritmo della ricostruzione del mesone B, soffermandosi invece su una parte del primo stage della ricostruzione totale, ossia della combinazione delle particelle ɣ per la ricostruzione dei mesoni neutri π0. Vincenzo Bruscino 566/2763 Pagina 61 di 101 Illustrazione 24: Parte del primo stadio della ricostruzione completa del mesone B. I programmi, sia il sequenziale che il parallelo, ricevono in input le particelle ɣ, le quali vengono combinate tra di loro senza ripetizioni; più nello specifico, i fotoni vengono sommati a coppie, sia per quanto riguarda i momenti che le energie. Così facendo avremo un possibile candidato π0, il quale sarà accettato se supererà un test confrontando la propria massa con una di riferimento data sempre in input. Andiamo ora ad analizzare, più nello specifico, i due approcci affrontati per risolvere la problematica assegnata. 5.1. Strategia sequenziale Il problema della ricostruzione dei decadimenti del mesone π0, facente parte del primo stage della ricostruzione totale del mesone B, è stato affrontato, in primo luogo, con un algoritmo iterativo senza sfruttare alcuna forma di parallelismo. Il codice è stato scritto in linguaggio C per poi rendere la conversione in CUDA C molto più semplice ed intuitiva. Come primo passo sono state introdotte due nuove strutture dati, una identificante i quadrivettori ɣ e l'altra i mesoni π0, entrambe caratterizzate da quattro elementi reali rappresentanti il momento e l'energia, e da una variabile di tipo char che delinea il genere della particella. La struttura dei π0 possiede due attributi interi in Vincenzo Bruscino 566/2763 Pagina 62 di 101 più che rappresentano l'indice dei fotoni che hanno ricostruito quel particolare mesone. L'algoritmo riceve quindi in input un determinato numero di quadrivettori ɣ, in genere sempre nell'ordine della ventina, che combina tra di loro senza ripetizioni: ciò sta a significare che l'algoritmo sequenziale dovrà ciclare per N∗( N −1) 2 volte, con N numero di fotoni dati in input. Il codice è strutturato nel seguente modo: 1. Allocazione dinamica della memoria delle strutture dati adoperate; 2. Caricamento dei dati ricevuti in input negli array di struct appena allocati; 3. Invocazione della funzione di ricostruzione dei mesoni neutri; 4. Stampa dei tempi di computazione per lo studio delle performance. Come è facile intuire, il cuore pulsante dell'algoritmo è incentrato nella funzione createPI0 che ha, appunto, il compito di generare i candidati corretti dei mesoni π0; questa accetta come parametri di ingresso il vettore dei fotoni, la dimensione di quest'ultimo, la massa ed il Δ di riferimento. Restituisce al programma chiamante l'array dei π0 generati e la sua dimensione. La ricerca delle particelle ɣ da combinare viene gestita da un doppio ciclo for, l'uno innestato nell'altro: il primo parte dal fotone i-esimo=0 fino al penultimo, mentre il secondo inizia dal successivo, ossia dal j-esimo=i+1, per non ripetere combinazioni che sono già avvenute; quest'ultimo for cicla fino all'ultima particella. I fotoni corrispondenti vengono quindi sommati tra di loro, componente a componente, generando un possibile candidato π0 del quale si andrà a calcolare la massa con la seguente formula: massa=Energia 2−x 2− y 2−z 2 La particella selezionata verrà aggiunta al vettore risultato dei π0 se la massa appena calcolata rientrerà in un determinato range, gestito da una massa iniziale e da un Δ che verrà sommato e sottratto a quest'ultima. Vincenzo Bruscino 566/2763 Pagina 63 di 101 Il programma chiamante quindi riceverà, alla fine della computazione, il vettore dei mesoni neutri trovati e stamperà a video il tempo di calcolo dei tre nuclei principali dell'applicazione, ossia il tempo di allocazione, quello della funzione vera e propria ed il tempo totale di esecuzione. Tali tempi verranno analizzati e confrontati nel prosieguo della tesi. 5.2. Strategia parallela Una volta conclusa la stesura del codice sequenziale si è passati alla pianificazione e alla progettazione parallela dell'applicativo. Come già detto in precedenza, il parallelismo utilizzato dal framework CUDA è quello SIMD (Single Instruction Multiple Data), più precisamente chiamato SIMT dalla nVIDIA (Single Instruction Multple Thread); ciò sta a significare che una stessa istruzione viene eseguita da più thread in parallelo, quindi tutto sta ad identificare quali operazioni si ripetono più volte in un software per far in modo che vengano gestite dai thread sulla GPU. Lo scheletro dell'algoritmo parallelo è rimasto, per sommi capi, simile a quello sequenziale, con però qualche aggiunta: 1. Calcolo del numero dei thread necessari in base ai fotoni dati in input; 2. Allocazione delle strutture dati utilizzate sulla CPU e sulla GPU; 3. Inserimento dei dati passati in input in apposite strutture allocate; 4. Trasferimento dei dati su cui elaborare i calcoli dalla CPU alla GPU; 5. Invocazione della procedura kernel sulla GPU; 6. Trasferimento dei risultati dalla GPU alla CPU; 7. Stampa dei tempi di esecuzione. Anche in questo listato troviamo le strutture dati QVECT e PI_0 per definire le varie particelle coinvolte nella ricostruzione del decadimento; queste però vengono Vincenzo Bruscino 566/2763 Pagina 64 di 101 allocate sia sull'host che sul device, dato che la GPU non può lavorare direttamente sui dati ricevuti in input dalla CPU. Di vitale importanza per l'algoritmo parallelo è il conoscere a priori il numero di thread necessari, in base al numero N di particelle ɣ ricevute in ingresso, per il lancio della funzione kernel; tale valore dovrà soddisfare la condizione di non ripetitività dei calcoli, ossia dovrà essere uguale al numero totale di combinazioni di N elementi di classe 2 senza ripetizioni. Tale numero corrisponde quindi al coefficiente binomiale così definito: Una volta allocate le strutture dati necessarie e trasferiti i dati su cui elaborare i calcoli sulla GPU, si mandano in esecuzione i thread in parallelo che elaboreranno il vettore dei π0 ; ciò avverrà con il lancio della procedura kernel che accetterà in ingresso il vettore dei fotoni, la dimensione di quest'ultimo, il numero del coefficiente binomiale calcolato, la massa ed il Δ utili per il test di accettazione. Come risultato verrà restituito, al programma chiamante, l'array dei mesoni neutri calcolati e la sua dimensione. La procedura __global__ void kernel(...) gestisce il lancio di tutte le funzioni __device__: questa scelta implementativa è stata applicata per rendere maggiormente editabile ed estendibile il sorgente in futuro, quindi il kernel avrà solo il compito di gestire le funzioni sulla scheda grafica, senza elaborare nessun tipo di operazione o calcolo. Sarà quindi compito della procedura __device__ void createPI0(...) quella di ricostruire il vettore dei π0, ricevendo in input gli attributi succitati già per la funzione kernel. All'interno del listato troviamo come nodi cardine: 1. Una variabile __shared__ che tiene traccia dell'incremento della dimensione dei π0 ricostruiti; Vincenzo Bruscino 566/2763 Pagina 65 di 101 2. Un algoritmo per il calcolo degli indici dei fotoni che devono partecipare alla ricostruzione del candidato corrente; 3. Test per l'accettazione del candidato trovato; 4. Aggiornamento dimensione del vettore dei π0 da parte del thread 0 di ogni blocco. La variabile __shared__ int Ind è condivisa tra i thread dei blocchi dato il suo utilizzo comune: essa indica infatti il numero attuale dei mesoni neutri ricostruiti e deve essere quindi di rapido e comune accesso tra tutti i thread in gioco; solamente il thread 0 di ogni blocco avrà il compito di riversare il risultato parziale in una variabile globale che poi sarà restituita al programma chiamante. L'algoritmo per la ricerca degli indici “giusti” dei fotoni deve garantire la logica parallela che si è voluta dare al programma: come prima cosa dobbiamo far presente che ogni singolo thread gestisce solo una coppia di particelle ɣ, ossia gestisce la somma componente a componente del fotone i con quello j, calcola la massa del candidato appena formatosi e ne fa il test di correttezza. Si è quindi andata a creare la situazione seguente: Thread 0=Gamma[0]+Gamma[1] Thread 1=Gamma[0]+Gamma[2] Thread 2=Gamma[0]+Gamma[3] … Thread k=Gamma[0]+Gamma[N-1] Thread k+1=Gamma[1]+Gamma[2] Thread k+2=Gamma[1]+Gamma[3] … Thread N-2=Gamma[N-3]+Gamma[N-1] Thread N-1=Gamma[N-2]+Gamma[N-1] Essendo i thread lanciati non in ordine, è stato quindi necessario calcolare gli indici i e j in base al TID (Thread ID), alla dimensione del vettore ɣ e al numero totale di thread lanciati. Vincenzo Bruscino 566/2763 Pagina 66 di 101 Il test per l'accettazione del candidato è pressoché identico a quello della versione sequenziale, salvo per la funzione atomica atomicAdd che incrementa la variabile __shared__ Ind. Una volta che tutti i thread avranno finito l'elaborazione, l'esecuzione dell'algoritmo tornerà in logica sequenziale e passerà il compimento del processo alla CPU; ciò è garantito dalla funzione cudaThreadSynchronize() che rende bloccante l'esecuzione del kernel, dato che, per i fini di questa tesi, andava calcolato il tempo di computazione della funzione __device__. Come per la versione sequenziale, l'algoritmo parallelo darà in output l'array dei π0 calcolato ed i tempi di calcolo dei punti chiave dell'applicativo. 5.3. Le diverse tipologie di testing Gli applicativi appena presentati sono stati testati su hardware specifici, in base alla tipologia implementativa utilizzata. L'algoritmo parallelo è stato eseguito sulla nVIDIA Tesla S2050 di cui si è già ampiamente parlato, mentre il codice sequenziale è stato lanciato su un server rack PowerEdge R510 della Dell con le seguenti specifiche tecniche: • Processore eight-core Intel Xeon serie E5506 @ 2.13 Ghz con 4096 KB di cache size; • 32 GB DDR3 fino a 1666 Mhz; • 8 unità dischi rigidi SATA (7200 rpm) da 3.5”, capacità 500 GB ognuno. Vincenzo Bruscino 566/2763 Pagina 67 di 101 Illustrazione 25: 1U server rack PowerEdge R510 Attualmente algoritmi simili vengono già usati da esperimenti esistenti quali BaBar e LHC; questi sorgenti, scritti in logica sequenziale, integrano parte del framework ROOT, un ambiente sviluppato in C++, progettato per l'analisi dei dati nel campo della fisica particellare. Lo scopo primario dei test effettuati è quindi valutare se l'affiancamento della GPU quale coprocessore matematico possa portare o meno miglioramenti alle performance di computazione. Ecco perchè, per fare ciò, sono stati messi a confronto un algoritmo sequenziale (lanciato su un attuale architettura usata per il calcolo ad alte prestazioni) con uno parallelo scritto in CUDA C; dagli esiti positivi o non, si potrà infine valutare se conviene attuare un porting degli attuali sorgenti sequenziali in paralleli, nell'ambito dell'esperimento SuperB. Analizziamo ora, più nel dettaglio, i test svolti recuperando i tempi di computazione delle parti più importanti dei due algoritmi; in entrambi i listati il tempo di esecuzione è stato calcolato con la funzione cudaEventRecord(...), essendo di altissima precisione. Vincenzo Bruscino 566/2763 Pagina 68 di 101 5.3.1. Test dell'allocazione di memoria Il primo caso di test che ci troviamo ad affrontare è quanto tempo ci impiegano i due algoritmi ad allocare le strutture dati necessarie. Le prove effettuate variano all'aumentare della memoria richiesta in base ai fotoni passati in input ai due algoritmi; il risultato delle verifiche è espresso in millisecondi (sarà così per tutti i successivi casi di test, quindi non verrà più ripetuto in seguito). FOTONI SERIALE PARALLELO 20 0,004 0,15 100 0,004 0,261 1000 0,004 0,256 10000 0,008 0,249 30000 0,009 0,251 40000 0,008 0,257 45000 0,008 0,256 Tabella 6: Tempi di allocazione memoria al variare del numero di fotoni in input. Come è facile intuire, il codice seriale impiegherà sempre meno tempo del parallelo ad allocare le strutture necessarie; questa è una considerazione ovvia dato che l'applicazione scritta in CUDA C dovrà allocare, oltre alle strutture dati sulla CPU come avviene nel sorgente sequenziale, anche la memoria sul device per poter permettere il trasferimento dei dati su cui elaborare i calcoli. Altro particolare che notiamo dallo studio dei risultati è lo stesso tempo di allocazione dai 100 ai 45000 fotoni: questo ci induce ad affermare che la funzione cudaMalloc(...) alloca la memoria in “blocchi prefissati” e non realmente nella quantità richiesta. Vincenzo Bruscino 566/2763 Pagina 69 di 101 Illustrazione 26: Grafico dei tempi di allocazione per le due strategie implementative. Dall'Illustrazione 26 si evince ancor di più il gap di performance tra l'allocazione di memoria in logica sequenziale e tra quella parallela, nella fattispecie notiamo un tempo maggiore di computazione della funzione cudaMalloc(...) in relazione alla normale malloc(...) eseguibile sull'host. 5.3.2. Test della ricostruzione dei π0 Passiamo ora alla verifica della parte fondamentale dei due algoritmi, ossia lo studio dei tempi di elaborazione del vettore dei π0 e quindi quanto tempo ci impiegano i due sorgenti ad espletare la funzione createPI0(...). Anche in questo caso, i test variano all'aumentare delle particelle ɣ date in ingresso ai due applicativi: Vincenzo Bruscino 566/2763 Pagina 70 di 101 FOTONI SERIALE PARALLELO 20 0,009 0,066 100 0,155 0,064 1000 15,207 0,222 10000 1422,734 19,145 30000 10637,208 169,38 40000 18613,333 300,851 45000 23266,98 380,011 Tabella 7: Tempi di esecuzione della funzione createPI0(...) in millisecondi. Dai valori in tabella, subito ci salta all'occhio che il codice seriale conclude i calcoli prima del parallelo solo nel test con 20 fotoni. Ciò implica che, con poche particelle ɣ su cui elaborare i calcoli, il codice parallelo impiegherà più tempo a lanciare la funzione kernel (tempo di overhead di lancio, vedi 4.1.3.) che a svolgere le operazioni all'interno della stessa. Già dai 100 fotoni possiamo invece notare un peggioramento delle prestazioni del listato scritto in logica sequenziale, fino ad arrivare addirittura ai 23 secondi di computazione per le 45000 particelle; il parallelo, dalla sua, ha invece un decadimento delle prestazioni molto minore, non toccando mai il mezzo secondo di elaborazione neanche nel caso peggiore. Il grafico nella pagina seguente darà ancor più l'idea di ciò che è stato appena detto. Vincenzo Bruscino 566/2763 Pagina 71 di 101 Illustrazione 27: Grafico dei tempi di esecuzione della funzione createPI0(...). Possiamo quindi concludere questo test affermando che in presenza di pochi dati su cui elaborare i calcoli conviene adoperare il codice sequenziale, mentre al crescere dei valori dati in input ci è più utile utilizzare quello parallelo. 5.3.3. Test della computazione totale degli algoritmi Arrivati a questo punto, possiamo stilare un ultimo test che ci permetterà di valutare quale tra le due strategie implementative conviene adoperare per il tipo di problematica affrontata. In quest'ultima verifica valuteremo il tempo di esecuzione totale dei due sorgenti, cercando quindi di stimare quando ci conviene adoperare un programma invece dell'altro; i tempi che verranno mostrati sono quindi comprensivi di tutti quelli appena studiati, compresi quelli per i trasferimenti nel codice parallelo. Vincenzo Bruscino 566/2763 Pagina 72 di 101 FOTONI SERIALE PARALLELO 20 0,013 0,351 100 0,159 0,457 1000 15,211 0,621 10000 1422,742 19,699 30000 10637,217 170,23 40000 18613,341 301,756 45000 23266,988 380,989 Tabella 8: Tempi di esecuzione dei due algoritmi in millisecondi. I risultati ci confermano, ancora una volta, che il codice parallelo va utilizzato quando si lavora su una mole di dati abbastanza grande (nel caso del nostro problema dai 1000 quadrivettori in su): ciò è dato dal fatto che la strategia parallela SIMT inizia ad essere vantaggiosa quando elabora la stessa istruzione su un numero abbastanza elevato di elementi e quindi quando la computazione dell'algoritmo vero e proprio risulta vantaggioso nonostante gli overhead iniziali delle funzioni del CUDA C. Se ci troviamo invece in presenza di pochi fotoni dati in input, la strategia sequenziale risulta migliore, dato che ci sono poche operazioni da espletare e quindi non conviene attivare tutta la “macchineria” di CUDA. Vincenzo Bruscino 566/2763 Pagina 73 di 101 Illustrazione 28: Tempi di esecuzione totali degli algoritmi sequenziale e parallelo. Il grafico riassume i tre punti chiave che abbiamo rilevato da questo esperimento: 1. L'algoritmo sequenziale è più performante del parallelo se si lavora con poche particelle; 2. Appena l'input comincia ad aumentare le prestazioni del codice sequenziale peggiorano vistosamente mentre quelle del parallelo no; 3. Nel caso peggiore verificato (45000 fotoni, più di un milione di cicli – lanci della funzione kernel) registriamo una differenza dei tempi di ~23 secondi. Possiamo quindi concludere che le due strategie implementative hanno entrambe campi di applicazione diversi: l'algoritmo sequenziale è performante quando si lavora su pochi dati o su operazioni poco ripetitive, quello parallelo è ottimo ad elaborare grosse moli di dati e ad eseguire spesso le stesse istruzioni. Ecco perchè trovare un buon connubio tra CPU (istruzioni per lo più sequenziali) e GPU (parallelismo delle operazioni) rimane il miglior compromesso per raggiungere le migliori prestazioni computazioni. Vincenzo Bruscino 566/2763 Pagina 74 di 101 Conclusioni In seguito alle esigenze di rendere più performanti gli algoritmi per la ricostruzione dei decadimenti del mesone B, sono stati dapprima svolti test per valutare l'ambiente di calcolo parallelo e, quindi, gli overhead minimi che bisogna considerare quando si converte un codice sequenziale in uno parallelo su GPU. Tali test hanno dato come risultato che, essendo necessarie allocazioni e trasferimenti di memoria sul device, bisogna ricorrere al massively parallel solo quando necessario, ossia quando si lavora su un numero considerevole di dati. Per verificare ciò, sono state riprogettate in CUDA C alcune fasi della ricostruzione del decadimento dei mesoni neutri π0: i risultati degli speed test hanno dimostrato che il calcolo su GPU (o più precisamente il co-processing CPU-GPU) può migliorare, in modo considerevole, le prestazioni degli algoritmi che richiedono quantità di tempo importanti a causa dell'enorme mole di dati o di calcoli che devono eseguire. Se invece ci troviamo a dover lavorare con applicativi che utilizzano pochi elementi in input, allora gli algoritmi “standard” per CPU risultano ancora i più performanti. CUDA ha comunque dimostrato di essere un'ottima nuova tecnologia, un trampolino di lancio per le numerose applicazioni nella ricerca scientifica; il CUDA C, in particolare, è semplice da apprendere e fa coesistere la mentalità parallela con quella sequenziale, a patto di conoscere le caratteristiche dell'architettura GPU in maniera tale da poterla sfruttare nel migliore dei modi. È appunto la “coesistenza” il nocciolo principale di questo nuovo paradigma implementativo, nel senso che il programmatore deve intuire quali parti dell'algoritmo vadano delegate all'host e quali al device, per poter beneficiare al massimo di entrambe le potenze computazioni dal punto di vista prestazionale. Concludendo, possiamo dunque affermare che il co-processing CPU-GPU può essere realisticamente considerato il futuro per quanto concerne il calcolo ad alte prestazioni, soprattutto per quanto riguarda esperimenti scientifici. Le differenze Vincenzo Bruscino 566/2763 Pagina 75 di 101 strutturali tra CPU e GPU possono dar vita ad un connubio potentissimo: con molto spazio sul chip dedicato al controllo e alla cache e con poche unità processanti potenti e sofisticate, le CPU possono gestire database, algoritmi ricorsivi e flussi di controllo non regolari; d'altro canto, con un miglior rapporto Gflops/consumo e Gflops/costo e molte unità processanti elementari, le GPU sono adatte a calcoli ripetitivi su grandi quantità di dati, evitando possibili colli di bottiglia grazie alle grandi quantità di memoria presenti sulle schede video oggigiorno. Questo sistema ibrido CPU-GPU sarà quindi sempre più presente in applicazioni di calcolo parallelo e “massivo” quali fisiche, meccaniche computazionali (fluidodinamica, cinematica, modelling acustico, termiche, ecc.), meteorologiche e simulative generali. Vincenzo Bruscino 566/2763 Pagina 76 di 101 Appendice A.1. Installazione e verifica del framework CUDA su sistemi Linux Per poter usare CUDA sul proprio sistema bisogna prima verificare la presenza dei seguenti requisiti: • Una GPU abilitata all'utilizzo di CUDA (dalla GeForce serie 8 in poi); • Driver di device installati; • Una versione supportata di Linux (Ubuntu, Debian, Red Hat, ecc.) con installato il compilatore gcc; • Il software CUDA (reperibile da http://www.nvidia.com/cuda). Per verificare la presenza di una scheda video nVIDIA che supporti CUDA, possiamo lanciare il seguente comando dal terminale: lspci | grep -i nvidia Il tool di sviluppo CUDA è supportato soltanto da alcune specifiche distribuzioni di Linux; per determinare quale distribuzione (ed eventuale numero di release) stiamo utilizzando, possiamo adoperare il comando: uname -m && cat /etc/*release Il sistema dovrebbe restituirci un output come il seguente: i386 Red Hat Enterprise Linux WS release 4 (Nahant Update 6) La linea i386 indica la presenza di un sistema a 32 bit; un sistema a 64 bit ci mostrerà invece la dicitura x86_64. La seconda linea, invece, ci da la versione ed il numero del sistema operativo. Per quanto riguarda il compilatore gcc, questo è generalmente installato su gran parte dei sistemi Linux; per verificare la versione di gcc installata sul proprio sistema, basta digitare (sempre da terminale): gcc --version Vincenzo Bruscino 566/2763 Pagina 77 di 101 Se ci verrà mostrato un messaggio di errore, allora bisognerà procedere all'installazione del compilatore gcc prima del toolkit CUDA. Una volta verificata la presenza di un processore nVIDIA supportato e di una versione Linux riconosciuta, bisogna testare l'esistenza di una versione recente dei driver del device. La maggior parte delle distribuzioni porta queste informazioni nel menù di sistema sotto le voci: Applications → System Tools → NVIDIA X Server Settings Se questa voce “grafica” non è presente, allora sempre da riga da riga di comando possiamo digitare: /usr/bin/nvidia-settings Se il comando non ci restituisce alcun output, allora dobbiamo installare i driver con il seguente metodo: 1. Uscire dalla GUI premendo la combinazione di tasti Ctrl-Alt-Backspace. Alcune distribuzioni richiedono di premere questa combinazione più di una volta; altre invece hanno disabilitata questa possibilità, quindi bisogna eseguire il comando sudo /etc/init.d/gdm stop (su sistemi Debian, Ubuntu). Su distribuzioni quali Red Hat, bisogna lanciare il comando /sbin/init 3 per uscire dalla GUI. 2. Lanciare l'installazione del driver, da linea di comando, come superuser. 3. Verificare se l'installazione è avvenuta con successo eseguendo il comando cat /proc/driver/nvidia/version. 4. Riavviare l'ambiente GUI usando il comando startx, oppure init 5 o sudo /etc/init.d/gdm start (o un comando equivalente a seconda del sistema). Arrivati a questo punto, possiamo ora descrivere l'installazione e la configurazione del toolkit CUDA e del GPU Computing SDK: 1. Installare il CUDA Toolkit lanciando il file .run come superuser; la directory di default sarà /usr/local/cuda. 2. Definire le variabili di ambiente PATH e LD_LIBRARY_PATH. La variabile PATH dovrà includere Vincenzo Bruscino 566/2763 il percorso /usr/local/cuda/bin, mentre Pagina 78 di 101 LD_LIBRARY_PATH conterrà /usr/local/cuda/lib oppure /usr/local/cuda/lib64 rispettivamente per i sistemi operativi a 32 o 64 bit. Il modo tipico per poter settare le due variabili è eseguire i seguenti comandi: export PATH=/usr/local/cuda/bin:$PATH export LD_LIBRARY_PATH=/usr/local/cuda/lib:$LD_LIBRARY_PATH per sistemi operativi a 32 bit, con lib64 al posto di lib per sistemi operativi a 64 bit, come menzionato sopra. Per rendere permanenti questi settaggi, posizionare i comandi succitati nel file ~/.bash_profile. 3. Installare SDK (collocato nel secondo .run file) come regular user nella directory di default $(HOME)/NVIDIA_GPU_Computing_SDK. Prima di continuare, è importante a questo punto verificare che i programmi scritti in CUDA C possano trovare e comunicare con l'hardware nVIDIA. Per far ciò, bisogna compilare e lanciare alcuni esempi inclusi nel SDK precedentemente installato. La versione del CUDA Toolkit può essere verificata dal comando nvcc -V. Il comando nvcc lancia il compilatore per i programmi scritti in CUDA C; questo invoca il compilatore gcc per il codice C e quello nVIDIA PTX per il codice CUDA C. Per utilizzare i programmi di testing e di esempio, bisogna collocarsi nella directory ~/NVIDIA_GPU_Computing_SDK/C e digitare make. I binari risultanti saranno installati in ~/NVIDIA_GPU_Computing_SDK/C/bin/linux/release. Dopo aver compilato, spostarsi nella cartella ~/NVIDIA_GPU_Computing_SDK/C/bin/linux/release e lanciare l'eseguibile deviceQuery. Se il software CUDA è stato installato e configurato correttamente, l'output dovrebbe (varia in alcuni dettagli a seconda della distribuzione di Linux installata) essere simile a quello mostrato nella seguente figura: Vincenzo Bruscino 566/2763 Pagina 79 di 101 Illustrazione 29: Risultati validi per il programma deviceQuery. Con le frecce in verde, sono stati evidenziati i risultati importanti dell'applicativo deviceQuery, ossia che il device è stato trovato, qual è il suo nome e che il test è finito con successo. Il programma bandwidthTest assicura che il sistema e l'hardware CUDA comunichino correttamente: Vincenzo Bruscino 566/2763 Pagina 80 di 101 Illustrazione 30: Risultati validi per il programma bandwidthTest. Anche in quest'altra immagine, la freccia verde indica il successo del test. A.2. Il compilatore nvcc Le applicazioni C per CUDA possono essere composte da file con codice C/C++ standard e file con codice che sfrutta le estensioni CUDA (tutti i file .cu che abbiamo usato in questo lavoro di tesi, ad esempio). I file con solo codice C/C++ possono essere dati in pasto ad un compilatore standard, i file invece che contengono anche estensioni CUDA devono passare sotto nvcc (nVIDIA CUDA Compiler). In entrambi i casi, si generano dei file oggetto che poi il linker integra, permettendo di ottenere un singolo eseguibile con i binari per CPU e GPU. Il compilatore nvcc accetta come file soltanto quelli con suffisso .cu o .cuh (nel caso in cui si vogliano annotare i prototipi delle funzioni definite nel relativo file .cu); in Vincenzo Bruscino 566/2763 Pagina 81 di 101 caso di utilizzo di file sorgenti quali .c o .h, il compilatore nvcc restituirà un errore di compilazione dovuto al fatto che le parti di codice eseguite sulla GPU non vengono riconosciute da tali suffissi. Illustrazione 31: Le varie fasi di compilazione mediante nvcc. Più in dettaglio, anche nei file .cu è contenuto codice C per la CPU e codice CUDA C per la GPU. Nvcc, che è un compiler driver, separa i due codici, lanciando il compilatore di sistema (Visual C per Windows, o gcc per Linux/Unix) per la parte sulla CPU ed invocando il CUDA compiler per la parte destinata alla GPU. Il CUDA compiler genera un binario di tipo PTX (Parallel Thread eXecution), che rappresenta l'Instruction Set Virtuale per i chip grafici nVIDIA, definendone anche il modello di programmazione, le risorse di esecuzione e lo stato. Un'ulteriore traslatore (o il compilatore stesso, o l'interprete a runtime di CUDA) trasforma il codice PTX nel codice binario dell'architettura fisica target. Tutti gli eseguibili con codice CUDA richiedono due librerie dinamiche: • La CUDA core library (cuda); • La CUDA runtime library (cudart). Vincenzo Bruscino 566/2763 Pagina 82 di 101 Quest'ultima, nel caso sia usata l'API a runtime (che permette di caricare codice PTX che viene compilato a runtime), carica autonomamente la core library. Normalmente la compilazione in una shell UNIX-like può essere avviata nel seguente modo: nvcc [opzioni] sorgente.cu -o nomeEseguibile C'è da aggiungere, inoltre, che per poter compilare senza problemi un programma CUDA C il cui codice potrebbe contenere inclusioni di altri file sorgente con il suffisso .c o .h, come nel seguente esempio: #include "library_name.h" #include " file_name.c" #include "file_name_2.c" ... si devono racchiudere le inclusioni con la parola chiave extern che istruisce il compilatore nvcc a “linkare” le sorgenti miste (con i suffissi .c e .cu), pena un errore di compilazione. Ecco un esempio: extern "C" { #include "library_name.h" #include " file_name.c" #include "file_name_2.c" } … Ovviamente questo discorso vale soltanto per i file sorgenti (e le librerie) scritti dall'utente, non per quelli standard, quali stdio.h, stdlib.h, ecc. Un'opzione importante da conoscere è quella per istruire il compilatore su quale tipo di hardware girerà il sorgente che si sta compilando, quindi indicare la Compute Capability del device in possesso. Da questo presupposto, per uno sfruttamento ottimale di una GPU nVIDIA e delle sue caratteristiche, al momento della compilazione è buona norma inserire la flag -arch sm_xy (dove x indica il major numerb ed y il minor number della Compute Capability). Un esempio di utilizzo Vincenzo Bruscino 566/2763 Pagina 83 di 101 applicabile per sfruttare le caratteristiche della nVIDIA Tesla S2050 (che, ricordiamo, ha Compute Capability 2.0) è il seguente: nvcc -arch sm_20 sorgente.cu -o nomeEseguibile In caso di mancato utilizzo della flag -arch sm_xy, il compilatore nvcc compilerà il sorgente con Compute Capability 1.0. A.3. Il CUDA GPU Occupancy Calculator Una delle problematiche, tra quelle che si possono incontrare nella programmazione CUDA, è il decidere su quanti blocchi e thread invocare la funzione kernel implementata. La nVIDIA corre in aiuto degli sviluppatori con un utile strumento che ci permette di poter sfruttare a pieno l'hardware in nostro possesso. Il CUDA GPU Occupancy Calculator è un foglio di calcolo che ci permette di conoscere, in base al numero di Compute Capability del nostro device, la configurazione ottimale per poter sfruttare al massimo le risorse della nostra GPU. In tutti i codici implementati in questo lavoro di tesi, si sono tenuti molto sott'occhio i consigli di questo documento, infatti i sorgenti sono stati lanciati tutti su un numero di thread per blocco equivalente a 448 (o al massimo 512), dato lo sfruttamento maggiore dei core con tali settaggi. Andando infatti ad inserire nel CUDA GPU Occupancy Calculator la Compute Capability 2.0 (quella appunto della Tesla S2050) e come numero di thread per blocco 512, il documento ci darà come output le seguenti informazioni: Active Threads per Multiprocessor Active Warps per Multiprocessor Active Thread Blocks per Multiprocessor Occupancy of each Multiprocessor 1536 48 3 100% Tabella 9: Valori di occupazione della GPU. Vincenzo Bruscino 566/2763 Pagina 84 di 101 Physical Limits for GPU Compute Capability: 2,0 Threads per Warp 32 Warps per Multiprocessor 48 Threads per Multiprocessor 1536 Thread Blocks per Multiprocessor Total # of 32-bit registers per Multiprocessor 8 32768 Register allocation unit size 64 Register allocation granularity Shared Memory per Multiprocessor (bytes) warp 49152 Shared Memory Allocation unit size 128 Warp allocation granularity (for block register allocation) 0 Maximum Thread Block Size 1024 Tabella 10: Limiti fisici per una GPU con Compute Capability 2.0. La Tabella 9 ci mostra che, con tale configurazione, l'occupazione per ogni multiprocessore è massima, ossia che riusciamo ad utilizzare 48 Warp attivi per multiprocessore (il limite massimo per ogni Streaming Multiprocessor). Precisando, un Warp è formato dal numero di thread per blocco (512 nel nostro esempio) diviso i thread per Warp (che sono 32); i blocchi, quindi, saranno sulla GPU tutti di 16 thread (512/32), ogni SM può gestire al massimo 3 blocchi arrivando quindi a sfruttare appieno il limite di thread per SM, ossia 48 (16*3). Vincenzo Bruscino 566/2763 Pagina 85 di 101 Per Limite Blocchi blocchi per SM per SM Risorse Warps Per Warp) (Threads Per Block / Threads 16 48 3 Registers (≈Registers Per Thread * Threads Per Block) 8192 32768 4 Shared Memory (Bytes) 4096 49152 12 Note: SM is an abbreviation for (Streaming) Multiprocessor Tabella 11: Risorse allocabili per una GPU con Compute Capability 2.0. I grafici seguenti mostrano invece l'impatto di sfruttamento dei multiprocessori al variare del numero di thread per blocco e l'impatto della shared memory: Illustrazione 32: Sfruttamento dei multiprocessori al variare della grandezza dei blocchi. Vincenzo Bruscino 566/2763 Pagina 86 di 101 Illustrazione 33: Impatto al variare della shared memory per blocco. L'Illustrazione 32 è di particolare rilevanza visto che, per poter sfruttare 48 thread per SM, ci fa capire che dobbiamo lanciare una kernel function con o 192, 256, 384, 512, 768 thread per blocco. Vincenzo Bruscino 566/2763 Pagina 87 di 101 A.4. Codice A.4.1. Sorgente sequenziale “se.cu” /* ALGORITMO SEQUENZIALE PER IL CALCOLO DEI MESONI PI 0 */ #include <stdio.h> #include <stdlib.h> #include <math.h> #include <cuda_runtime.h> //Definizione strutture typedef struct { //Quadrivettore gamma float Ene; //Energia float x; float y; float z; char gen; //Genere } QVECT; typedef struct { //PI 0 float Ene; //Energia float x; float y; float z; char gen; //Genere int g1; //Gamma 1 int g2; //Gamma 2 } PI_0; //Definizione di procedure int createPI0(QVECT *, PI_0 *, unsigned long int, float, float); int main(int argc, char **argv){ //Dichiarazione variabili QVECT *Gamma; PI_0 *Pi0; unsigned long int N=20; int i, dimPi0; float massa0=pow(4.5,2), delta=6, time[100]; Vincenzo Bruscino 566/2763 Pagina 88 di 101 // *** DEFINISCO VARIABILI CUDA TEMPO *** cudaEvent_t start, stop, start3, stop3, start4, stop4; // *** CREO OGGETTI TEMPO CUDA *** cudaEventCreate(&start); cudaEventCreate(&stop); cudaEventCreate(&start3); cudaEventCreate(&stop3); cudaEventCreate(&start4); cudaEventCreate(&stop4); printf("INIZIO ALGORITMO!!!\n\n"); //Inizio calcolo tempo esecuzione totale // *** CUDA START TIME *** cudaEventRecord(start, 0); //Inizio calcolo tempo allocazione memoria // *** CUDA START TIME *** cudaEventRecord(start3, 0); //Allocazione memoria Gamma=(QVECT*)malloc(N*sizeof(QVECT)); Pi0=(PI_0*)malloc(10*sizeof(QVECT)); //Alloco dimensione vettore Pi0 con valore temporaneo // *** CUDA STOP TIME *** cudaEventRecord(stop3, 0); cudaEventSynchronize(stop3); cudaEventElapsedTime(&time[0], start3, stop3); //Inserisco dati nei quadrivettori for(i=0; i<N; i++){ Gamma[i].x=i; Gamma[i].y=i; Gamma[i].z=i; Gamma[i].Ene=i+3; Gamma[i].gen='a'; } //Inizio calcolo tempo procedura // *** CUDA START TIME *** cudaEventRecord(start4, 0); Vincenzo Bruscino 566/2763 Pagina 89 di 101 //Invoco la procedura createPI0 dimPi0=createPI0(Gamma,Pi0,N,massa0,delta); // *** CUDA STOP TIME *** cudaEventRecord(stop4, 0); cudaEventSynchronize(stop4); cudaEventElapsedTime(&time[1], start4, stop4); // *** CUDA STOP TIME *** cudaEventRecord(stop, 0); cudaEventSynchronize(stop); cudaEventElapsedTime(&time[2], start, stop); //Stampo tempi printf("TEMPO MALLOC: %f ms\n\n",time[0]); printf("TEMPO PROCEDURA: %f ms\n\n",time[1]); printf("TEMPO TOTALE: %f ms\n\n",time[2]); //Rilascio memoria allocata free(Gamma); free(Pi0); printf("FINE ALGORITMO!!!\n\n"); return 0; } int createPI0(QVECT *Gamma, PI_0 *Pi0, unsigned long int N, float M0, float del) { //Dichiarazione variabili int i, j=0, k=0; float massa; QVECT Candidato; //Ciclo per trovare le particelle giuste da combinare for(i=0;i<N-1;i++){ for(j=i+1;j<N;j++){ //Somma di due quadrivettori Candidato.x=Gamma[i].x+Gamma[j].x; Candidato.y=Gamma[i].y+Gamma[j].y; Vincenzo Bruscino 566/2763 Pagina 90 di 101 Candidato.z=Gamma[i].z+Gamma[j].z; Candidato.Ene=Gamma[i].Ene+Gamma[j].Ene; //Controllo massa massa=(Candidato.Ene*Candidato.Ene)-(Candidato.x*Candidato.x)(Candidato.y*Candidato.y)-(Candidato.z*Candidato.z); if(massa>M0-del && massa<M0+del){ Pi0[k].x=Candidato.x; Pi0[k].y=Candidato.y; Pi0[k].z=Candidato.z; Pi0[k].Ene=Candidato.Ene; Pi0[k].g1=i; Pi0[k].g2=j; k++; } } } //Ritorno il numero di PI 0 trovati return k; } A.4.2. Sorgente parallelo “par.cu” /* ALGORITMO PARALLELO PER IL CALCOLO DEI MESONI PI 0 */ #include <stdio.h> #include <stdlib.h> #include <math.h> #include <cuda_runtime.h> //Definizione strutture particelle typedef struct { //Quadrivettore gamma float Ene; //Energia float x; float y; float z; char gen; //Genere } QVECT; typedef struct { //PI 0 float Ene; Vincenzo Bruscino 566/2763 Pagina 91 di 101 float x; float y; float z; char gen; int g1; //Gamma 1 int g2; //Gamma 2 } PI_0; //Definizione di procedure __global__ void kernel(QVECT *, PI_0 *, unsigned long int, unsigned long int, float, float, int *); __device__ void createPI0(QVECT *, PI_0 *, unsigned long int, unsigned long int, float, float, int *); int main(int argc, char **argv){ //Dichiarazione variabili QVECT *Gamma_Host, *Gamma_Dev; PI_0 *Pi0_Dev, *Pi0_Host; unsigned long int N=20, coefBin; int i, *dimPi0_h, *dimPi0_d; float massa0=pow(4.5,2), delta=6, time[100]; // *** DEFINISCO VARIABILI CUDA TEMPO *** cudaEvent_t start, stop, start3, stop3, start4, stop4, start5, stop5, start6, stop6; // *** CREO OGGETTI TEMPO CUDA *** cudaEventCreate(&start); cudaEventCreate(&stop); cudaEventCreate(&start3); cudaEventCreate(&stop3); cudaEventCreate(&start4); cudaEventCreate(&stop4); cudaEventCreate(&start5); cudaEventCreate(&stop5); cudaEventCreate(&start6); cudaEventCreate(&stop6); printf("INIZIO ALGORITMO!!!\n\n"); //Inizio calcolo tempo esecuzione totale // *** CUDA START TIME *** cudaEventRecord(start, 0); Vincenzo Bruscino 566/2763 Pagina 92 di 101 //Calcolo del coefficiente binomiale coefBin=(N*(N-1))/2; //Inizio calcolo tempo allocazioni di memoria // *** CUDA START TIME *** cudaEventRecord(start3, 0); //Allocazione memoria vettori host e device Gamma_Host=(QVECT*)malloc(N*sizeof(QVECT)); cudaMalloc( (void**)&Gamma_Dev,N*sizeof(QVECT) ); cudaMalloc( (void**)&Pi0_Dev,10*sizeof(PI_0) ); //Dimensione temporanea dei PI 0 trovati dimPi0_h=(int*)malloc(sizeof(int)); //Dimensione vettore Pi0 cudaMalloc( (void**)&dimPi0_d,sizeof(int) ); // *** CUDA STOP TIME *** cudaEventRecord(stop3, 0); cudaEventSynchronize(stop3); cudaEventElapsedTime(&time[0], start3, stop3); //Inserisco dati nei quadrivettori dell'host for(i=0; i<N; i++){ Gamma_Host[i].x=i; Gamma_Host[i].y=i; Gamma_Host[i].z=i; Gamma_Host[i].Ene=i+3; Gamma_Host[i].gen='a'; } //Inizio calcolo tempo cudaMemcpy // *** CUDA START TIME *** cudaEventRecord(start4, 0); //Copio i dati dall'host al device cudaMemcpy(Gamma_Dev,Gamma_Host,N*sizeof(QVECT),cudaMemcpyHo stToDevice); // *** CUDA STOP TIME *** cudaEventRecord(stop4, 0); cudaEventSynchronize(stop4); cudaEventElapsedTime(&time[1], start4, stop4); Vincenzo Bruscino 566/2763 Pagina 93 di 101 cudaMemset( dimPi0_d,0,sizeof(int) ); //Setto valore iniziale a 0 //Inizio calcolo tempo procedura // *** CUDA START TIME *** cudaEventRecord(start5, 0); //Invoco la procedura kernel sul device kernel<<<dim3(1,1,1),dim3(190,1,1)>>>(Gamma_Dev,Pi0_Dev,N,coefBin,ma ssa0,delta,dimPi0_d); cudaThreadSynchronize(); //Barriera di sincronizzazione thread // *** CUDA STOP TIME *** cudaEventRecord(stop5, 0); cudaEventSynchronize(stop5); cudaEventElapsedTime(&time[2], start5, stop5); //Inizio calcolo tempo cudaMemcpy di ritorno // *** CUDA START TIME *** cudaEventRecord(start6, 0); //Copio i risultati dal device all'host cudaMemcpy(dimPi0_h,dimPi0_d,sizeof(int),cudaMemcpyDeviceToHost); Pi0_Host=(PI_0*)malloc(*dimPi0_h*sizeof(PI_0)); //Alloco memoria Pi 0 Host cudaMemcpy(Pi0_Host,Pi0_Dev,*dimPi0_h*sizeof(PI_0),cudaMemcpyDevice ToHost); // *** CUDA STOP TIME *** cudaEventRecord(stop6, 0); cudaEventSynchronize(stop6); cudaEventElapsedTime(&time[3], start6, stop6); // *** CUDA STOP TIME *** cudaEventRecord(stop, 0); cudaEventSynchronize(stop); cudaEventElapsedTime(&time[4], start, stop); //Stampo tempi printf ("TEMPO ALLOCAZIONI DI MEMORIA: %f ms\n\n", time[0]); printf ("TEMPO CUDAMEMCPY HOST TO DEVICE: %f ms\n\n", time[1]); Vincenzo Bruscino 566/2763 Pagina 94 di 101 printf ("TEMPO PROCEDURA: %f ms\n\n", time[2]); printf ("TEMPO CUDAMEMCPY DEVICE TO HOST: %f ms\n\n", time[3]); printf ("TEMPO TOTALE: %f ms\n\n", time[4]); //Rilascio memoria cudaFree(Gamma_Dev); cudaFree(Pi0_Dev); cudaFree(dimPi0_d); free(Gamma_Host); free(Pi0_Host); free(dimPi0_h); printf("FINE ALGORITMO!!!\n\n"); return 0; } __global__ void kernel(QVECT *Gamma, PI_0 *Pi0, unsigned long int N, unsigned long int N2, float M0, float del, int *dimPi0){ //Invoco procedura per il calcolo dei PI 0 createPI0(Gamma,Pi0,N,N2,M0,del,dimPi0); } __device__ void createPI0(QVECT *Gamma, PI_0 *Pi0, unsigned long int N, unsigned long int N2, float M0, float del, int *dimPi0){ //Dichiarazioni variabili locali sul device int linIdx, i, z, j, loc, sh, tid; float massa; QVECT Candidato; //Dichiarazione variabile condivisa sul device __shared__ int Ind; //Il primo thread del blocco inizializza la variabile shared if (threadIdx.x == 0) { Ind = *dimPi0; } __syncthreads(); //Barriera di sincronizzazione threads Vincenzo Bruscino 566/2763 Pagina 95 di 101 //Calcolo del thread ID sh=threadIdx.x+blockDim.x*threadIdx.y; tid = blockDim.x*blockDim.y*(blockIdx.x + gridDim.x* blockIdx.y) + sh; //Algoritmo per il calcolo degli indici giusti in base al thread if (tid<N2){ linIdx=N2-tid; i=int(N - 0.5 - sqrt(0.25 - 2 * (1 - linIdx))); z=(N+N-1-i)*i; j=tid - z/2 + 1 + i; if (i==j){ i=i-1; j=N-1; } //Somma di due quadrivettori Candidato.x=Gamma[i].x+Gamma[j].x; Candidato.y=Gamma[i].y+Gamma[j].y; Candidato.z=Gamma[i].z+Gamma[j].z; Candidato.Ene=Gamma[i].Ene+Gamma[j].Ene; //Controllo massa massa=(Candidato.Ene*Candidato.Ene)-(Candidato.x*Candidato.x)(Candidato.y*Candidato.y)-(Candidato.z*Candidato.z); if(massa>M0-del && massa<M0+del){ loc=atomicAdd(&Ind,1); Pi0[loc].x=Candidato.x; Pi0[loc].y=Candidato.y; Pi0[loc].z=Candidato.z; Pi0[loc].Ene=Candidato.Ene; Pi0[loc].g1=i; Pi0[loc].g2=j; } } __syncthreads(); //Il thread 0 di ogni blocco aggiorna la dimensione parziale dei PI 0 if(threadIdx.x==0) atomicAdd(dimPi0,Ind); } Vincenzo Bruscino 566/2763 Pagina 96 di 101 A.4.3. Sorgente “testMalloc.cu” /* PROGRAMMA DI TEST CUDAMALLOC */ #include <stdio.h> #include <stdlib.h> #include <cuda_runtime.h> int main(int argc, char **argv){ //Dichiarazione variabili float *testmalloc, time; cudaEvent_t start, stop; int N; if(argc==2){ //Controllo valore riga di comando N=atoi(argv[1]); //Converto la dimensione da testare printf("Ho letto: %d\n\n",N); //Creo eventi per il tempo cudaEventCreate(&start); cudaEventCreate(&stop); // *** CUDA START TIME *** cudaEventRecord(start, 0); cudaMalloc( (void**)&testmalloc,N*sizeof(float)); cudaEventRecord(stop, 0); cudaEventSynchronize(stop); cudaEventElapsedTime(&time, start, stop); printf ("TEMPO ALLOCAZIONE DI MEMORIA: %f ms\n\n", time); } else printf("ERRORE! Lanciare: ./TestMalloc <valore numerico>\n\n"); return 0; } Vincenzo Bruscino 566/2763 Pagina 97 di 101 A.4.4. Sorgente “testCpy.cu” /* PROGRAMMA DI TEST CUDAMEMCPY */ #include <stdio.h> #include <stdlib.h> #include <cuda_runtime.h> int main(int argc, char **argv){ //Dichiarazione variabili unsigned long int *device, *host; cudaEvent_t start, stop; float time; int N, i; if(argc==2){ //Verifico la presenza di un parametro passato in input N=atoi(argv[1]); printf("Ho letto: %d\n\n",N); printf("Dimensione unsigned long int: %d Byte\n",sizeof(unsigned long int)); printf("Dimensione di %d unsigned long int: %ld Byte\n\n",N, N*sizeof(unsigned long int) ); //Alloco memoria host=(unsigned long int*)malloc(N*sizeof(unsigned long int)); cudaMalloc( (void**)&device,N*sizeof(unsigned long int) ); for(i=0; i<N; i++) host[i]=i; //Creo eventi per il tempo cudaEventCreate(&start); cudaEventCreate(&stop); // *** CUDA START TIME *** cudaEventRecord(start, 0); cudaMemcpy(device,host,N*sizeof(unsigned long int),cudaMemcpyHostToDevice); cudaEventRecord(stop, 0); Vincenzo Bruscino 566/2763 Pagina 98 di 101 cudaEventSynchronize(stop); cudaEventElapsedTime(&time, start, stop); printf ("TEMPO CUDAMEMCPY: %f ms\n\n", time); free(host); cudaFree(device); } else printf("ERRORE! Lanciare: ./TestCpy <valore numerico>\n\n"); return 0; } A.4.5. Sorgente “testKernel.cu” /* PROGRAMMA DI TEST PER IL KERNEL */ #include <stdio.h> #include <stdlib.h> #include <cuda_runtime.h> __global__ void kernel(){} //Funzione kernel vuota int main(int argc, char **argv){ //Definizione variabili cudaEvent_t start, stop; float time; int bl1, bl2, bl3, th; if(argc==5){ //Controllo ricezione valori in input bl1=atoi(argv[1]); bl2=atoi(argv[2]); bl3=atoi(argv[3]); th=atoi(argv[4]); printf("Ho letto: dim3(%d,%d,%d),dim3(%d,1,1)\n",bl1,bl2,bl3,th); //Creo eventi per il tempo cudaEventCreate(&start); Vincenzo Bruscino 566/2763 Pagina 99 di 101 cudaEventCreate(&stop); // *** CUDA START TIME *** cudaEventRecord(start, 0); kernel<<<dim3(bl1,bl2,bl3),dim3(th,1,1)>>>(); //Lancio del kernel cudaThreadSynchronize(); //Barriera di sincronizzazione cudaEventRecord(stop, 0); cudaEventSynchronize(stop); cudaEventElapsedTime(&time, start, stop); printf("TEMPO ESECUZIONE KERNEL: %f ms\n\n", time); } else printf("ERRORE! Lanciare: ./TestKernel <blk1> <blk2> <blk3> <thr>\n\n"); return 0; } Vincenzo Bruscino 566/2763 Pagina 100 di 101 Bibliografia (1) Programming Massively Parallel Processors A Hands-on approach, David B. Kirk, Wen-mei W. Hwu (2) CUDA by Example An Introduction to General-Purpose GPU Programming, Jason Sanders, Edward Kandrot (3) NVIDIA CUDA C Programming Guide Version 4.0, NVIDIA (4) The CUDA Compiler Driver NVCC, NVIDIA (5) A Hierarchical NeuroBayes-based Algorithm for Full Reconstruction of B Mesons at B Factories, M. Feindt, F. Keller, M. Kreps, T. Kuhr, S. Neubauer, D. Zander, A. Zupanc Vincenzo Bruscino 566/2763 Pagina 101 di 101