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