indice - infn.it alla detection di onde gravitazionali ed in particolare del progetto virgo ed...

99
Indice Introduzione 3 Capitolo 1: Le onde gravitazionali 5 1.1 Introduzione alle onde gravitazionali.................................6 1.2 Fonti di emissione.............................................8 1.3 Sistemi binari coalescenti........................................9 1.4 Detection: rilevatori e Matched Filtering.............................11 1.5 Analisi del segnale proveniente da sistemi binari........................13 1.6 Analisi della scelta implementativa.................................19 Capitolo 2: I processori grafici: le GPU 21 2.1 Storia dei processori grafici......................................22 2.2 Classificazione delle architetture...................................24 2.3 Architettura di una GPU........................................26 2.4 GPGPU: General Purpose GPU computing............................29 2.5 CUDA....................................................30 2.6 OPENCL..................................................37 2.7 Differenze CUDA – OpenCL.....................................44 Capitolo 3: Descrizione dell'algoritmo 46 3.1 Inizializzazione del workspace....................................49 3.2 Calcolo del tempo di coalescenza..................................55 3.3 Generazione del template.......................................57 3.3.1 Codice Host.............................................57 3.3.2 Codice kernel............................................59 1

Upload: vananh

Post on 17-Feb-2019

218 views

Category:

Documents


0 download

TRANSCRIPT

Indice

Introduzione 3

Capitolo 1: Le onde gravitazionali 5

1.1 Introduzione alle onde gravitazionali.................................61.2 Fonti di emissione.............................................81.3 Sistemi binari coalescenti........................................91.4 Detection: rilevatori e Matched Filtering.............................111.5 Analisi del segnale proveniente da sistemi binari........................131.6 Analisi della scelta implementativa.................................19

Capitolo 2: I processori grafici: le GPU 21

2.1 Storia dei processori grafici......................................222.2 Classificazione delle architetture...................................242.3 Architettura di una GPU........................................262.4 GPGPU: General Purpose GPU computing............................292.5 CUDA....................................................302.6 OPENCL..................................................372.7 Differenze CUDA – OpenCL.....................................44

Capitolo 3: Descrizione dell'algoritmo 46

3.1 Inizializzazione del workspace....................................493.2 Calcolo del tempo di coalescenza..................................553.3 Generazione del template.......................................57

3.3.1 Codice Host.............................................573.3.2 Codice kernel............................................59

1

Capitolo 4: Testing dell'algoritmo 63

4.1 Accuracy test...............................................644.1.1 CPU vs GPU............................................644.1.2 CUDA vs OpenCL........................................66

4.2 Benchmark test..............................................684.2.1 CUDA vs OpenCL........................................704.2.2 CPU vs GPU............................................74

Capitolo 5: Conclusioni 76

Appendice A 79

Implementazione CUDA........................................79

Appendice B 86

Implementazione OpenCL.......................................86

Ringraziamenti 97

Bibliografia 98

2

3

Introduzione

La rilevazione di onde gravitazionali è tra le più grandi sfide che la ricerca scientifica

moderna si prefigge di vincere nei prossimi anni. Le onde gravitazionali, ipotizzate da Albert

Einstein nella Teoria della Relatività Generale, non sono ancora state oggetto di osservazione

diretta. Negli ultimi 10 anni sono entrati in funzione innovativi detector gravitazionali quali

ad esempio Virgo (INFN/CNRS) e LIGO (US) finalizzati a tale “prima” rilevazione.

Il presente lavoro di tesi è stato svolto presso l'INFN (Istituto Nazionale di Fisica Nucleare) di

Perugia e rientra nel progetto INFN MaCGO (Many-core Computing for future Gravitational

Observatories) che si occupa dello sviluppo di una libreria numerica su GPU e sistemi

manycore per l’analisi numerica dei dati propria dei detector Gravitazionali. Il lavoro, frutto

di questa collaborazione, sarà sfruttato nell'ambito dei diversi progetti internazionali rivolti

alla detection di onde gravitazionali ed in particolare del progetto VIRGO ed AdvancedVirgo.

L’attività svolta rientra inoltre nelle attività di design study per la parte di computing del

progetto Europeo Einsten Telescope.

La tecnica utilizzata nella elaborazione digitale e rilevazione di segnali gravitazionali, oggetto

di questa testi, si basa sul filtro digitale Matched-Filtering (MF) e metodo Neyman-Pearson.

Tale strategia risulta però computazionalmente onerosa, sia perché è necessario generare

migliaia o decine di migliaia di filtri ( detti “template”) per ogni ciclo di elaborazione e sia

perché l’elaborazione utilizza pesantemente FFT. Il presente lavoro è rivolto verso la prima

problematica sviluppando su GPU una libreria di generazione di filtri che altro non sono che

segnali gravitazionali di riferimento.

La GPU, acronimo per Graphics Processing Unit, è il processore grafico di una scheda video

per computer e console in grado di processare oltre 10 milioni di poligoni al secondo. Fino a

INTRODUZIONE

poco tempo fa le GPU erano esclusivamente utilizzate per le operazioni di rendering grafico,

ma negli ultimi anni si è cercato sfruttare le loro enormi potenzialità di calcolo anche in altri

settori. È così nato il General Purpose GPU computing (GPGPU), un settore della ricerca

informatica per l'utilizzo dei processori grafici per scopi diversi dalla tradizionale

elaborazione di applicazioni 3D. Difatti tutte le applicazioni che sono per loro natura di tipo

altamente parallelo possono beneficiare di una tale architettura. Attualmente l’orientamento

dell’HPC è fortemente orientato verso queste nuove tecnologie che permettono di ottenere un

fattore di guadagno in termini di prestazioni anche di due ordini di grandezza e di ridurre la

potenza per GFlops richiesta. Le GPU sono infatti, in un certo senso, sistemi many-core in

cui centinaia di cores sono presenti sulla stessa scheda e sono in grado di eseguire lo stesso

flusso di istruzioni su una molteplicità di dati in parallelo.

L'elaborato è così composto: nel capitolo 2 sono introdotti i concetti fondamentali riguardanti

le onde gravitazionali, facendo particolare riferimento ai sistemi binari coalescenti che sono

l'oggetto del generatore di segnali sviluppato; nel capitolo 3, invece, si focalizza l'attenzione

sull'architettura di una GPU e analizziamo due approcci differenti alla programmazione:

CUDA e OpenCL; nel capitolo 4 invece si riporta un'analisi dettagliata dell'algoritmo

sviluppato definendo la struttura del codice e descrivendo le singole macro-operazioni che

vengono elaborate; nel capitolo 5 sono riportati i risultati ottenuti dai test di accuratezza

numerica e prestazionale dell'algoritmo su GPU e confrontati con gli analoghi risultati ottenuti

su CPU, utilizzata come riferimento. Nell’ultimo capitolo si riportano le conclusioni di questo

lavoro. Nell'appendice, infine, sono presenti i codici CUDA e OpenCL sviluppati.

4

5

Capitolo 1

Le onde gravitazionali

L'obiettivo finale di questo elaborato è quello di presentare un nuovo algoritmo per la

generazione di segnali d'onda gravitazionale basato su un'architettura parallela in forte

espansione: le GPU.

Prima di passare all'analisi implementativa del codice, di cui si parlerà nel Capitolo 4, è

necessario fare una premessa sull'argomento, introducendo il concetto di onde gravitazionali,

o anche Gravitational Waveform (GW). Sebbene la loro presenza sia stata ipotizzata circa 90

anni fa, la loro individuazione risulta ancora oggi un grosso tabù che la scienza si è prefissata

di risolvere nell'arco di una decina di anni.

Lo scopo dei seguenti paragrafi è, pertanto quello di fare un identikit delle GW. Oltre a fare

una premessa sulle origini storiche, verranno messe a confronto le caratteristiche delle GW

con segnali d'onda ben più noti: quelli generati da onde elettromagnetiche (EMW). Verranno

definite le fonti di emissione e fra tutte analizzeremo una particolare sorgente di GW, quale la

coalescenza di due oggetti binari compatti in orbita tra di loro (coalescing inspiral compact

binaries). Di questi sistemi analizzeremo le caratteristiche della forma d'onda e forniremo

l'insieme di equazioni che ci permettono di descrivere un tale sistema. Infine illustreremo il

'perché' la rilevazione di GW ha suscitato tanto interesse nei ricercatori e il 'perché' si è deciso

di sfruttare le potenzialità computazionali delle GPU per la generazione di tali forme d'onda.

1.1 INTRODUZIONE ALLE ONDE GRAVITAZIONALI

1.1 Introduzione alle onde gravitazionali

Fu Albert Einstein nel 1916 a ipotizzare l'esistenza di tali onde come conseguenza della sua

rivoluzionaria teoria della relatività generale [1]. Secondo la sua teoria, mutazioni nella

concentrazione di masse (o energie) in una dimensione spazio-tempo, causa distorsioni che si

propagano nell'universo alla velocità della luce. Tali distorsioni sono l'effetto di quelle

radiazioni emesse da oggetti astrofisici che vengono definite onde gravitazionali.

Tuttavia all'epoca della nascita della teoria relativistica di Einstein, e quindi della “scoperta”

delle GW, non si disponevano di conoscenze tecnologiche adeguate e capaci di individuare

tali segnali. Sebbene il progresso scientifico e informatico e la ricerca svolta in questi settori

negli ultimi decenni abbia fatto grossi passi in avanti, a distanza di quasi un secolo non si è

ancora giunti a una prova tangibile della loro esistenza. Una prima individuazione indiretta

risale però al 1974 quando due scienziati, il professor Taylor e il suo allievo Hulse,

individuarono un sistema di due astri in orbita tra loro: si trattava di una pulsar, la

PSRB1913+16, ed un buco nero [2]. I due astronomi osservarono per molti anni il loro

comportamento e notarono che il periodo orbitale diminuiva col tempo. I due scienziati

sospettarono del fatto che tali cambiamenti nel periodo orbitale erano dovuti alla perdita di

energia emessa sotto forma di onde gravitazionali e provarono a ottenere riscontri con i

modelli matematici derivanti dalle approssimazioni post-newtoniane (PNA)1, riformulate da

Einstein. I risultati ottenuti da Taylor e Hulse valsero ai due il premio nobel per la fisica nel

1993.

Al giorno d'oggi sono stati individuati altri sistemi di astri simili a quello di Taylor e Hulse,

come ad esempio quello scoperto nel 2003 da un team di ricercatori e composto da due stelle

di neutroni piccolissime: PSR J0737-3039 [3].

Le GW non sono ancora state individuate direttamente, ma presumibilmente dovrebbero

esistere. Ma perché tali segnali rivestono un grande interesse? E perché andiamo alla ricerca

di questi segnali? L'individuazione di GW dimostrerebbe definitivamente la teoria della

relatività generale di Einstein e costituirebbero quindi la prova tangibile dell'esistenza di un

1 Formalismo che approssima le equazioni di Einstein sviluppando i coefficienti della metrica e la velocità dei corpi

6

1.1 INTRODUZIONE ALLE ONDE GRAVITAZIONALI

forte campo gravitazionale. Inoltre si conquisterebbe una nuova visione dell'intero universo e

sulla sua storia, e si aprirebbero le porte ad una nuova gamma di radiazioni oltre a quelle già

sfruttate e identificate dallo spettro elettro-magnetico.

Le onde gravitazionali sono simili alle onde elettro-magnetiche, con la differenza che le

EMW sono forme d'onda prodotte da magneti caricati elettricamente che generano un moto di

cariche che si propagano nello spazio alla velocità della luce, le onde gravitazionali sono

invece generate dal moto di masse (pianeti o astri in generale) e emettono radiazioni che si

propagano in una dimensione spazio-temporale alla velocità della luce. L'effetto di un'onda

gravitazionale è quello di incidere sulla curvatura spazio-tempo dell'universo. Tuttavia

l'ampiezza di queste onde è infinitesimale. Data la loro natura quadrupolare (a differenza delle

EMW caratterizzate da una polarizzazione di dipolo), le GW interagiscono con la materia

tramite due polarizzazioni di quadrupolo, definite plus(h+) e cross(hx) e causano distorsioni

spazio-temporali su di essa. Per spiegare meglio questo concetto facciamo un esempio.

Si dispongano ad anello un insieme di particelle su di un piano xy (illustrazione 1.1), e si

ipotizzi l'arrivo di un'onda gravitazionale, in direzione perpendicolare al piano lungo z. Nel

caso in cui l'onda abbia solo polarizzazione h+, si osserva una deformazione dell'anello lungo

gli assi principali, mentre per onda a polarizzazione hx, si osservano deformazioni lungo le

diagonali principali. Questo fenomeno è dovuto al fatto che le due polarizzazioni sono ruotate

l'una rispetta all'altra di 45 gradi.

7

Illustrazione 1.1: Effetto di un'onda gravitazionale

1.1 INTRODUZIONE ALLE ONDE GRAVITAZIONALI

La perturbazione è proporzionale all'ampiezza dell'onda gravitazionale e alla massa della

sorgente di gravitazione. Tuttavia l'ampiezza delle GW è infinitesimale e anche le fonti di

emissione più considerevoli hanno un'ampiezza generalmente dell'ordine di 10-21 e tale da

causare distorsioni molto piccole (10-18 metri, circa 1/1000 il diametro del protone),

impercettibili all'occhio umano ma che l'uomo potrebbe captare grazie alla costruzione di una

strumentazione in grado di realizzare una così precisa misurazione[2].

Le GW hanno inoltre una frequenza generalmente di molto inferiore delle EMW(in media al

disotto dell'ordine del Khz contro le decine di Mhz delle EMW) e pur trasportando una grossa

quantità di energia, la loro interazione con la materia è molto debole. Da questo si capisce la

grossa difficoltà della loro individuazione. Tuttavia negli ultimi anni ci sono stati dei

progressi, grazie alla realizzazione di detector sempre più sensibili (anche se siamo ancora

alla produzione di esemplari di prima generazione, e verso nuove macchine di seconda e terza

generazione) e allo sviluppo di modelli matematici, le Post-Newtonian Approximations

(PNA) [4], sempre più accurati applicabili a tale strumentazione. Le approssimazioni PN,

derivanti da modelli teorici, sono infatti in continua espansione. Difatti si parla di diversi

livelli di approssimazione; ad ogni livello corrisponde un fattore dell'equazione, con il livello

più basso, PN0, che rappresenta il fattore dominante.

Secondo le stime di alcuni scienziati, si pensa che nei prossimi dieci anni si riesca ad ottenere

una prova concreta della esistenza di GW, grazie all'avanzamento tecnologico nella

realizzazione dei detector e nello stesso tempo di modelli matematici e software da applicare a

questi.

1.2 Fonti di emissione

Ma quali sono le sorgenti che generano queste onde gravitazionali [5]?Si riconoscono varie

fonti che possono emettere questo tipo di radiazioni. Alcune di queste si prestano a una

possibile individuazione in quanto sono abbastanza predicibili, altre invece sono molto più

difficili da analizzare a causa di una forte imprevedibilità del sistema. Tra queste ultime

troviamo innanzitutto fonti di natura stocastica, derivanti dall'origine del cosmo

8

1.2 FONTI DI EMISSIONE

(cosmological background signals). Esempio di questa categoria è il famigerato Big Beng che

risulterebbe una delle prime sorgenti di onde gravitazionali (e probabilmente non la

primissima in assoluto). Altro fenomeno in cui si manifesta una buona emissione di GW

deriva dal collasso di una supernova, che porta alla formazione di una stella di neutroni: in

questo caso si parla di burst signal. Un tale evento avviene mediamente una volta ogni trenta

anni e ha una durata molto breve, risulta pertanto difficilmente predicibile e individuabile.

Una delle fonti che si potrebbe prestare a una buona probabilità di individuazione deriva da

isolated spinning neutron stars(stelle di neutroni rotanti e isolate), anche denominate pulsar.

Tali segnali sono periodici (periodic signal) e hanno il vantaggio di rimanere osservabili per

tempi molto lunghi. Questa caratteristica aumenta la speranza di rilevabilità delle onde

gravitazionali emesse.

Ma la nostra attenzione si focalizzerà su un'altra fonte, tra tutte probabilmente la più

interessante per applicabilità ai detector di prima generazione e particolarmente adatte a

VIRGO e del cui genere si è già avuto una prova indiretta grazie al lavoro dei sopracitati

Taylor e Hulse: coalescing inspiral non-spinning compact binaries, ovvero la coalescenza di

sistemi binari compatti.

1.3 Sistemi binari coalescenti

Il lavoro di tesi qui presentato, ha pertanto lo scopo di simulare il segnale gravitazionale

generato da coalescing inspiral non-spinning compact binaries, ovvero sistemi binari

compatti.

Tali sistemi sono composti da due masse, estremamente dense, come stelle di neutroni, buchi

neri e nane bianche(neutron stars2, black hole3, white dwarf4), in orbita tra loro, che ruotando

perdono energia, emessa sotto forma di radiazioni gravitazionali. A causa della variazione nel

2 Una stella compatta in cui il peso della stella è supportato dalla pressione di neutroni liberi. Hanno una massa simile a quella del Sole

3 Un corpo celeste dotato di una velocità di fuga dalla propria superficie maggiore della velocità della luce,tale da non permettere l' allontanamento di alcunché dalla propria superficie. Tale oggetto sarebbe quindi invisibile e rilevabile solo tramite glie effetti del suo enorme campo gravitazionale.

4 Una stella di bassa luminosità, dotata di un'altissima densità e gravità superficiale.

9

1.3 SISTEMI BINARI COALESCENTI

tempo della frequenza orbitale che tende a crescere, il raggio orbitale, inteso come la distanza

tra le due masse, di conseguenza tende a diminuire. I due corpi quindi si avvicinano sempre

più fino ad arrivare alla coalescenza. A questo punto essi finiscono per fondersi e danno vita a

un nuovo corpo, solitamente un Black Hole.

Da questo moto di sistemi binari compatti si distinguono tre fasi:

– inspiral: i due corpi ruotano a una distanza considerevolmente elevata; il segnale

d'onda ha un andamento predicibile e ben descritto dalle approssimazioni post-

newtoniane. Ha la forma d'onda di un inviluppo in frequenza ed ampiezza (chirp

signal);

– merger: quando la frequenza orbitale aumenta considerevolmente, le due masse

arrivano a una distanza relativamente piccola fino ad arrivare alla fusione e alla

formazione di un nuovo corpo. In questa fase il segnale è irregolare e fortemente

dipendente dai dettagli della collisione ed è stato recentemente studiato con i metodi

della relatività numerica;

– ringdown: il nuovo corpo generato dalla fusione, generalmente un buco nero

massiccio estremamente denso, raggiunge uno stato di equilibrio; la forma d'onda

assume le caratteristiche di una sinusoide decrescente.

Il presente lavoro è volto alla riproduzione della forma d'onda del segnale nella sola fase di

inspiral.

La predicibilità di un sistema binario compatto dipende dalle caratteristiche distintive del

10

Illustrazione 1.2: Moto di un sistema binario coalescente

1.3 SISTEMI BINARI COALESCENTI

sistema e in particolare dalla sua massa totale. Infatti la probabilità di individuazione è

direttamente proporzionale alla massa totale del sistema. La massa di un BH, che può arrivare

fino a 30 volte la massa solare, è di molto superiore a quella di una NS, 1-3 volte la massa del

sole. È per questo che un sistema BH-BH risulterebbe più predicibile e individuabile. Tuttavia

tali sistemi non sono ancora stati bene identificati, mentre si sono raggiunte buone conoscenze

riguardanti i sistemi composti da due NS.

1.4 Detection: rilevatori e Matched Filtering

Per riprodurre e successivamente individuare un segnale di questo tipo abbiamo bisogna di un

algoritmo esegua la cosiddetta tecnica di Matched Filtering, ma anche di strumentazione

(detector o interferometri) altamente sofisticata e precisa, su cui applicare il codice

implementato. Il software è naturalmente fortemente dipendente dall'hardware, oltre che a una

buona conoscenza della forma d'onda del segnale. Si ha pertanto bisogno di un detector

alquanto complesso e di grandi dimensioni. La costruzione di tali strumenti non è semplice e

richiede una massima accuratezza in ogni dettaglio. I primi progetti di costruzione, iniziati

negli anni sessanta, si basavano su rilevatori a barre risonanti. Questo tipo di rilevatori è

capace di osservare fenomeni con una frequenza maggiore a 1 Khz. Attualmente si è orientati

verso la realizzazione di rilevatori interferometrici, che sfrutta una tecnica basata

sull'interferenza dei raggi laser. A questa generazione di rilevatori appartengono i progetti

VIRGO [6] (in cui rientra il progetto che stiamo presentando), LIGO [7], GEO e TAMA che

possono intercettare fenomeni con bande di frequenze più basse, che vanno da qualche Hz

fino a 10 Khz. Tali interferometri sono ancora esemplari di prima generazione, ma si sta

investendo in questo settore per creare dei prototipi di seconda e poi terza generazione. A

questi va aggiunto il progetto LISA [8] per la costruzione di un interferometro spaziale, in

grado di esplorare le bande di frequenza da 10-4 Hz a 10-1 Hz.

Per la realizzazione di interferometri il disegno base è quello dell’interferometro di Michelson

(illustrazione 1.3): una sorgente laser che invia un fascio luminoso collimato verso uno

specchio semitrasparente (beam splitter) in posizione centrale; due specchi piani posti al

11

1.4 DETECTION: RILEVATORI E MATCHED FILTERING

termine di due

percorsi ortogonali a partire dallo specchio centrale; un misuratore d’intensità luminosa

(fotodiodo) disposto in modo da completare una croce insieme agli altri quattro elementi

ottici. Tali interferometri ricoprono un'area molto vasta, per esempio VIRGO è composto da

due bracci ortogonali della lunghezza di 3Km l'uno.

Lo scopo del presente lavoro è però quello di creare uno strumento software da applicare al

tipo di detector appena introdotto, al fine di rilevare la presenza di segnali d'onda

gravitazionale. Per l'identificazione di GW si utilizza una tecnica di Matched Filtering (MF).

Dato un campione del segnale h(t) che vogliamo cercare in una sequenza di dati caratterizzati

da un rumore n(t), si può dimostrare che la funzione che massimizza il rapporto segnale-

rumore è data da:

c t =∫ N f ∗H f S f

∗e−∞ dt

Dove H(f) e N(f) i corrispettivi del segnale e del rumore nel dominio delle frequenze e S(f) è

il power spectrum del rumore. I valori restituiti c(t) sono legati al rapporto SNR(Signal to

12

Illustrazione 1.3: Schema di un interferometro di Michelson

1.4 DETECTION: RILEVATORI E MATCHED FILTERING

Noise Ratio) fra il segnale e il rumore. Maggiore è l'altezza del segnale in uscita e maggiore

sarà la probabilità che nella posizione specifica sia presente il segnale cercato.

Quindi, più in generale, per la detection di GW si crea un algoritmo che opera nel seguente

modo:

1. genera un numero elevato di templates (segnali di riferimento) tale da produrre una

griglia che copre lo spazio di parametri su cui effettuare l'indagine;

2. eseguire la procedura di MF per ogni segnale di riferimento;

3. prelevare il picco massimo fra tutti i massimi registrati.

Durante la fase di MF, entrano in gioco delle teorie probabilistiche sull'evento e risulta

indispensabile valutare ed eliminare la presenza di rumori che potrebbero interferire nella

ricerca. Le principali fonti di rumore sono il rumore sismico, il rumore termico e lo shot

noise(derivante dal fascio luminoso). Questi rumori si possono attenuare in fase di

progettazione del rilevatore. Dal momento che l'intensità del segnale gravitazionale è molto

bassa, questa fase di eliminazione dei rumori è essenziale per aumentare la SNR e quindi per

l'individuazione di GW. Nel caso di VIRGO, dai test effettuati si sono potuti apprezzare ottimi

risultati che parlano di una quantità di rumore inferiore alla soglia stimata in fase di

progettazione.

Per ottenere un riscontro più attendibile dell'algoritmo di MF, la politica che si sta attuando è

quella di testare lo stesso segnale su diversi detector, posizionati in diverse parti del pianeta,

sfruttando le potenzialità di un sistema GRID, per stabilire se c'è coerenza con i risultati

ottenuti e definire con più precisione la probabilità che l'evento si sia verificato.

1.5 Analisi del segnale proveniente da sistemi binari

Lo scopo di questo lavoro, è quello di ottimizzare la fase di generazione dei segnali di onda

gravitazionale. Ottenere ciò, prescinde da una buona conoscenza della natura del segnale che

si vuole trattare [4]. Pertanto passiamo ora a una più precisa definizione di come si

caratterizzano le forme d'onda che andremo ad analizzare.

L'output del detector può essere sintetizzato come sovrapposizione del segnale d'onda

13

1.5 ANALISI DEL SEGNALE PROVENIENTE DA SISTEMI BINARI

gravitazionale h(t) e del rumore n(t):

Dal momento che il segnale gravitazionale è generalmente molto debole, risulta

fondamentale, ai fini della rivelabilità dell'onda gravitazionale, ridurre al minimo tali rumori

in fase di progettazione e realizzazione del detector.

Ma ovviamente, oltre alla riduzione del rumore, risulta fondamentale una corretta

riproduzione del segnale che deriva da una conoscenza approfondita della forma d'onda.

Il segnale gravitazionale è espresso come composizione delle due polarizzazioni di

quadrupolo h+ e hx:

dove h+ e hx rappresentano l'ampiezza delle due polarizzazioni e F+ e FX sono le cosiddette

beam-pattern function specifiche per il detector, che dipendono dalla posizione della sorgente

rispetto al detector. Nel caso del laser-interferometro VIRGO, esse sono definite come:

Le equazioni delle due polarizzazioni, h+ e hx, sono derivate dalle approssimazioni newtoniani

al primo ordine (PN0) nel seguente modo:

dove G è le costante gravitazionale e c la velocità della luce, i è l'angolo di incidenza, m è la

massa totale del sistema (m = m1 + m2 ), mentre ω e φ sono rispettivamente la frequenza e la

fase orbitale. Dalle formule delle due polarizzazioni, si possono isolare quindi alcuni termini,

14

[1.1]

[1.2]

[1.3]

[1.4]

1.5 ANALISI DEL SEGNALE PROVENIENTE DA SISTEMI BINARI

ottenendo un'espressione semplificata:

dove A è una costante che dipende dalla distanza del sistema dall'interferometro, mentre i due

fattori variabili che caratterizzano le polarizzazioni sono:

– la fase : ϕ(t), che dipende da m e da i;

– l'ampiezza : h(t), che dipende da m e da R.

Sia la fase che l'ampiezza sono esprimibili indipendentemente come approssimazioni PN.

Dal momento che il segnale h(t) può essere calcolato a diversi gradi di approssimazione, esso

è più generalmente espresso dalla seguente formula:

con le approssimazioni PN così definite, per la plus polarization:

15

[1.6]

[1.5]

1.5 ANALISI DEL SEGNALE PROVENIENTE DA SISTEMI BINARI

e per la cross polarization:

16

[1.8]

[1.7]

1.5 ANALISI DEL SEGNALE PROVENIENTE DA SISTEMI BINARI

dove:

• ν = (m1 * m2)/(m1 +m2) è la massa ridotta,

• m = m1 +m2 è la massa totale del sistema,

• δm = m1 - m2 è la differenza tra le masse,

• ϕ è la fase.

Il valore di ϕ è calcolato in funzione di ω e φ e viene espresso come:

dove ω e φ sono la fase e la frequenza istantanea e ω0 è la frequenza dell'onda all'istante t0 e

sarà presa uguale alla frequenza di taglio inferiore del detector.

Le approssimazioni di fase e frequenza sono descritte dalle seguenti equazioni:

17

[1.9]

[1.10]

[1.11]

1.5 ANALISI DEL SEGNALE PROVENIENTE DA SISTEMI BINARI

dove τ è la variabile temporale adimensionale legata alla variabile temporale t dalla seguente

equazione:

dove tc è il tempo di coalescenza, ovvero la durata della fase di inspiral del segnale (ref. 1.3).

Risulta quindi evidente dalle precedenti equazioni, che più ci avviciniamo alla coalescenza e

più l'ampiezza del segnale aumenta, diventando massima pochi istanti prima della fusione.

La forma d'onda risultante ha visualmente una forma di inviluppo in frequenza e in ampiezza,

che denota come più ci avviciniamo all'istante di coalescenza più l'intensità del segnale

aumenta (vedi illustrazione 1.4).

Dalle premesse fatte, risulta necessario per il calcolo delle due polarizzazioni calcolare la fase

e la frequenza orbitale del sistema, che prescinde dall'aver stimato la durata del segnale e

quindi il tempo di coalescenza.

Il calcolo del tempo di coalescenza può essere risolto usando diversi metodi. Nel nostro caso

si è utilizzato il metodo di “Newton-Raphson”5 che si traduce sostanzialmente nel trovare lo

zero della equazione della frequenza angolare, calcolata in un intervallo di tempo

sufficientemente grande, e quindi bisogna risolvere:

w(τ )=w0=2*π*f0 → w(τ ) - 2*π*f0 = 0

[1.13]

Quindi definita una w0 di partenza (uguale alla frequenza di taglio del detector) otteniamo

tramite la [1.11] il valore di τ0. Sostituendo alla [1.12] il vaore di τ0, appena trovato, ricaviamo

il tempo di coalescenza tc, che definisce la durata del segnale. A questo punto, una volta

calcolato il tempo di coalescenza, siamo in grado di generare i templates che stavamo

cercando, calcolando prima i valori di fase φ e frequenza ω istantanei, tramite la [1.10] e la

5 Anche detto metodo delle tangenti, è uno dei metodi usati per il calcolo approssimato della radice di un'equazione del tipo : f(x)=0. Esso si applica in un intervallo [a,b] in cui la funzione è continua e derivabile e le sue derivate prima e seconda esistono e sono diverse da zero.

18

[1.12]

1.5 ANALISI DEL SEGNALE PROVENIENTE DA SISTEMI BINARI

[1.11], e da questi la fase ϕ [1.9], per poi risolvere le equazioni delle due polarizzazioni h+ e

hx tramite le approssimazioni PN [1.7] e [1.8] e salvare i risultati in uscita (che costituiranno i

nostri templates) in appositi vettori.

1.6 Analisi della scelta implementativa

La corretta generazione di templates accurati permette di ricreare fedelmente il segnale.

Bisogna tener presente che la ricerca di questi tipi di segnali necessita di una potenza e

velocità di calcolo notevoli. La stessa generazione di template necessita di un'elevata capacità

computazionale. Difatti, la tecnica di MF richiede la generazione di un elevato numero di

segnali di riferimento, che per i detector di prossima generazione, potranno essere dell'ordine

di 10000-1000000. Devono perciò essere utilizzati strumenti hardware e software dedicati. In

questo lavoro di tesi, mi sono appunto dedicato alla fase di generazione dei templates,

ottimizzando questa parte.

Dal momento che le normali tecniche di programmazione basate su sistemi CPU multi-core

19

Illustrazione 1.4: Forma d'onda del segnale nella fase di inspiral

1.6 ANALISI DELLA SCELTA IMPLEMENTATIVA

presentano comunque dei limiti, quando si vuole eseguire parallelamente la stessa

elaborazione su una grossa mole di dati, si è cercato di puntare su un nuovo tipo di

architettura che può apportare enormi benefici sulle prestazioni: quella basata su GPU. Infatti

le GPUs, costituiscono un'architettura many-core che permette l'elaborazione di un elevato

numero di dati in parallelo, offrendo quindi una potenza di calcolo di molto superiore (in

media dai 10 ai 100x) rispetto all'architettura multi-core basata su CPU più performante

presente sul mercato.

20

21

Capitolo 2

I processori grafici: le GPU

Nelle ultime decine di anni, gli astronomi sono riusciti a trarre giovamenti dal crescente

aumento delle prestazioni delle CPU, che seguendo la legge di Moore6 raddoppiavano la loro

velocità ogni due anni. Tuttavia una tale tecnologia è praticamente satura perché si è ormai

arrivati quasi al limite prestazionale, dovuto al fatto che non è possibile aumentare

smisuratamente la frequenza del clock di una CPU perché porterebbe sia ad un enorme

consumo energetico che ad un'elevata generazione di calore ed inoltre esistono dei limiti

dovuti alle tecniche di miniaturizzazione. Pertanto è da qualche anno in atto una radicale

mutazione nell'architettura di un computer, che porterà ulteriori miglioramenti nelle

prestazioni grazie allo sviluppo del General Purpose GPU computing. Una GPU, acronimo

per Graphics Processing Unit, è un coprocessore della CPU basato su un'architettura parallela,

ovvero in grado di svolgere un elevato numero di operazioni in virgola mobile su diversi dati

in parallelo. Le GPU sono state in principio ideate e progettate per il rendering grafico e

difatti costituiscono le schede grafiche per computer e console. Fino a poco tempo fa il loro

compito era strettamente legato allo svolgimento di operazioni di decoding di file audio e

video, ma negli ultimi anni si è cercato di sfruttare le loro potenzialità anche in altri settori,

trovando applicazioni in molti campi come ad esempio l'esplorazione petrolifera,

l'elaborazione scientifica, ricerca medica e perfino la determinazione del prezzo delle opzioni

6 Le prestazioni dei processori, e il numero di transistor ad esso relativo, raddoppiano ogni 18 mesi.

2 I PROCESSORI GRAFICI: LE GPU

sulle azioni di borsa.

Sostanzialmente l'idea di GPU nasce in necessità del fatto che alcuni calcoli, come quelli

necessari per la resa grafica, graverebbero troppo su una normale CPU. Pertanto si è orientati

verso processori specifici a svolgere questo tipi di operazioni. Le GPU moderne, sebbene

operino a frequenze più basse delle normali CPU (e quindi consumano meno watt di potenza e

dissipano meno calore), sono molto più veloci di esse nell'eseguire i compiti in cui sono

specializzate ed offrono inoltre una valida soluzione low-cost.

Da vari test si è potuto evincere che un porting di codice da CPU a GPU può portare

giovamenti sulle prestazioni di un fattore 10x o addirittura di 100x e si pensa che entro il 2016

si possa arrivare fino a 1000x. Tuttavia non bisogna generalizzare questo dato, in quanto il

porting di codice da CPU a GPU può risultare non banale e soprattutto non tutti i problemi si

prestano a una buona “parallelizzazione”. Questo lavoro di tesi ha utilizzato come riferimento

di codice CPU per il generatore, quello realizzato dal Dr. Giancarlo Cella (INFN Pisa).

In questo capitolo analizzeremo gli aspetti fondamentali che riguardano le GPU, partendo

dalle premesse storiche sulla loro nascita e l'origine del loro sviluppo, per poi andare a

definire nello specifico il tipo di architettura su cui si basano, le caratteristiche hardware, e

proponendo due diversi approcci alla programmazione su GPU: CUDA e OpenCL.

2.1 Storia dei processori grafici

I primi chip grafici, di tipo monolitico, risalgono agli anni '80 e disponevano di un set limitato

di funzioni per l'elaborazione bidimensionale dell'immagine. A inizi degli anni '90 si inizia a

pensare all'idea di un processore dedito alle operazioni di rendering grafico. Si iniziano così a

produrre delle vere e proprie CPU disegnate e programmate per fornire le funzioni di disegno

utilizzate da applicazioni di tipo Computer Aided Design (CAD). Tuttavia questa tecnologia è

stata sorpassata dall'avvento dei primi chip grafici integrati per l'accellerazione 2D, che erano

meno flessibili di una CPU, ma di maggiore semplicità di fabbricazione e a costi minori. Una

grande svolta evolutiva è data soprattutto dall'industria dei videogame che ha spinto i

produttori ad integrare chip grafici in grado di fornire un set di funzioni per l'accelerazione

22

2.1 STORIA DEI PROCESSORI GRAFICI

3D. Nascono così le libreria OpenGL e DirectX, di MicrosoftWindows la cui crescita segue il

passo dell'evoluzione dell'hardware. Si arriva così alla fine degli anni 90 all'idea odierna di

GPU, grazie all'introduzione di shader programmabili per le funzioni grafiche. Se dal punto di

vista della computer graphics, gli shader servono a definire l'aspetto finale dell'oggetto sulla

superficie di disegno, dal punto di vista software essi costituiscono un insieme di istruzioni

che operano su pixel o vertici di un oggetto. Con il passare del tempo si sono aggiunte diverse

funzionalità agli shader che hanno acquistato la capacità di compiere cicli e un grosso numero

di operazione in virgola mobile, tanto da diventare flessibili tanto quanto una CPU. Scaturisce

proprio dagli shader prommabili quello che noi oggi definiamo General Purpose on GPU

computing, che si sviluppa concretamente a partire dalla creazione di shader unificati. Da qui

deriva la possibilità di esportare l'applicabilità delle GPU. È stato così possibile sfruttare le

potenzialità di una GPU non più solo per l'accelerazione 3D per console, computer e

workstation, ma anche in altri settori.

L'introduzione degli shader programmabili e il primo esempio di GPU viene dato da NVIDIA,

nel 1999. Successivamente ATI, oggi ormai inglobata da AMD, ha aggiunto innovazioni

tecnologiche e la forte concorrenza nel settore tra queste due aziende ha permesso una rapida

crescita evolutiva del General Purpose GPU computing (GPGPU).Basta pensare che le GPU

hanno fatto registrare un raddoppio di prestazioni ogni 6 mesi e se confrontiamo questo dato

con quello relativo alle CPU che per decenni hanno seguito la legge di Moore, notiamo come

l'evoluzione statistica di una GPU è di tre volte superiore rispetto alla rivale CPU (

illustrazione 2.1). Quest'ultima è oltretutto quasi arrivata al limite tecnologico, con frequenza

massima proponibile sul mercato attorno ai 3GHz, soglia dettata da un alto consumo di

energia e soprattutto da una elevata dispersione di calore. Le GPU invece lavorano a

frequenze più basse e riescono ad ottenere un massimo picco di performance dell'ordine dei

Tflop/s contro i 12 Gflops di una normale CPU moderna (Pentium IV).

Al giorno d'oggi ci sono più aziende che producono GPU (Intel, AMD, NVIDIA...) ma

ognuna applica politiche dal punto di vista dell'architettura dell'hardware differenti. Si sono

inoltre creati diversi ambienti di sviluppo software per GPU, di cui noi andremo ad analizzare

i due più significativi:

• CUDA, sviluppato da NVIDIA per la programmazione delle prorpie sche grafiche

prodotte, che è stato il primo framework e tuttora il più diffuso e utilizzato;

• OpenCL, riconosciuto come standard ufficiale e proposto dalla Kronos Group

23

2.1 STORIA DEI PROCESSORI GRAFICI

(associazione delle maggiori aziende nel settore della computer graphics, tra cui

Apple, NVIDIA, AMD/ATI...) che ha una sintassi leggermente più complessa di

CUDA, perchè richiede una serie di istruzioni di inizializzazione, che sono oscurate

dalla programmazione in ambiente CUDA; esso però si propone come unica

interfaccia di programmazione verso tutti gli ambienti multi e many core.

2.2 Classificazione delle architetture

Secondo la classificazione di Micheal J. Flynn si distinguono quattro diversi modelli di

sviluppo di un'architettura di un processore, che si differenziano per il flusso di istruzioni che

l'elaboratore può processare ad ogni istante e il flusso dei dati su cui può operare

24

Illustrazione 2.1: Evoluzione di CPU e GPU a confronto

2.2 CLASSIFICAZIONE DELLE ARCHITETTURE

simultaneamente. Dalla combinazione di queste due caratteristiche si definiscono le categorie

di Flynn, che sono:

– SISD: (Single Instruction Single Data) è la classica architettura della macchina di Von

Neumann, su cui si basano i convenzionali calcolatori. Un singolo flusso di istruzioni

viene eseguito sequenzialmente, su un singolo dato alla volta, senza implementare

nessun grado di parallelismo;

– SIMD: (Single Instruction Multiple Data) a questa categoria appartengono le

architetture composte da più unità di elaborazione, che eseguono lo stesso flusso di

istruzioni su dati diversi. Questo schema consiste di un processore principale che invia

lo stesso flusso di istruzioni da eseguire contemporaneamente da un insieme di unità di

elaborazione, che hanno il compito di eseguire le operazioni in parallelo;

– MISD: in questa tipo di architettura diverse unità di elaborazione operano

contemporaneamente sullo stesso dato. Una tale struttura non è ancora stata

implementata per essere commercializzata ma è stata sviluppata solo per scopi di

ricerca scientifica in alcuni super-computer;

– MIMD: rappresenta un evoluzione del SISD. In questo tipo di architettura più processi

sono attivi contemporaneamente su più processori, che utilizzano aree di memoria

proprie o condivise. Questo modello si implementa interconnettendo un numero

elevato di elaboratori di tipo convenzionale. Rientrano in questa categoria i sistemi di

calcolo a multiprocessori e sistemi di calcolo distribuiti.

Le categorie SIMD e MIMD si definiscono architetture parallele. In realtà, le GPU di

NVIDIA hanno sviluppato un'architettura parallela che non rientra propriamente nella

classificazione appena citata, e che viene denominata SIMT (Single Instruction Multiple

Thread). In effetti la stessa istruzione, o meglio flusso di istruzioni, viene eseguita non su una

molteplice di dati, bensì da una molteplicità di thread. Un'architettura SIMT richiede una

maggiore logica di controllo, rispetto alla convenzionale SIMD, che porta come conseguenza

un aumento della superficie del chip e un maggior consumo energetico. Ma a differenza della

SIMD, quest'architettura è in grado di massimizzare l'uso delle proprie unità di calcolo,

soprattutto quando l'applicazione è composta da un elevato numero di threads. Infatti le GPU

sfruttano appieno le proprie potenzialità quando il numero dei thread è dell'ordine delle

migliaia, permettendo in questo modo di avere un costo di creazione e di gestione degli stessi

praticamente nullo.

25

2.3 ARCHITETTURA DI UNA GPU

2.3 Architettura di una GPU

Le GPU di NVIDIA costituiscono un'architettura parallela multithread e manycore. Le

componenti principali sono Scalable Processor Array (SPA), che si occupa di eseguire tutte le

operazioni programmabili sulla GPU, e una Dynamic Random Access Memory (DRAM). Le

GPU necessitano anche di un Host Interface che si occupa delle operazioni tra l'Host e il

Device, ovvero tra la CPU e la periferica che si vuole interconnettere (la GPU nel nostro

caso). I suoi compiti sono quelli di trasferire i dati da e verso la CPU, interpretare i comandi

dell'host e verificare la consistenza di questi. Successivamente il lavoro passa al Compute

work distribution (CWD) che distribuisce il flusso di istruzioni sullo SPA. Il flusso di

istruzioni eseguito sul device prende il nome di kernel. Il Pixel e Vertex work distribution

svolgono un ruolo simile ma vengono utilizzati esclusivamente per operazioni di rendering

grafico, così come il Raster operation processor che opera direttamente con la DRAM e

svolge le operazioni di colorazione dei pixel. All'interno dello SPA sono presenti due Stream

Multiprocessor (SM).

In tutto, in una Tesla C1060 [9], sono presenti 30 SM, ognuno dei quali aventi 8 Stream

Processor (SP), per un totale di 240 cores. Ogni SM, ha inoltre al suo interno due Special

Function Unit (SFU), per eseguire operazioni complesse come funzioni logaritmiche,

26

Illustrazione 2.2: Architettura di una Tesla C1060

2.3 ARCHITETTURA DI UNA GPU

esponenziali, trigonometriche, una MTI (Multi Thread Injstruction fetch and Issue unit) che

serve a distribuire il carico dei thread all'interno di uno stesso SM, una cache per le istruzioni

e una per la memoria. Ogni SM ha inoltre una porzione di memoria condivisa di 16 Kb, tra

tutti gli SP al suo interno. Ogni SP, invece, ha una propria unità scalare MAD (Multiply-Add),

composta da una ALU (Arithmetic Logic Unit) ed una FPU (Floating Point Unit), in grado di

garantire un elevata potenza per i calcoli Floating Point (FP) a singola precisione (32 bit) e

permettono anche l'esecuzione di istruzioni FP a doppia precisione (64 bit). Tuttavia si ha una

sola unità di elaborazione in doppia precisione condivisa fra gli 8 SP dello stesso SM. Per

quanto riguarda la memoria, abbiamo diversi spazi di indirizzamento: global memory, local

memory, shared memory, registri e memoria cache (texture e constant). Nella global memory

vengono mossi tutti i dati prima di eseguire l'elaborazione. La local memory è invece uno

spazio riservato ad ogni singolo thread di 16 Kb, la constant cache è contenuta all'interno

della memoria globale ed ha a disposizione 64 Kb, mentre, come abbiamo già anticipato, la

shared memory ha 16 Kb a disposizione, divisi in 16 blocchi da 1 Kb ciascuno, ed è condivisa

dagli SP all'interno dello stesso SM.

27

Illustrazione 2.3: Insieme di multiprocessori SIMT con memoria condivisa

2.3 ARCHITETTURA DI UNA GPU

Da tale struttura si evince una mancanza di un sistema di caching generale, che può

comportare un'elevata latenza per le operazioni sui dati in memoria globale, che necessita di

400-600 cicli di clock. Tuttavia questa latenza può essere ben mascherata nel caso si abbia un

numero di thread per SM sufficientemente alto. Questo si ottiene attraverso uno scheduling

intelligente, in cui il thread che ha richiesto un accesso in memoria viene messo in attesa e

vengono così liberate risorse che possono essere sfruttate da un altro thread, rimasto inattivo

fino a quel momento, che può eseguire il proprio flusso di istruzioni. Al contrario di quanto

avviene per la memoria globale, i tempi di accesso alla memoria condivisa sono di poco

superiori a quelli per i registri. Tuttavia si dispone di soli 16Kbyte di memoria condivisi tra

tutti gli SP all'interno dello stesso SM. Questo può comportare a una conflittualità di accesso

ai dati, che porta alla serializzazione degli accessi e a un aumento della latenza a scapito delle

prestazioni.

La caratteristica distintiva di uno SM deriva dal meccanismo di esecuzione delle istruzioni,

associabile a un'architettura di tipo SIMD ma sostanzialmente differente. Difatti ogni singolo

SP infatti è in grado di gestire un flusso di esecuzione differente, comportandosi non come un

processore vettoriale ma come diverse unità di esecuzione scalari; perciò si parla di SIMT

(Single Instruction Multiple Threads).

Ogni SM gestisce fino a 24 Warps, ognuno composto da 32 thread. La MT Issue inoltra le

istruzioni ai warps. Può accadere che all'interno dello stesso warp, in presenza nel codice di

istruzioni di salto condizionato (ad esempio if-else, switch-case...) i threads divergano, ovvero

seguono percorsi differenti. Si ha un ottimizzazione delle prestazioni quando tutti i threads

dello stesso warp seguono lo stesso flusso di istruzioni. Infatti in caso contrario i flussi

vengono eseguiti serialmente con un conseguente degrado delle prestazioni. Pertanto bisogna

tener conto di questo possibile inconveniente quando si vuole scrivere software che sfruttino

appieno le potenzialità' di una GPU.

In generale se si vuole ottenere il picco massimo delle prestazioni bisogna tener conto dei

seguenti fattori:

• conditional branching (salto condizionato): se i threads all'interno dello stesso warp in

presenza di un istruzione di salto divergono, essi vengono eseguiti serialmente.

Pertanto un processore SIMT risulta efficiente al massimo quando tutti i thread dello

stesso warp seguono lo stesso flusso di istruzioni. Tuttavia si può mascherare la

28

2.3 ARCHITETTURA DI UNA GPU

penalità derivante dalla divergenza dei warps nel caso in cui si ha un numero elevato

di thread, solitamente almeno 5000;

• shared memory (memoria condivisa): ogni SM dispone di on-chip shared memory di

16 KB con un tempo di latenza di diversi ordini di grandezza inferiore rispetto alla

memoria globale. Pertanto risulta in alcuni casi essenziale disegnare un algoritmo che

sfrutti la memoria veloce per aumentare le performance;

• coalesced global memory operations: ogni qualvolta risulti necessario che più threads

richiedano accesso alla memoria globale è conveniente, laddove sia possibile, usare

tecniche per accedere ai dati in lettura o scrittura in blocco: coalesced access. È

possibile effettua coalesced accesses se i threads all'interno dello stesso blocco fanno

richiesta di accedere a una zona di memoria allineata. In particolare, un accesso in

lettura o scrittura può avvenire in maniera coalesced se almeno la metà dei threads in

un warp (half-warp), effettua un accesso in memoria in modo tale che il thread i-esimo

acceda alla locazione i-esima di uno spazio allineato ad un indirizzo multiplo delle

dimensioni di un half-warp (16). Un algoritmo che riesce a sfruttare questa tecnica

riesce ad ottenere una maggiore banda passante per la memoria, con prestazioni di 10

volte superiori rispetto ad operazioni con la memoria non coalesced.

2.4 GPGPU: General Purpose GPU computing

Fino a qualche anno fa la programmabilità delle GPU era utilizzata solo per applicazioni

audio e video, ma da qualche anno si è cercato di sfruttare le potenzialità di una tale

architettura anche per una più ampia gamma di applicazioni, dando vita al General Purpose

GPU computing (GPGPU) [10,11].

La nascita del GPGPU deriva dall'introduzione di shader programmabili, che erano in grado

di elaborare calcoli matriciali associando i dati da elaborare a delle texture. Gli shader infatti

sono nient'altro che semplici applicazioni eseguite su una grossa quantità di dati. Inizialmente

gli shader supportavano delle Apllication Programming Interface (API) ideate per il disegno

3D. Il loro compito era quello di eseguire operazioni su un insieme di vertici, pixel e texture.

29

2.4 GPGPU: GENERAL PURPOSE GPU COMPUTING

Il fattore che ha influenzato la nascita del GPGPU è stata la creazione di uno Shader Unified

Model, ovvero un modello di shading che utilizza lo stesso set di istruzioni per la definizione

di pixel, geometry e vertex shader. Nel 2006 nasce così una nuova generazione di GPU,

sviluppata da NVIDIA, che sfrutta queste nuove potenzialità degli shader. Questa nuova

architettura per le GPU prende il nome di CUDA, Compute Unified Driver Architecture, che

sfrutta tutti i vantaggi di un processore grafico anche per operazioni che non riguardano la

Computer Graphics. Oltre a CUDA sono nate in seguito anche altre piattaforme, come Stream

di AMD o Larrabee di Intel, che costituiscono anche ambienti per la programmazione di GPU

che si servono di API ideate appositamente per la programmazione General Purpose. Vista la

forte espansione e l'interesse dimostrato nei confronti di questa nuova generazione di GPU, la

Khronous Group, un consorzio che riunisce tutte le maggiore aziende del settore, ha deciso di

definire uno standard che alla lunga dovrebbe avere la meglio sui vari paradigmi di

programmazione fin qui creati, ovvero OpenCL, Open Compute Language. Al momento però

l'ambiente CUDA di NVIDIA è quello che vanta una maggiore documentazione e quindi offre

un migliore supporto per il programmatore. Questo spiega perchè circa il 90% dei programmi

implementati per GPU viene ancora scritto in CUDA, che riesce ad offrire anche migliori

strumenti per il debug.

2.5 CUDA

CUDA [12] è nello stesso tempo sia un'architettura parallela sviluppata da NVIDIA, ma

rappresenta anche un modello di programmazione API (Application Programming Interface)

che sfrutta le potenzialità dei sistemi manycore. Per maggiore chiarezza di qui in avanti

quando ci riferiremo al linguaggio di programmazione lo chiameremo C for CUDA o anche

C-CUDA, distinguendolo dall'architettura che nomineremo più semplicemente CUDA. Il

termine C-CUDA sta ad indicare che esso è essenzialmente un'estensione del C/C++ con

l'aggiunta di un ISA (Instruction Set Archietecture) in gradi di interfacciarsi verso un sistema

many-core. Questa stretta parentela del C-CUDA con il linguaggio C/C++ permette un facile

apprendimento ai programmatori già familiari con questo ambiente. CUDA offre inoltre due

30

2.5 CUDA

modelli di programmazione:

– uno ad alto livello, ovvero sullo stesso livello del C e che abbiamo identificato con C

for CUDA e che maschera al programmatore tutta una serie di funzioni, come quelle

per l'inizializzazione della periferica, e rende il codice più semplice da scrivere e più

compatto;

– uno ad un abstraction key più basso che necessita di un maggior numero di righe per la

scrittura di codice (allo stesso livello di OpenCL), che rappresenta l'interfacciamento

diretto al Driver API CUDA.

Di seguito descriveremo le specifiche del paradigma del C for CUDA, mentre

successivamente analizzeremo un linguaggio a livello più basso come l'OpenCL.

Gli elementi principali sui quali la piattaforma CUDA si basa sono:

• modello di esecuzione basato sui thread

• meccanismi di condivisione della memoria

• meccanismi di sincronizzazione

Il concetto che sta alla base dell'architettura CUDA è quello di partizionare il problema in

tanti piccoli sotto-problemi da risolvere indipendentemente. Questo è possibile tramite la

cooperazione di più flussi di esecuzione concorrenti, ognuno gestito da un thread, all'interno

31

Illustrazione 2.4: API di CUDA

2.5 CUDA

dello stesso programma. Ad ogni multiprocessore viene assegnato un blocco di threads, con

un limite massimo (solitamente di 512 threads per blocco, o fino a 1024 nelle più moderne

GPU) dovuto alle limitazioni dell'hardware. Ogni blocco condivide uno spazio di memoria

per tutti i suoi threads. I blocchi vengono suddivisi in una griglia adattabile a una, due o tre

dimensione a seconda della tipologia del problema da risolvere. La griglia ha lo scopo di fare

eseguire a tutti i threads del blocco lo stesso codice, o meglio lo stesso kernel. Questo

permette sia la risoluzione di ogni sotto-problema a ogni blocco, sia la cooperazione di più

blocchi per la risoluzione dello stesso problema. Da ciò ne deriva una scalabilità del sistema

sia rispetto al numero di SM, che rispetto al numero di threads che questi possono gestire.

Dal punto di vista software un programma C-CUDA è costituito da una parte Host, ovvero

eseguita dalla CPU, scritta in C-CUDA e che effettua chiamate di funzioni scritte in C sul

device (GPU) definiti kernel. Il codice host viene eseguito serialmente sulla CPU, mentre il

32

Illustrazione 2.5: Modello di esecuzione dei kernel e gestione dei threads

2.5 CUDA

codice del device viene elaborato parallelamente dalla GPU. La parte host si occupa di tutte le

inizializzazioni del device e quindi dei trasferimenti di memoria da e verso la periferica. Il

device è suddiviso in Stream Multiprocessor, indipendenti tra di loro aventi un certo numero

di Stream Processor (ad esempio in una Tesla C1060 sono presenti 30 SM, ed all'interno di

ogni SM ci sono 8 Stream Processor, per un totale di 240 cores).

Oltre a questa granularità architetturiale, anche a livello software è possibile suddividere in tre

livelli gerarchici tra loro: grid, block e thread. Ogni kernel viene suddiviso in blocchi,

indipendenti tra loro. Ogni blocco viene assegnato dallo scheduler ad uno SM. A sua volta

ogni SM assegnerà ai suoi SP i diversi thread generati dal programma. Sia i blocchi che i

threads vengono organizzati in una matrice, in cui indici adiacenti occupano zone di memoria

adiacenti. Tale matrice può essere, in rapporto al problema da risolvere, bidimensionale per i

blocchi e tridimensionale per i threads. Ogni thread in questo modo viene identificato da un

indice, tramite una politica di Thread Identifier. In questo modo è possibile identificare un

singolo thread dalla concatenazione di identificatore di blocco e di thread locale. L'idea è

quella che l'utente definisce la dimensionalità della griglia dei thread, tenendo conto delle

relative limitazione hardware, ma è l'hardware a decidere la distribuzione dei blocchi agli SM.

L'identificazione dei thread nel codice del kernel, avviene tramite alcune variabili built-in che

il compilatore C-CUDA mette a disposizione. Abbiamo quindi:

• threadIdx: rappresenta l'indice del thread all'interno dello stesso blocco, indirizzabile

fino a tre dimensioni in rapporto al problema da risolvere. Abbiamo quindi tre

possibili componenti: threadIdx.x , threadIdx.y, threadIdx.z;

• blockIdx: identifica il numero del blocco, può essere composto da una o due

componenti: blockIdx.x, blockIdx.y;

• blockDim: contiene il numero di thread per blocco;

• gridDim: numero di blocchi per il kernel in esecuzione.

La chiamata a un kernel necessita anch'essa di una speciale sintassi, dove devono essere

specificati il numero di threads per blocco e il numero di blocchi della griglia:

Kernel_name<<<BlocksPerGrid, ThreadsPerBlock>>>(Parameters)

dove BlocksPerGrid definisce il numero di blocchi ognuno con un numero di threads pari a

ThreadsPerBlock.

33

2.5 CUDA

Nella dichiarazione di funzioni C-CUDA si devono adottare dei particolari qualificatori:

• __global__ : definisce una funzione, o meglio un kernel, invocabile dall'host (CPU) ed

eseguibile dal device(GPU);

• __device__ : definisce un kernel, eseguibile ed invocabile esclusivamente dal device;

• __host__ : opzionale, definisce una funzione host, invocabile dall'host.

Le funzioni eseguite sul device, kernel, hanno delle limitazioni:

• non possono essere ricorsive;

• non possono presentare nella dichiarazione dei parametri statici;

• non possono avere un numero variabili di argomenti.

Fatte queste premesse, possiamo già scrivere con semplicità un kernel che effettua la somma

di due vettori e salva il risultato in un terzo vettore ed effettuare la chiamata ad esso nel main:

__global__ void addVector(float* A, float* B, float* C){

int threadID=blockDim.x*blockIdx.x+threadIdx.x;C[threadID]=A[threadID]+B[threadID];

}int main(){

...addVector<<<N,M>>>(A,B,C,)...

}

Nella variabile threadID è salvato l'ID globale di ogni singolo thread, mentre i parametri

compresi tra “<<< “e “>>>” stanno ad indicare che il kernel verrà eseguito su N blocchi

ognuno di M thread.

Come si nota, con poche righe di codice riusciamo a scrivere un kernel compatto, facilmente

leggibile e intuibile, che esegue molto più velocemente di un normale codice scritto per CPU.

Da questo semplice esempio risulta evidente che non c'è un maggior grado di difficoltà a

programmare oggetti di questo tipo, l'unica differenza sta nell'approccio che deve avere il

programmatore, che deve trovare il modo per parallelizzare l'algoritmo.

L'esecuzione parallela dei thread e l'utilizzo di spazi di memoria condivisa può portare a delle

conflittualità. Per questo i thread per cooperare senza contrasti devono riuscire a

sincronizzarsi. Per fare ciò C-CUDA mette a disposizione del programmatore delle funzioni

per la sincronizzazione. Ad esempio __syncthreads() se richiamata all'interno di un kernel,

34

2.5 CUDA

informa il singolo thread che deve interrompere la propria esecuzione ed attendere che anche

tutti gli altri threads all'interno dello stesso blocco l'abbiano anch'essi invocata a loro volta.

Un'altra funzione invece può essere richiamata nella parte host del codice è

cudaThreadSynchronize(). Questa viene di solito utilizzata prima di un trasferimento dei dati

dalla memoria del device a quella dell'host, per assicurarsi che tutti i thread abbiano terminato

il proprio lavoro.

Per le operazioni con la memoria del device, CUDA mette a disposizione funzioni per

l'allocamento e il deallocamento della memoria e funzioni per il trasferimento dati da e verso

la GPU. Abbiamo quindi:

• cudaMalloc(void** ptr, size_t count): che alloca una area di memoria nel device di

dimensione pari a count byte;

• cudaFree(void* devPtr): che libera la memoria puntata da devPtr;

• cudaMemCpy(void *des, const void *src, size_t count, enum cudaMemCpy kind):

dove:

- src è il puntatore all'area di memoria che contiene i dati da copiare;

- des è il puntatore all'area di memoria dove i dati devono essere trasferiti;

- count corrisponde al numero di byte che si vogliono trasferire;

- kind specifica il tipo di trasferimento da eseguire e può assumere i valori:

▪ cudaMemCpyHostToDevice: per indicare un trasferimento dati da CPU a GPU

▪ cudaMemCpyDeviceToHost: per indicare un trasferimento dati da GPU a CPU

A questo punto è possibile scrivere il codice completo in C-CUDA, compreso delle

allocazioni di memoria nel device, che esegue lo somma di due vettori e salva il risultato in un

terzo vettore:

#include <stdlib.h>#include <stdio.h>

#define N 10

__global__ void vetAdd(float* A, float* B, float* C){ int i = blockIdx.x * blockDim.x + threadIdx.x; if(i<N) { C[i] = A[i] + B[i]; }}

int main()

35

2.5 CUDA

{ size_t size = N * sizeof(float);

float hostA[N]; float hostB[N]; float hostC[N];

int i; for (i=0;i<N;i++) { hostA[i]= i+i; hostB[i]= i; //Allocazione della device memory float* devA; cudaMalloc((void**)&devA, size); float* devB; cudaMalloc((void**)&devB, size); float* devC; cudaMalloc((void**)&devC, size);

//trasferimento dati dall'host al device cudaMemcpy(devA, hostA, size, cudaMemcpyHostToDevice); cudaMemcpy(devB, hostB, size, cudaMemcpyHostToDevice);

//Esecuzione del kernel vetAdd<<<N,1>>>(devA, devB, devC); //Sincronizzazione cudaThreadSynchronize(); //Recupero dei dati: trasferimento dal device all'host cudaMemcpy(&hostC, devC, size, cudaMemcpyDeviceToHost);

for (i=0;i<N;i++) { printf("%f\n",hostC[i]); } //Stampa il risultato sullo schermo

//Liberazione della memoria cudaFree(devA); cudaFree(devB); cudaFree(devC);}

Bisogna tener presente che l'implementazione di strumenti di debug per codici scritti per GPU

è abbastanza complessa. C-CUDA mette comunque a disposizione del programmatore uno

strumento, cuda-gdb, per eseguire il debug dell'applicazione. Finora si è giunti alla versione

2.1 beta di gdb.

36

2.6 OPENCL

2.6 OPENCL

L'OpenCL [13,14] (Open Computing Language) nasce nel giugno 2008 da un'idea di Apple,

che pensa di creare un linguaggio standard e royalty-free per il calcolo parallelo di tipo

general purpose. La prima specifica di OpenCL (1.0) viene rilasciata nel dicembre 2008 da

Khronous Group, un consorzio che riunisce tutte le principali aziende del settore della

Computer Graphics (da NVIDIA a AMD/ATI, passando per Apple, Intel, Sony, Nokia e tutte

le maggiori aziende operanti nella Computer Graphics e non solo). Dopo 18 mesi di lavoro,

nel giugno 2010, viene anche rilasciata la versione 1.1 di OpenCL con l'aggiunta di nuove

funzionalità.

L'OpenCL nasce con lo scopo di essere un linguaggio a basso livello, portatile, rivolto a

sistemi multi-core e many-core, cross-platform per computers, servers e embedded devices e

operante su diversi sistemi operativi. Per questo OpenCL viene definito come un framework,

che permette di connettere alla CPU più devices e operare su di esse come se ci si trovasse

davanti ad un unico ed eterogeneo sistema. Inoltre stabilisce dei requisiti di precisione

numerica per fornire una consistenza matematica attraverso i diversi hardware e venditori (un

fattore di non poca rilevanza nella moderna comunità scientifica).

Quindi, a differenza di CUDA, OpenCL si propone come un'interfaccia di programmazione

non solo per GPU, ma per qualsiasi sistema multi o many-core. Inoltre, rispetto a CUDA, usa

una nomenclatura differente, ma concettualmente identica: ad esempio per definire le

dimensionalità della griglia l'equivalente del termine grid è costituito da NDRange, così come

il block è definito work-group e il thread come work-item. Ma la differenza sostanziale dal

punto di vista della programazione, è che OpenCL offre al programmatore un solo livello di

astrazione più basso rispetto al C-CUDA e che si interfaccia direttamente al driver API. Per

questo un codice scritto in OpenCL è molto più corposo rispetto ad uno CUDA, in quanto

sono richieste una serie di operazioni necessarie alla preparazione del device, che invece in C-

CUDA sono implicite e quindi nascoste al programmatore. OpenCL è comunque un

linguaggio abbastanza semplice da usare, per un programmatore già familiare con l'ambiente

C ed ancora più accessibile a chi ha già un minimo di esperienza con C-CUDA.

Nella costruzione di un applicativo in OpenCL si deve procedere per passi. Per prima cosa

37

2.6 OPENCL

bisogna interrogare il sistema per ottenere informazioni sulla piattaforma, per poi individuare

la lista delle devices disponibili per quella piattaforma e poterne selezionare una o più di una.

A partire dal o dai device selezionati si crea un context da associarli. La creazione del context

è il primo passo per l'inizializzazione e l'uso di OpenCL. Per memorizzare i dati, OpenCL

mette a disposizione degli oggetti di tipo buffer (per blocchi di memoria 1-dimensionali) o

image (per blocchi di memoria a 2 o 3 dimensioni). Una volta allocata la memoria sul device

e specificato il device, bisogna caricare e costruire il kernel. Per rendere possibile una

chiamata a un kernel, il programmatore deve costruire un kernel object. In questa fase viene

eseguita la compilazione del kernel a run-time, permettendo un'elevata portabilità del codice

ed un'ottimizzazione dello stesso in rapporto alla specifica GPU. Una volta creato il kernel

object, inizializzati e settati i suoi parametri, bisogna creare una command queue. La

command queue rappresenta un'interfaccia virtuale verso la periferica, tramite la quale si

eseguono tutte le elaborazioni sul device. Infatti nella command queue verranno inseriti i

kernel da eseguire sul device e le operazioni per il trasferimento dati tra la memoria host e

device; in seguito alla creazione del program object, lo si inserisce nella coda dei comandi e

successivamente si passa alla sua esecuzione. Per lanciare l'esecuzione del kernel è tuttavia

fondamentale stabilire dapprima le dimensionalità della griglia, o meglio dell'ND-range.

Terminata l'esecuzione del kernel, è necessario recuperare i dati elaborati, eseguendo un

trasferimento di memoria dal device all'host. In quest'ultima operazione è generalmente

necessario attuare delle tecniche di sincronizzazione, in modo da aspettare la conclusione di

tutti i threads che eseguono il kernel. Infine è possibile liberare le risorse impiegate.

Per effettuare questa serie di computazioni, OpenCL mette a disposizione una funzione per

ognuna di esse. Ecco di seguito elencate tutte le chiamate che è necessario invocare per

inizializzare la periferica, caricare un kernel ed eseguirlo su di essa:

– clGetPlaform: seleziona una piattaforma;

– clGetDeviceIDs: seleziona una lista di device;

– clCreateContext: associa un context ad uno o più deviceID;

– clCreateCommandQueue: crea una coda di comandi per un dato context;

– clCreateBuffer: alloca, e opzionalmente trasferisce, dati nella memoria del device;

– clEnqueWriteBuffer: trasferisce dati sul buffer object;

– clCreateProgram: crea un oggetto cl_program associato a un codice sorgente e ad un

38

2.6 OPENCL

context;

– clBuildProgram: effettua la compilazione a runtime del program;

– clCreateKernel: crea un oggetto cl_kernel associato a un determinato kernel presente

nel codice compilato;

– clSetKernelArg: setta i parametri del kernel;

– clEnqueueNDRangeKernel: inserisce il kernel in coda, se la coda è vuota il kernel

viene eseguito ;

– clEnqueueReadBuffer: per leggere e trasferire i dati appena elaborati dal kernel dal

device all'host.

Di seguito andiamo ad illustrare l'esempio di un codice OpenCL che effettua la somma di due

vettori ed è l'equivalente del codice scritto in C-CUDA precedentemente descritto.

Il seguente kernel OpenCL esegue la somma di due vettori e salva il risultato in un terzo

vettore.

1. __kernel void addVector (__global const float* a,2. __global const float* b,3. __global float* c)4. {5. //simple vector element index6. int thread_index = get_global_id (0);7.8. c[thread_index] = a[thread_index] + b[thread_index];9. }

Come si nota, per definire un kernel in OpenCL, si deve adottare una specifica sintassi

dichiarativa diversa da C-CUDA: __kernel void nome_kernel. I parametri di ingresso del

kernel devono essere dichiarati come puntatori e con due o più qualificatori che ne indicano,

oltre che al tipo di dato, la sua locazione in memoria. Il primo qualificatore indica in quale

regione della device memory risiede il dato. I tipi di dato indirizzabili nella device memory

sono:

• __global: memoria globale, read/write, accessibile dall'host, ma ad elevata latenza;

• __local (shared memory in CUDA): zona di memoria R/W condivisa dai work-item

all'interno dello stesso work group; è di dimensioni limitata ma molto veloce;

• __constant: porzione della memoria globale, ma di sola lettura;

39

2.6 OPENCL

• __private: memoria privata, generalmente risiedente nei registri, ovvero associata ed

accessibile da un singolo work-item e non dall'host.

Un'altra importante specifica è quella da adottare per l'indirizzamento dei thread.

Nell'esempio riportato è usato l'indirizzamento globale tramite la chiamata alla funzione

get_global_id(0). OpenCL mette a disposizione altre funzioni per l'indicizzazione dei work-

item o per la dimensionalità della griglia:

• uint get_work_dim(): ritorna il numero di dimensioni in uso;

• size_t get_global_size(uint D): numero di global work-items;

• size_t get_global_id(uint D): global work-item ID;

• size_t get_local_size(unit D): numero di local work-items;

• size_t get_local_id(unit D): local work-item ID;

• size_t get_num_groups(unit D): numero di work-groups;

• size_t get_group_id(unit D): work-group ID.

Se si presuppone che il codice del kernel sia contenuto nel file addVector.cl presente nella

directory corrente e che il nome del kernel da eseguire sia addVector, il codice host di seguito

esegue il kernel appena introdotto su una device di tipo GPU.

1. #include <stdio.h>2. #include <stdlib.h>3. #include <CL/cl.h>4. #include <string.h>5. 6. #define N 10 7.8. cl_platform_id my_platform; //OpenCL platform9. cl_context my_context; // OpenCL context10. cl_command_queue my_command_queue; // OpenCL command queue11. cl_device_id* devices; // OpenCL device list12. cl_program my_program; // OpenCL program13. cl_kernel my_kernel; // OpenCL kernel14.15. cl_int ciErr1; //Gestione dell'errore16.17. char* c_source_CL = NULL;18.

40

2.6 OPENCL

19. const char* ProgramSource = "addVector.cl"; //File in cui è contenuto il kernel

20. size_t kernel_length;21.22.//Utility per salvare il file sorgente in una stringa di caratteri23.char* utils_load_prog_source(const char* cFilename, const char* cPreamble,

size_t* szFinalLength)24.{25. ...26.}27.28.int main(int argc, char *argv[]){29.30.//Variabili Host31. float A[N];32. float B[N];33. float C[N];34.35.//Variabili device36. cl_mem devA;37. cl_mem devB;38. cl_mem devC;39.40. int i;41. for (i=0;i<N;i++)42. {43. A[i]=i;44. B[i]=i+i;45. }46. // Selezionare la platform47. cl_uint numPlatforms = 0;48. ciErr1 = clGetPlatformIDs(0, 0, &numPlatforms);49. if (ciErr1 != CL_SUCCESS) { printf("Error in clGetPlatformID !!!\n\n");

}50. cl_platform_id* platforms = NULL;51. platforms = (cl_platform_id*)malloc(sizeof(cl_platform_id)*numPlatforms);52. ciErr1 = clGetPlatformIDs(numPlatforms, platforms, &numPlatforms);53. if (ciErr1 != CL_SUCCESS) { printf("Error in clGetPlatformIDs !!!Err

code: %d\n\n",ciErr1); }54. //Selezionare il device55. cl_uint num_of_devices = 0;56. ciErr1 = clGetDeviceIDs(platforms[0], CL_DEVICE_TYPE_GPU, 0, 0,

&num_of_devices);

41

2.6 OPENCL

57. cl_device_id* devs = NULL;//new cl_device_id[10];]58. devs = (cl_device_id*)malloc(sizeof(cl_device_id)*num_of_devices);59. clGetDeviceIDs (platforms[0], CL_DEVICE_TYPE_GPU, num_of_devices, devs,

NULL);60. cl_device_id devid;61. devid = devs[0];62. //Creazione del context63. cl_context_properties cp[3];64. cp[0] = CL_CONTEXT_PLATFORM;65. cp[1] = (cl_context_properties)platforms[0];66. cp[2] = 0;67. my_context = clCreateContext(cp, 1, &devid,NULL,NULL,&ciErr1);68. if (ciErr1 != CL_SUCCESS) { printf("Error in

clCreateContext !!!\n\n" ); }69. //creazione della command queue 70. my_command_queue = clCreateCommandQueue(my_context, devid,

CL_QUEUE_PROFILING_ENABLE, &ciErr1 );71. if (ciErr1 != CL_SUCCESS) { printf("Error in

clCreateCommandQueue !!!\n\n" ); }72.73. //allocazione e trasferimento dati verso il device74. size_t size = sizeof(float)*N;75. devA = clCreateBuffer(my_context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR,

size, (void*)&A, &ciErr1);76. if (ciErr1 != CL_SUCCESS) { printf ("Error in clCreateBuffer!!!Error

code: %d\n", ciErr1); }77. devB = clCreateBuffer(my_context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR,

size, (void*)&B, &ciErr1);78. if (ciErr1 != CL_SUCCESS) { printf ("Error in clCreateBuffer!!!Error

code: %d\n", ciErr1); }79. devC = clCreateBuffer(my_context, CL_MEM_READ_WRITE, size, NULL, &ciErr1);80. if (ciErr1 != CL_SUCCESS) { printf ("Error in clCreateBuffer!!!Error

code: %d\n", ciErr1); }81.82. //Creazione del program object83. c_source_CL = utils_load_prog_source(ProgramSource, "", &kernel_length);84. const char* CLsrc = c_source_CL;85. my_program = clCreateProgramWithSource(my_context, 1, &CLsrc,

&kernel_length, &ciErr1);86. if (ciErr1 != CL_SUCCESS) { printf("Error in

clCreateProgramWithSource !!!Err code %d\n\n", ciErr1 ); }87. clBuildProgram(my_program, 1, &devid, NULL, NULL, NULL);88. if (ciErr1 != CL_SUCCESS) { printf("Error in clBuildProgram !!!Err code

42

2.6 OPENCL

%d\n\n", ciErr1 ); }89. 90.// creazione del kernel object91. my_kernel = clCreateKernel(my_program, "addVector", &ciErr1);92. if (ciErr1 != CL_SUCCESS) { printf("Error in clCreateKernel !!!Err code

%d\n\n", ciErr1 ); }93. 94.// settaggio dei parametri del kernel95. size_t sizeofclmem = sizeof(cl_mem);96. ciErr1 = clSetKernelArg(my_kernel, 0, sizeofclmem, (void*)&devA);97. if (ciErr1!=CL_SUCCESS) printf("Error in setKernelArg(ws), error code =

%d\n", ciErr1);98. ciErr1 = clSetKernelArg(my_kernel, 1, sizeofclmem, (void*)&devB);99. if (ciErr1!=CL_SUCCESS) printf("Error in setKernelArg(N), error code =

%d\n", ciErr1);100. ciErr1 = clSetKernelArg(my_kernel, 2, sizeofclmem, (void*)&devC);101. if (ciErr1!=CL_SUCCESS) printf("Error in setKernelArg(dt), error

code = %d\n", ciErr1);102.103. //definizione della dimensione della griglia104. const size_t cnBlockSize = 1;105. const size_t cnBlocks = N ;106. const size_t cnDimension = cnBlocks * cnBlockSize;107. 108. //Esecuzione del kernel109. cl_event event;110. clEnqueueNDRangeKernel(my_command_queue, my_kernel, 1, 0,

&cnDimension, &cnBlockSize, 0, 0, &event);111. //Sincronizzazione112. clWaitForEvents ( 1, &event);113. if (ciErr1!=CL_SUCCESS) printf("Error in clWaitForEvent\n");114.115. // Rcupero dei dati: trasferimento di memoria dal device all'host116. clEnqueueReadBuffer(my_command_queue, devC, CL_TRUE, 0, size, C, 0,

NULL, NULL);117.118. //Stampa dei risultati a video119. for (i=0;i<N;i++)120. {121. fprintf(stdout,"%f\n",C[i]);122. }123. }

43

2.6 OPENCL

È evidente che il codice OpenCL, rispetto all'equivalente C-CUDA, ha un maggior numero di

righe. Questo è dovuto al fatto che in OpenCL sono necessarie una serie di operazioni che

riguardano l'inizializzazione del device, che in C-CUDA sono implicite e mascherate al

programmatore. Tali operazioni consistono nella:

• creazione del context: righe da 46 a 68;

• creazione della command queue: righe da 69 a 71;

• creazione del program object e compilazione a run-time del kernel: righe da 82 a 92;

• inserimento del kernel nella command queue: riga 110;

Oltre a queste macro-operazioni è naturalmente necessario:

• allocare memoria sul device e copiarci i dati usati nell'elaborazione del kernel: righe

da 73 a 80.

• Settare ad uno ad uno i parametri del kernel: righe da 94 a 101;

• recuperare i dati ottenuti dall'esecuzione del kernel, trasferendo dalla memoria della

GPU a quella della CPU: riga 116.

Anche in OpenCL si devono attuare tecniche di sincronizzazione, per attendere che tutti i

work-item abbiano terminato l'esecuzione prima di eseguire il trasferimento dei dati dal

device all'host. Questo si ottiene tramite la gestione di un event object, associato

all'esecuzione del kernel (righe da 109 a 113).

2.7 Differenze CUDA – OpenCL

In questa sezione sintetizziamo le differenze di nomenclatura e di paradigma di

programmazione tra i due linguaggi introdotti precedentemente: a sinistra abbiamo la

specifica C-CUDA e a destra l'equivalente OpenCL.

Per quanto riguarda il modello di architettura:

• grid ↔ NDRange

• thread ↔ work-item

• block ↔ work-group

44

2.7 DIFFERENZE CUDA – OPENCL

Per i qualificatori:

• __global__ funtion ↔ __kernel function

• __device__ function ↔ function (nessun qualificatore richiesto)

• __device__ variable ↔ __global variable

• __shared__ variable ↔ __local variable

Per l'indicizzazione dei threads:

• threadIdx ↔ get_local_id()

• blockIdx ↔ get_group_id()

• blockDim ↔ get_local_size()

• gridDim ↔ get_num_groups()

• blockIdx.x*blockDim.x + threadIdx.x ↔ get_global_id(0)

• gridDim.x*blockDim.x ↔ get_global_size(0)

Per la sincronizzazione:

• cudaThreadSynchronize() ↔ clWaitForEvens(1,&event)

• __syncthreads() ↔ barrier()

Per il modello di memoria:

• shared memory ↔ local memory (memoria dedicata per ogni singolo blocco di thread)

• local memory ↔ private memory (memoria dedicata per ogni singolo thread)

45

46

Capitolo 3

Descrizione dell'algoritmo

Come anticipato, il presente lavoro di tesi è stato svolto nell'ambito della realizzazione di un

generatore di segnali simulati di forme d'onda gravitazionali, utilizzando GPU. Un generatore

di segnale è uno strumento utilizzato per simulare un segnale a partire da un modello teorico

che ne descrive l'andamento nel tempo. In particolare il generatore qui realizzato si basa sul

modello teorico definito dalle approssimazioni post-newtoniane per sistemi binari compatti

coalescenti. Il codice è stato sviluppato in due ambienti di programmazione GPGPU: CUDA e

OpenCL.

I segnali che siamo andati a simulare hanno una banda di frequenza utile compresa tra 1Hz e

2 – 4 Khz, dipendente da vari parametri. La sensibilità del detector VIRGO è invece compresa

tra i 30 e i 10000 Hz. Quindi per il teorema di Nyquist, secondo cui la frequenza di

campionamento deve essere almeno il doppio della banda del segnale, è sufficiente

campionare a una frequenza superiore ai 4 – 8 Khz.

L'obiettivo è quello di disegnare un algoritmo che sia il più efficiente possibile ed in grado di

generare questi modelli d'onda. Pertanto si è scelto di usare la GPU per eseguire questo

elevato numero di operazioni su più dati in parallelo. Essendo il nostro programma scritto per

GPU, sarà composto da una parte di codice che verrà eseguita serialmente dall'host(CPU) e

una parte invece parallelamente dal device(GPU).

Di seguito è descritta la sequenza di macro-operazioni richieste, di cui è composto l'algoritmo:

3 DESCRIZIONE DELL'ALGORITMO

1. inizializzazione del sistema: init_workspace():

Ogni sistema binario compatto è identificabile da un set di parametri. Per

comodità e per efficienza, nel nostro codice è stata creata una struttura

apposita, chiamata nel codice workspace, che identifica un singolo sistema

binario compatto a partire da :

▪ le due masse coinvolte: m1 e m2;

▪ la distanza dal detector: R;

▪ l'angolo d'incidenza rispetto al detector: i.

Tale struttura sarà sfruttata in seguito per il calcolo del tempo di coalescenza e

delle approssimazioni PN di fase, ampiezza e delle due polarizzazioni.

L'inizializzazione della struttura non è sicuramente gravosa dal punto di vista

computazionale e viene eseguita dalla CPU;

2. Calcolo del tempo di coalescenza: find_tc()

Il tempo di coalescenza costituisce la durata temporale del segnale. Definita

una frequenza iniziale f0 di un dato sistema binario compatto (identificato dalla

struttura precedentemente inizializzata) si esegue il calcolo del tempo di

coalescenza. La lunghezza del segnale influenza la dimensione del template e

di conseguenza il tempo di esecuzione dell'algoritmo. Anche questa

elaborazione è eseguita dall'host;

3. generazione del segnale: signal_generation():

A partire da un set di parametri, quali un workspace, il tempo di coalescenza, la

frequenza di campionamento, fase e frequenza iniziali, viene generato il

segnale d'onda gravitazionale. Questa è la parte computazionalmente più

dispendiosa ma parallelizzabile ed è pertanto svolta dalla GPU tramite

l'invocazione di un singolo kernel.

4. recupero dei dati :trasferimento dati da GPU a CPU:

Una volta terminata l'esecuzione del kernel, bisogna recuperare i dati risiedenti

sulla memoria della GPU, che costituiscono il template che stavamo cercando.

È per questo necessario un trasferimento di memoria dalla GPU alla CPU.

47

3 DESCRIZIONE DELL'ALGORITMO

Nella prima parte del codice eseguita dall'host sono presenti le fasi di inizializzazione dei

parametri e del device, il calcolo del tempo di coalescenza, le operazioni di trasferimento con

la memoria e la chiamata al kernel. Una volta invocato un kernel, lo si carica e lo si esegue sul

device. Quando la GPU ha finito l'elaborazione, il flusso di esecuzione del programma torna

alla CPU, che potrà salvare i dati ottenuti sulla propria memoria per renderli usufruibili

dall'host. In alcuni casi operativi si può fare eseguire ad entrambi i processori(GPU e CPU)

contemporaneamente il proprio flusso di istruzioni.

Andiamo ora ad illustrare un po più nello specifico il codice sviluppato, andando ad

esaminare il ruolo svolto dalle funzioni coinvolte nell'elaborazione.

48

Illustrazione 3.1: Schema di esecuzione dell'algoritmo

3.1 INIZIALIZZAZIONE DEL WORKSPACE

3.1 Inizializzazione del workspace

Come abbiamo già osservato, il segnale di GW è definito univocamente a partire da un set di

parametri. In particolare a partire da due masse m1 e m2, aventi l'asse inclinato di un certo

angolo i rispetto al detector e a distanza R (espressa in anni-luce) da esso, vengono emesse

onde gravitazionali che provocano distorsioni spazio-temporali la cui intensità è espressa dalle

equazioni delle due polarizzazioni, plus e cross.

Tutti i parametri (presenti nelle equazioni [1.7] [1.8] [1.9] [1.10] [1.11]) necessari alla

simulazione, sono stati organizzati all'interno di una struttura dati, chiamata nel nostro codice

workspace, che descrive univocamente le caratteristiche di un dato sistema. Questa è poi la

stessa struttura dati che verrà inviata alla GPU, per eseguire l'elaborazione del kernel. Tale

struttura viene inizializzata, tramite una funzione scritta in C/C++, a partire dai valori delle

due masse e della distanza dal detector. La struttura è composta da 73 campi di tipo float.

Ecco come è definito il nostro workspace, facendo riferimento all'indicizzazione:

➢ 0 – > costante gravitazionale in rapporto alla massa totale: Tm = G*TotalMass/c3;

➢ 1 – > massa totale: mass_total = (m1+m2) * SolarMass;

➢ 2 – > massa ridotta: mass_reduced = ((m1*m2) / (m1+m2)) * SolarMass;

➢ 3 – > differenza tra le masse: dMoverM = (m1-m2) * SolarMass;

➢ 4 – > rapporto tra la massa ridotta e la massa totale : nu = m1*m2/((m1+m2)*(m1+m2))

( 0 <= nu <= ¼ );

➢ da 5 a 12 – > coefficienti di frequenza istantanea:

▪ aw = 1;

▪ bw = (743./2688. + 11./32.*nu);

▪ cw = -3.*M_PI/10.;

▪ dw = 1855099./14450688. + 56975./258048.*nu + 371./2048.*nu2;

▪ ew = -M_PI*(7729./21504. - 13./256.*nu);

▪ fw = -720817631400877./288412611379200. + 107./280.*GAMMAE +

53./200.*M_PI*M_PI + (123292747421./20808990720.0 -

77./48.*lambda - 451./2048.*M_PI*M_PI + 11./16.*theta)*nu -

49

3.1 INIZIALIZZAZIONE DEL WORKSPACE

30913./1835008.*nu2 + 235925./1769472.*nu3;

▪ gw = -107./2240. * (-8.0);

▪ hw = -188516689./433520640.*M_PI - 97765./258048.*M_PI*nu +

141769./1290240.*M_PI*nu2;

➢ da 13 a 20 – >coefficienti di fase istantanea:

▪ ap; = -1.0/nu;

▪ bp = -(3715./8064. + 55./96.*nu)/nu;

▪ cp = 3.*M_PI/(4.*nu);

▪ dp = -(9275495./14450688. + 284875./258048.*nu + 1855./2048.*nu2)/nu;

▪ ep = (38645./172032. - 65./2048.*nu)*M_PI/nu;

▪ fp = -(831032450749357./57682522275840. - 53./40.*M_PI*M_PI -

+ 107./56.*GAMMAE + (-123292747421./4161798144. +

+ 2255./2048.*M_PI*M_PI + 385./48.*lambda -55./16.*theta)*nu +

+ 154565./1835008.*nu2 - 1179625./1769472.*nu3)/nu;

▪ gp = -(107./448.)/nu;

▪ hp = -M_PI/nu*(188516689./173408256. + 488825./516096.*nu +

-141769./516096.*nu2);

➢ 21 – > h0 = 2*G*ReducedMass/(c2*R*MegaParsecConstant);

➢ da 22 a 46 – > coefficienti per la plus polarization:

▪ plus_c00 = -1./96.*si2*(17. + ci2);

▪ plus_c02 = -(1.0+ci2);;

▪ plus_c11 = -si/8.0*dMoverM*(5.0+ci2);

▪ plus_c13 = 9.0*si/8.0*dMoverM*(1.0+ci2);

▪ plus_c22 = 1.0/6.0*((19.0+9.0*ci2-2.0*ci4)- nu*(19.0-11.0*ci2-6.0*ci4));

▪ plus_c24 = -4.0/3.0*si2*(1.0+ci2)*(1.0-3.0*nu);

▪ plus_c31 = si/192.0*dMoverM*((57.0+60*ci2-ci4)-2.0*nu*(49.0-12.0*ci2+

-ci4));

▪ plus_c32 = -2.0*M_PI*(1.0+ci2);

▪ plus_c33 = si/192.0*dMoverM*(-27.0/2.0)*((73.0+40.0*ci2-9.0*ci4)+

-2.0*nu*(25.0-8.0*ci2-9.0*ci4));

50

3.1 INIZIALIZZAZIONE DEL WORKSPACE

▪ plus_c35 = si/192.0*dMoverM*(625.0/2.0)*(1.0-2.0*nu)*si2*(1.0+ci2);

▪ plus_c41 = si/40.0*dMoverM*(-5.0*M_PI)*(5.0+ci2);

▪ plus_c42 = 1.0/120.0*((22.0+396.0*ci2+145.0*ci4-5.0*ci6) +

+5.0/3.0*nu*(706.0-216*ci2-251.0*ci4+15.0*ci6)+

- 5.0*nu2*(98.0-108*ci2+7.0*ci4+5.0*ci6));

▪ plus_c43 = si/40.0*dMoverM*135.0*M_PI*(1.0+ci2);

▪ plus_c44 = 2.0/15.0*si2*((59.0+35.0*ci2-8.0*ci4)+

-5.0/3.0*nu*(131.0+59.0*ci2-24.0*ci4)+

+ 5.0*nu2*(21.0-3.0*ci2-8.0*ci4));

▪ plus_c46 = -81.0/40.0*(1.0-5.0*nu + 5.0*nu2)*si4*(1.0+ci2);

▪ plus_s41 = si/40.0*dMoverM*(11.0+7.0*ci2+10.0*(5.0+ci2)*M_LN2);

▪ plus_s43 = si/40.0*dMoverM*(-27.0)*(7.0-10.0*log(1.5))*(1.0+ci2);

▪ plus_c51 = si*dMoverM*(1771./5120. - 1667./5120.*ci2 + 217./9216.*ci4 +

- 1./9216.*ci6 + nu*(681./256. + 13./768.*ci2 - 35./768.*ci4 +

+ 1./2304.*ci6) + nu2*(-3451./9216. + 673./3072.*ci2 +

+ 5./9216.*ci4 - 1./3072.*ci6));

▪ plus_c52 = M_PI/3.0*(19. + 9.*ci2 - 2.*ci4 + nu*(-16. + 14.*ci2 + 6.*ci4));

▪ plus_c53 = si*dMoverM*(1./512*(5.*3537. - 22977.*ci2 - 15309.*ci4 +

+ 729.*ci6) + nu/1280.*(-23829. + 5529.*ci2 + 7749.*ci4 +

- 729.*ci6) + nu2/5120.*(29127. - 27267.*ci2 -1647.*ci4 +

+ 2187.*ci6));

▪ plus_c54 = -16./3.*M_PI*(1 + ci2)*si2*(1 - 3.*nu);

▪ plus_c55 = si*dMoverM*(1./9216.*(-108125. + 40625.*ci2 + 83125.*ci4 +

-15625.*ci6) + nu/2304.*(9.*8125. - 40625.*ci2 - 48125.*ci4 +

+ 15625.*ci6) + nu2/9216*(-119375. + 3.*40625.*ci2 +

+ 44375.*ci4 - 15625.*ci6));

▪ plus_c57 = dMoverM*117649./46080.*si4*si*(1.0 + ci2)*(1. - 4.*nu +

+ 3.*nu2);

▪ plus_s52 = 1./5.*(-9. + 14.*ci2 + 7.*ci4 + nu*(96. - 8.*ci2 - 28.*ci4));

▪ plus_s54 = si2*(1.0 + ci2)*(56./5. - 32./3.*M_LN2 - nu*(1193./30. +

- 32.*M_LN2));

51

3.1 INIZIALIZZAZIONE DEL WORKSPACE

➢ da 47 a 71 – > coefficienti per la cross polarization:

▪ cross_s02 = -2.0*ci;

▪ cross_s11 = -3.0/4.0*si*ci*dMoverM;

▪ cross_s13 = 9.0/4.0*si*ci*dMoverM;

▪ cross_s22 = ci/3.0*((17.0-4.0*ci2)-nu*(13.0-12.0*ci2));

▪ cross_s24 = -8.0/3.0*(1.0-3.0*nu)*ci*si2;

▪ cross_s31 = si*ci/96.0*dMoverM*((63.0-5.0*ci2)-2.0*nu*(23.0-5.0*ci2));

▪ cross_s32 = -4.0*M_PI*ci;

▪ cross_s33 = si*ci/96.0*dMoverM*(-27.0/2.0)*((67.0-15.0*ci2)-2.0*nu*(19.0-

15.0*ci2));

▪ cross_s35 = si*ci/96.0*dMoverM*(625.0/2.0)*(1.0-2.0*nu)*si2;

▪ cross_s41 = -si*ci*dMoverM*3./4.*M_PI;

▪ cross_s42 = ci/60.0*((68.0+226.0*ci2-15.0*ci4)+5.0/3.0*nu*(572.0 +

- 490.0*ci2+45.0*ci4) -5.0*nu2*(56.0-70.0*ci2+15.0*ci4));

▪ cross_s43 = si*ci*dMoverM*27./4.*M_PI;

▪ cross_s44 = 4.0/15.0*ci*si2*((55.0-12.0*ci2)- 5.0/3.0*nu*(119.0-36.0*ci2)+

+ 5.0*nu2*(17.0-12.0*ci2));

▪ cross_s46 = -81.0/20.0*(1.0-5.0*nu + 5.0*nu2)*ci*si4;

▪ cross_c41 = -3./20.*si*ci*dMoverM*(3. + 10.*M_LN2);

▪ cross_c43 = 27./20.*si*ci*dMoverM*(7. - 10.*log(1.5));

▪ cross_c50 = 6./5.*si2*ci*nu;

▪ cross_c52 = ci/5.*(10. - 22.*ci2 + nu*(-154. + 94.*ci2));

▪ cross_c54 = ci*si2*(-112./5. + 64./3.*M_LN2 + nu*(1193./15.-64.*M_LN2));

▪ cross_s51 = si*ci*dMoverM*(-913./7680. + 1891./11520.*ci2 - 7./4608.*ci4 +

+ nu*(1165./384. - 235./576.*ci2 + 7./1152.*ci4) +

+ nu2*(-1301./4608. + 301./2304.*ci2 - 7./1536.*ci4));

▪ cross_s52 = M_PI/3.*ci*(34. -8.*ci2 - nu*(20. - 24.*ci2));

▪ cross_s53 = si*ci*dMoverM*(12501./2560. - 12069./1280.*ci2 +

+ 1701./2560.*ci4 + nu*(-19581./640. + 7821./320.*ci2 +

- 1701./640.*ci4) + nu2*(18903./2560. - 11403./1280.*ci2 +

52

3.1 INIZIALIZZAZIONE DEL WORKSPACE

+ 5103./2560.*ci4));

▪ cross_s54 = si2*ci*(-32./3.*M_PI*(1. - 3.*nu));

▪ cross_s55 = si*ci*dMoverM*(-101875./4608. + 6875./256.*ci2 +

- 21875./4608.*ci4 + nu/1152.*(66875. - 2.*44375.*ci2 +

+ 21875.*ci4) + nu2*(-100625./4608. + 83125./2304.*ci2 +

- 21875./1536.*ci4));

▪ cross_s57 = si*si4*ci*dMoverM*117649./23040.*(1. - 4.*nu + 3.*nu2);

➢ 72 – > normalizzazione della frequenza: norm(w) = 1/8 * Tm.

Dove ci e si sono abbreviazioni per cos(i) e sin(i) (a loro volta ci2 corrisponde a cos(i)2, ci4 a

cos(i)4, e così via...) con 'i' che equivale all'angolo d'inclinazione (di default π/3), mentre

'M_PI' rappresenta la costante π. Inoltre:

• dMoverM = mass_difference/mass_total;

• GAMMAE = 0.577215664901532860607,

• lambda = -1987/3080

• theta = -11831/9240,

• SOLARMASS = 1.98892e30,

• LIGHTSPEED = 2.99792458e8,

• GRAVITATIONAL_G = 6.67259e-11,

• MPC = 3.0856775807e22.

Le prime cinque locazioni identificano variabili che derivano dalla massa dei due oggetti. Dai

valori delle masse derivano i valori di tutti i coefficienti. I coefficienti rappresentano i fattori

costanti che devono essere combinati aritmeticamente con altri fattori variabili nella

risoluzione delle rispettive equazioni. Ad esempio i coefficienti tra gli 'indici 5 e 20 sono

utilizzati per risolvere le approssimazioni di fase (5-12) e frequenza orbitali (13-20), mentre i

restanti coefficienti (da 22 a 71) si riferiscono ai diversi livelli di approssimazione PN per il

calcolo delle polarizzazioni.

Inoltre, come si nota nell'illustrazione 3.2, i coefficienti per le due polarizzazioni sono

dichiarati seguendo una logica ben precisa; ad esempio il coefficiente cross_s02 indica che si

tratta del coefficiente per la cross polarization al livello zero di approssimazione (PN0) che

dovrà essere moltiplicato per la funzione trigonometrica seno(indicata dalla lettera 's') di 2 w,

53

3.1 INIZIALIZZAZIONE DEL WORKSPACE

dove per w si intende la frequenza istantanea. Ogni coefficiente è associato a uno dei fattori

che compongono le equazioni delle approssimazioni [1.7] e [1.8] descritte nel capitolo 1.

Questa struttura ci permette di implementare il calcolo delle polarizzazione a diversi livelli di

approssimazione. Ad esempio per il calcolo della PN0, verranno utilizzati solo gli indici 22 e

23 per la H+ e l'indice 47 per la Hx, mentre per il livello più alto (PN2 che corrisponde al

quinto livello di approssimazione) sono necessari tutti i coefficienti. L'indice 21 (h0)

rappresenta l'ampiezza del segnale che è uno dei due fattori, insieme alla fase, che influenza il

calcolo delle polarizzazioni H+ e Hx, e quindi la forma d'onda del segnale.

L'inizializzazione del workspace avviene a partire dalle due masse m1 e m2 e dalla distanza R

dalla sorgente (di default uguale a 1*MPC), ed è eseguita da una funzione C++ che salva in

ws la struttura inizializzata. La chiamata a tale funzione è quindi definta come:

init_workspace(workspace* ws, float m1, float m2, float R);

Nell'inizializzazione del workspace, viene inoltre anche definito l'angolo d'incidenza i fissato

a π/3.

Una tale struttura è essenziale al fine di ottimizzare il passaggio dei parametri e l'accesso

all'area dati nei calcoli permettendo, inoltre, una migliore leggibilità e debugging del codice.

54

Illustrazione 3.2: Corrispondenza tra variabile e coeficente dell'approssimazione PN

3.1 INIZIALIZZAZIONE DEL WORKSPACE

Dopo avere definito la struttura, per completare l'inizializzazione è necessario calcolare il

tempo di coalescenza.

3.2 Calcolo del tempo di coalescenza

Il calcolo del tempo di coalescenza deriva dall'evoluzione nel tempo della fase orbitale. È

possibile descrivere tale variazione una volta definita una frequenza orbitale iniziale f0: questa

in generale è scelta uguale alla frequenza di taglio inferiore del detector. Per valori minori

della frequenza f0 si hanno tempi di coalescenza più lunghi, al contrario per valori maggiori

di f0 si hanno tempi di coalescenza più brevi: ovvero si può definire che il tempo di

coalescenza è inversamente correlato alla frequenza di taglio f0 (vedi illustrazione 3.3).

55

Illustrazione 3.3: Tempo di coalescenza in funzione della frequenza iniziale

3.2 CALCOLO DEL TEMPO DI COALESCENZA

Il valore di f0 non può essere scelto arbitrariamente, in quanto influisce su una corretta analisi

del segnale. Infatti nel caso si applichi una frequenza troppo alta, si potrebbero scartare

regioni di frequenza in cui c'è presenza del segnale, che porterebbe a una diminuzione del

rapporto segnale-rumore (SNR). Frequenze di taglio troppo piccole, al contrario,

aumenterebbero esponenzialmente il tempo di coalescenza e quindi il periodo di osservabilità

del segnale e di conseguenza necessiterebbero di template di dimensioni troppo grandi per

riprodurre interamente il segnale. Pertanto è opportuno scegliere una frequenza di taglio

ottimale. Questa è una scelta estremamente importante e critica nei detector di nuova

generazione. L'illustrazione 3.3 mostra appunto la corrispondenza tra tempo di coalescenza e

frequenza di taglio.

Per calcolare il tempo di coalescenza si deve eseguire un integrazione numerica, seguendo il

metodo di Raphson-Newton, che calcola lo zero di una funzione continua e derivabile. Nel

nostro caso si devo trovare la τ0 tale che, definita la frequenza iniziale f0 :

w(τ0 )=w0=2*π*f0 → w(τ0 ) - 2*π*f0 = 0

Ricordiamo a proposito che la w(t) è definita dalla [1.11] del capitolo 1 e che la variabile

temporale τ è definita dalla [1.12], da cui è possibile ricavare il tempo di coalescenza una

volta ricavata la radice τ0.

La funzione che calcola il tempo di coalescenza ha in ingresso un puntatore alla struttura

workspace, precedentemente inizializzata, e un floating point fmin, che rappresenta la

frequenza di taglio inferiore (o frequenza iniziale del sistema) e ritorna un floating point

equivalente al tempo di coalescenza. Ecco un esempio di chiamata a tale funzione:

float ct = cuInspiral_PN5_find_coalescence_time(&ws, fmin);

PN5 sta ad indicare che la frequenza orbitale w(t) è calcolata al quinto livello di

approssimazione.

Stimato il tempo di coalescenza si può passare alla fase computazionalmente più dispendiosa,

ovvero la generazione del template.

56

3.3 GENERAZIONE DEL TEMPLATE

3.3 Generazione del template

Il calcolo vero e proprio del segnale è svolto dalla GPU, essendo questa la parte

computazionalmente più importante. Prima di eseguire il kernel sul device, si inizializza la

periferica e si trasferiscono i dati necessari all'esecuzione del kernel dalla memoria principale

alla memoria della GPU. Una volta eseguite queste operazioni di inizializzazione nella parte

host del codice, si richiama il kernel che costituisce la parte operativa del codice (calcolo di

fase e frequenza istantanea, plus e cross polarization) e che trasferisce il flusso di esecuzione

del programma sul device. Andiamo quindi a descrivere il codice sviluppato, distinguendolo

in codice host e codice device.

3.3.1 Codice Host

Come abbiamo introdotto precedentemente, nella parte host vengono svolte tutte quelle

operazioni che riguardano la dichiarazione, l'allocazione e l'inizializzazione dei parametri

oltre che alla preparazione del device e l'invocazione del kernel. Avendo già calcolato il tempo

di coalescenza e quindi inizializzato il workspace, bisogna ora creare ed allocare tutte le

restanti variabili che entrano in gioco nell'analisi e nella generazione di un segnale d'onda

gravitazionale, e quindi:

– omega0 : frequenza iniziale, di default = 15 Hz;

– phase0 : fase iniziale = 0;

– dt: intervallo temporale che intercorre tra una campione e l'altro, uguale all'inverso

della frequenza di campionamento, dt =1/fc;

– N: numero totale di thread;

– hpt e hqt: i vettori dove viene salvato l'output ottenuto;

– scalef: speciale fattore di scala, = 10-21: questo fattore dipende dal fatto che

solitamente le elaborazioni sui calcolatori convenzionali (quali le CPU) svolgono le

57

3.3 GENERAZIONE DEL TEMPLATE

operazioni in virgola mobile in doppia precisione (64 bit), mentre l'elaborazione da noi

eseguita sulla GPU, per ottimizzare il tempo di esecuzione, esegue calcoli in singola

precisione (32 bit); quindi per mantenere l'accuratezza numerica si adottano alcuni

accorgimenti informatici, come ad esempio nel nostro caso introducendo un

appropriato fattore di scala.

Una caratteristica importante della programmazione GPGPU è il modello della memoria: in

particolare il processore grafico, durante l'esecuzione del kernel, vede esclusivamente la

memoria della scheda grafica. Pertanto per ognuna delle variabili precedentemente elencate e

coinvolte nell'esecuzione del kernel, si devono eseguire le seguenti operazioni:

1. creare due riferimenti per ciascuna variabile, uno risiedente sulla memoria dell'host e

l'altro invece sul device.

2. scrivere prima sulla memoria host;

3. copiare dalla memoria host alla memoria device;

Volendo riassumere ecco tutte le operazioni che devono essere svolte dalla CPU, per

preparare l'invocazione del kernel ed eseguirlo sul device:

1. inizializzazione del device;

2. dichiarazione e inizializzazione delle variabili host;

3. dichiarazione delle variabili del device;

4. allocazione della memoria del device;

5. trasferimento dati dall'host al device;

6. invocazione del kernel;

7. trasferimento dati dal device all'host (una volta terminato il kernel);

8. rilascio della memoria.

A seconda del linguaggio di programmazione adottato si utilizza una sintassi specifica per

effettuare questo processo. In CUDA per utilizzare zone di memoria della periferica è

necessario creare un puntatore del tipo desiderato ed allocare la quantità di memoria

sufficiente a contenere il dato tramite un'opportuna funzione, simile alla malloc() nel cpp:

cudaMalloc(). Per scrivere e leggere sulla locazione di memoria, o più generalmente per

trasferire dati con essa, si invoca cudaMemCpy(). OpenCL mette invece a disposizione un

tipo di dato specifico per far riferimento alla memoria del device: cl_mem, che risulta essere a

tutti gli effetti un oggetto di tipo buffer che consente trasferimenti dati da e verso la GPU. La

funzione che permette di gestire questi oggetti è clCreateBuffer, che consente di eseguire sia

58

3.3 GENERAZIONE DEL TEMPLATE

l'allocazione e che il trasferimento di memoria in una singola chiamata. Nel caso in cui si

vuole scrivere su un buffer precedentemente allocato si invoca invece clEnqueueWriteBuffer.

Per leggere dal buffer e scrivere sulla memoria della CPU, OpenCL mette a disposizione del

programmatore clEnqueueReadBuffer().

Una volta terminate le operazioni di inizializzazione, si può invocare il kernel e lanciare la sua

esecuzione sul device. Tuttavia quando si lancia un kernel, bisogna stabilire le dimensionalità

della griglia, e quindi l'indicizzazione dei thread, e la distribuzione dei thread. Ancora una

volta i due linguaggi usano una sintassi differente. Questa è per esempio l'invocazione di un

kernel in CUDA:

cuInspiral_signal_proc<<<blocksPerGrid,threadsPerBlock>>>(list_of_kernel_pa

rameters);

e questo è l'equivalente in OpenCL:

clEnqueueNDRangeKernel(my_command_queue, my_kernel, 1, 0, totalThreads,

threadsPerBlock, 0, 0, &event);

Conclusa l'esecuzione del kernel, il controllo passa di nuovo alla CPU che deve effettuare un

nuovo trasferimento di memoria, questa volta dal device all'host, per salvare i campioni

ottenuti (corrispondenti ai valori delle polarizzazioni H+ e Hx) e contenuti nei vettori hpt e hqt.

Questi costituiranno i templates che consentiranno la simulazione e quindi l'analisi di tali

segnali. Infine è possibile liberare le risorse di memoria precedentemente allocata.

Ora passiamo alla descrizione del kernel che viene eseguito dalla GPU.

3.3.2 Codice kernel

La funzione che genera il template è dichiarata come:

__global__ void Inspiral_signal_proc(float* workspace, int* N,float* dt,

59

3.3 GENERAZIONE DEL TEMPLATE

float* ct, float* omega0, float* phase0, float* d_scalef, float* hpt,

float* hqt);

Andando ad analizzare nello specifico i parametri di ingresso del kernel, abbiamo:

• workspace: la struttura dati che identifica il sistema, precedentemente inizializzata;

• N: numero totale di threads, scelto dall'utente;

• dt: l'inverso della frequenza di campionamento fc, definita da riga di comando;

• ct: tempo di coalescenza, calcolato precedentemente;

• omega0 e phase0: definiti in compilazione e pari a 15 e 0, rispettivamente;

• d_scalef: uno speciale parametro per scalare in maniera corretta i valori delle tue

polarizzazioni di un fattore di e21;

• hpt e hqt: i vettori dove vengono salvati i campioni ottenuti e che costituiscono il

template.

Oltre a questi parametri, quando si lancia il kernel deve essere stabilita la dimensionalità della

griglia. Il parametro N deve essere opportunamente scelto, in rapporto al tempo di

coalescenza e alla frequenza di campionamento. Quindi, se il nostro segnale per esempio ha

un tempo di coalescenza stimato a 50sec ed usiamo una frequenza di campionamento di 4000

Hz, saranno necessari almeno 50*4,000=200,000 campioni per riprodurre il segnale

interamente e, per come è disegnato il nostro algoritmo, significa che il numero totale di

threads N deve essere maggiore o uguale a 200,000. Traducendo questa dipendenza in una

formula, otteniamo:

[3.1]

Dove fc è la frequenza di campionamento e tc il tempo di coalescenza. In realtà si fissa N

uguale alla prima potenza di 2 tale che : 2N≥ fc∗tc [3.2].

Il kernel effettua tutte le operazioni descritte nel capitolo 1 per la generazione dei templates;

in particolare ogni singolo punto del segnale è associato ad un thread CUDA/OpenCL, che

deve eseguire:

60

N≥ fc∗tc

3.3 GENERAZIONE DEL TEMPLATE

1. il calcolo della frequenza istantanea ω(t);

2. il calcolo della fase istantanea φ(t);

3. il calcolo della fase attuale ϕ, calcolata in funzione di φ(t) e ω(t);

4. il calcolo delle polarizzazioni plus e cross, H+(t) e Hx(t);

Per identificare ogni singolo thread gli viene assegnato un indice threadIndex. Ad ogni thread

corrisponderà un'istante temporale t, secondo la seguente relazione:

t = CoalescenceTime – ( dt * ThreadIndex)

L'algoritmo procede e deriva il valore di frequenza ω associato e fase φ associato a

quell'istante temporale 't'.

Nel codice, sono stati inoltre inseriti dei controlli per garantire:

1. t > 0 → ovvero che l'istante temporale rientra nella durata del segnale;

2. ω > 0 → ovvero che la frequenza orbitale sia positiva;

Successivamente, si procede con il calcolo della fase orbitale psi, definito come:

psi = phase0+p-2.0*Tm*w*log(w/(omega0))

dove p e w sono la fase φ(t) e la frequenza istantanea ω(t) appena calcolate nei passaggi

precedenti, Tm è una costante derivante dalla massa e definita nel workspace, omega0 e

phase0 sono due dei parametri di ingresso del kernel, già introdotti precedentemente.

A questo punto vengono calcolate le due polarizzazioni, cross e plus, tramite le

approssimazioni post-Newtoniane tramite le [1.7] e [1.8] introdotte nel capitolo 1. La

complessità del calcolo e la quantità di risorse da impiegare aumenta all'aumentare del grado

di approssimazione utilizzato. Terminata l'elaborazione dei dati non rimane altro che salvare

in due array, hpt e hqt, il dato ottenuto corrispondente al valore della polarizzazione in un

determinato istante temporale.

Quando tutti gli N thread hanno completato la loro esecuzione, gli array hpt e hqt contengono

il segnale simulato dell'onda gravitazionale che verrebbe emessa da due masse m1 e m2 a

distanza R e angolo d'incidenza i rispetto all'interferometro, calcolato con il metodo delle

61

3.3 GENERAZIONE DEL TEMPLATE

approssimazioni PN proposto da Luc Blanchet[4].

L'ultimo passo da compiere è il salvataggio dei dei dati ottenuti sulla memoria dell'host,

effettuando un trasferimento di memoria dalla GPU verso la CPU.

62

Illustrazione 3.4: Schema del kernel di generazione del segnale

Kernel signal_proc()

t = CT - (*dt)*thid;

If (t>0)

Calculate_w()

Calculate_p()Calculate_psi()Calculate_H+()Calculate_Hx()

hpt[thid]=H+hqt[thid]=Hx

hpt[thid]=0.0hqt[thid]=0.0

hpt[thid]=0.0hqt[thid]=0.0

NO

NO

SI

SI

If (t>0)

63

Capitolo 4

Testing dell'algoritmo

In questo capitolo sono riportati i risultati dei test effettuati al fine di verificare la soluzione

GPU sia sotto il profilo delle performance che di accuratezza numerica. A tale scopo sono

state organizzate due prove:

1. l'accuratezza numerica = ACCURACY TEST: test per definire l'accuratezza numerica

dell'algoritmo; tale test è stato valutato mettendo a confronto:

✗ CPU e GPU: le due diverse architetture;

✗ CUDA vs OpenCL: i due diversi ambienti di programmazione GPGPU trattati;

2. tempo di esecuzione = BENCHMARK TEST: test per stimare il tempo di elaborazione

dell'algoritmo; anche in questo caso per valutarne l'efficienza sono state eseguite due

tipologie di test:

✗ CPU vs GPU: confronto di prestazioni tra i due diversi approcci architetturiali;

✗ CUDA vs OpenCL: confornto di prestazioni tra i due ambienti di

programmazione GPGPU trattati.

Andiamo ora ad introdurre i test effettuati ed analizzare i risultati ottenuti.

4.1 ACCURACY TEST

4.1 Accuracy test

Nel caso delle GPU, effettuare un test di accuratezza numerica è estremamente importante

essendo questo un aspetto potenzialmente critico. Difatti le elaborazioni su GPU, pur

supportando le operazioni in doppia precisione, forniscono prestazioni nettamente superiori se

svolte in singola precisione, per motivi architetturiali che descriveremo in seguito.

Il fattore di accuratezza, inoltre, è tanto più importante nel nostro caso dal momento che si

vuole simulare un segnale di onda gravitazionale sulla base di modelli matematici teorici dalla

cui accuratezza dipende la rilevazione di onde gravitazionali. Infatti il segnale è

numericamente al limite del range numerico della singola precisione con reali rischi di

overflow.

In particolare in questa tipologia di test vengono messi a confronto due set di dati (templates)

derivanti dallo stesso set di parametri (masse, distanza e angolo d'incidenza) ma generati con

codici differenti: CUDA, OpenCL e CPU

Nel dettaglio, durante il test di accuratezza i dati sono stati confrontati nel seguente modo:

• CUDA vs OpenCL : in cui vengono messi a confronto i templates generati su GPU dai

due diversi linguaggi; questo test permette di valutare l'accuratezza numerica tra le due

implementazioni su GPU;

• GPU vs CPU: in cui viene valutata la differenza di accuratezza numerica tra un codice

scritto su GPU e uno su CPU; questo test permette di verificare l'accuratezza numerica

tra CPU e GPU, prendendo la CPU come riferimento, poiché su essa i calcoli sono

svolti in doppia precisione.

Andiamo ad analizzare entrambi, partendo dal confronto fra GPU e CPU

4.1.1 CPU vs GPU

Al fine di verificare l'accuratezza della GPU nei calcoli del nostro algoritmo, abbiamo

effettuato un confronto fra i risultati ottenuti tra il codice GPU e quello CPU compilato in

64

4.1 ACCURACY TEST

doppia precisione. Così facendo abbiamo utilizzato la CPU come riferimento e verificato i

risultati ottenuti dalla GPU. Difatti l'implementazione su GPU qui presentata effettua calcoli

di tipo float in singola precisione (32 bit). Seppure il tipo float a doppia precisione (64 bit) sia

stato introdotto già nelle Tesla di compute capability7 1.3, le prestazioni diminuiscono

notevolmente quando si vogliono utilizzare dati di tipo double, ovvero floating point (FP) a

doppia precisione. Questo è dovuto al fatto che le architetture Tesla, su cui è eseguito il

codice, dispongono di una sola unità di elaborazione di dati a doppia precisione per

MultiProcessore, contro le 8 unità di elaborazioni (una per ogni SP) di tipo floating point a

singola precisione. Da ciò ne deriva che le elaborazioni di dati a 64 bit (double) sono peggiori

nella media di 8x di quelli a 32 bit (float). In particolare, dalle specifiche della Tesla C1060

[8] su cui viene eseguito l'algoritmo di generazione dei templates, il picco massimo di FP a

singola precisione è di 933 Gflops contro i 78 Gflpos della doppia precisione.

Premesso ciò andiamo a definire le modalità in cui è stato eseguito il testing dell'accuratezza

tra CPU e GPU.

Per valutare l'accuratezza abbiamo utilizzato l'energia E del segnale definita come la

sommatoria, per ogni istante temporale, della somma dei quadrati delle due polarizzazioni Hi+

e Hix diviso per il numero totale di campioni N; in termini matematici:

E=i

N H i+ 2 H i

x 2

N

La tabella 4.1 di seguito mostra i risultati ottenuti dall'esecuzione dei due codici (CPU e GPU)

variando il set di parametri. In particolare sono stati presi in considerazione due set di masse:

• m1 = 1.4, m2 = 1.4;

• m1 = 1, m2 = 5

e due frequenze iniziali:

• 10 Hz;

• 20Hz;

Dai test effettuati si osserva uno scarto in termini di energia generalmente inferiore al 6%.

Questo valore è tollerabile in fase di analisi essendo comunque inferiore all'errore indotto dal

processo rumoroso.

7 Definisce il set di istruzioni supportato dalla periferica

65

4.1 ACCURACY TEST

Parametri Durata del segnale (s) EGPU ECPU Scarto Scarto %

Fc=20 Hz, m1 = 1.4, m2 = 1.4 160.679794 8.57e-37 9.04e-37 0.47e-37 5.2%

Fc=10 Hz, m1 = 1.4, m2 = 1.4 1015.946899 2.16e-36 2.31e-36 0.15e-36 6.5%

Fc=20 Hz, m1 = 1, m2 = 5 80.905273 1.65e-36 1.71e-36 0.06e-36 3,50%

Fc=10 Hz, m1 = 5, m2 = 5 513.744934 4.24e-36 4.46e-36 0.24e-36 5,38%

Tabella 4.1

4.1.2 CUDA vs OpenCL

Partendo dal fatto che entrambi i codici sono stati eseguiti sullo stesso sistema di calcolo (e

quindi dalla stessa GPU), ci aspettiamo di avere una differenza di accuratezza numerica molto

bassa se non nulla tra i due ambienti di programmazione. Difatto, come si può già notare da

un confronto visivo (vedi illustrazione 4.1), si riscontra un'effettiva corrispondenza tra i

segnali d'onda generati dai due algoritmi.

66

Illustrazione 4.1: Template generati da CUDA e OpenCL a confronto

4.1 ACCURACY TEST

In tale grafo le due curve disegnate si riferiscono a una sola delle due polarizzazioni. Si nota

che le due sinusoidi si sovrappongono e quindi coincidono. Questo, ovviamente, non è un

confronto quantitativo.

Per definire con precisione numerica la misura in cui la corrispondenza tra i due segnali si

manifesta, è stato effettuato un test che genera e confronta due templates, uno ottenuto con il

codice CUDA e uno con quello OpenCL. I due segnali descrivono lo stesso sistema fisico

avendo entrambi la stessa parametrizzazione (masse, distanza, angolo d'inclinazione, fase e

frequenza iniziale). Il confronto è svolto calcolando lo scarto quadratico medio tra di essi.

Tale scarto quadratico medio è definito dalla seguente formula:

sqm= i

N hCUDAi−hOpenCLi2

N

dove :

hCUDAi=H i+ 2H i

x2 hOpenCL i=H i+2H i

x2

Tale test è stato implementato tramite un programma scritto in c++, che esegue le seguenti

funzioni:

1. legge i file in cui sono stati salvati i due templates, CUDA e OpenCL;

2. estrae le colonne che corrispondono al valore delle polarizzazioni;

3. calcola la somma dei quadrati delle due polarizzazioni, per entrambi i template CUDA

e OpenCL;

4. effettua la differenza tra i valori CUDA e OpenCL, ed eleva al quadrato il risultato

ottenuto;

5. per ogni riga del file esegue i punti 2, 3 e 4 e in una variabile accumulatore viene di

volta in volta sommato lo scarto quadratico ottenuto;

6. terminato il file, il contenuto dell'accumulatore viene diviso per il numero N di righe

del file: il risultato ottenuto rappresenta lo scarto quadratico medio.

La tabella 4.2 illustra i risultati ottenuti dall'esecuzione del test, variando il set di parametri in

ingresso. In particolare sono stati presi due set di masse:

• m1 = 1.4, m2 = 1.4;

• m1 = 1, m2 = 5

67

4.1 ACCURACY TEST

e due frequenze iniziali:

• 10 Hz;

• 20Hz.

Dall'esecuzione dell'algoritmo si è giunti alla conclusione che ci aspettavamo, ovvero che lo

scarto quadratico medio tra le due diverse implementazioni CUDA e OpenCL sullo stesso

harware è effettivamente inferiore al minimo numero rappresentabile in singola precisione

(10-45) e quindi operativamente nullo.

Parametri Tempo di coalescenza Scarto quadratico medioFc=20 Hz, m1 = 1.4, m2 = 1.4 160.679794 0Fc=12 Hz, m1 = 1.4, m2 = 1.4 1015.946899 0Fc=20 Hz, m1 = 1, m2 = 5 80.905273 0Fc=12 Hz, m1 = 5, m2 = 5 513.744934 0

Tabella 4.2

4.2 Benchmark test

Il benchmark effettuato ha lo sopo di valutare l'efficienza computazionale del codice

sviluppato. L'algoritmo proposto, come già visto (ref. Capitolo 3) si divide in diverse fasi di

elaborazione:

1. inizializzazione del sistema;

2. calcolo del tempo di coalescenza;

3. generazione dei templates;

4. salvataggio dei dati.

La parte di codice eseguita dalla GPU è quella relativa alla generazione del template (punto3)

e sarà quindi l'oggetto di questo test di benchmark. Bisogna ricordare che il tempo di

generazione dei templates (da qui in avanti faremo riferimento ad esso come tempo di

generazione) è fortemente dipendente dalla lunghezza temporale del segnale stesso e quindi

68

4.2 BENCHMARK TEST

dal tempo di coalescenza, che a sua volta dipende dai parametri che definiscono il sistema, le

due masse e la frequenza iniziale applicata. Per come è implementato l'algoritmo, il tempo di

generazione è atteso aumentare linearmente al crescere del tempo di coalescenza.

Di particolare interesse sarà, quindi, mettere in rapporto il tempo di generazione

dell'algoritmo (asse y) in funzione del tempo di coalescenza del segnale (asse x). Per ottenere

una variazione del tempo di coalescenza di un dato sistema binario, fissate le masse dei due

corpi, è necessario far variare la frequenza iniziale del sistema o meglio la frequenza di taglio

applicata al detector (ref. Capitolo 3).

Le prove di benchmark sono state pertanto eseguite mantenendo costanti le massa dei due

corpi e la frequenza di campionamento (e nel caso dell'elaborazione su GPU anche il numero

totale di thread) e variando la frequenza di taglio inferiore. Inoltre, al fine di avere un po' di

statistica sui risultati, fissati i parametri, la generazione del segnale è ripetuta per un numero

di cicli pari a 1000 calcolando, infine, la media del tempo di generazione.

Prima di valutare i risultati ottenuti, bisogna fare alcune premesse. In particolare occorre

ricordare che prima di lanciare un kernel devono essere definite le dimensionalità della

griglia. Nel nostro caso, il numero di thread per blocco è fissato a 256 (valore ottimale

consigliato) per tutte le esecuzioni del kernel. Il numero totale di blocchi, invece, è correlato

al numero totale di thread 'N' che è uno dei parametri di ingresso del kernel ed è scelto

opportunamente dall'utente quando viene lanciato l'eseguibile da riga di comando. Difatti

ricordiamo che il numero totale di thread deve essere scelto in relazione al tempo di

coalescenza e alla frequenza di campionamento utilizzata (vedi [3.1] e [3.2]). Dal momento

che il tempo di coalescenza ha una dipendenza inversa rispetto alla frequenza di taglio

applicata (ref. 3.2), col diminuire della frequenza di taglio (e quindi con l'aumentare del

tempo di coalescenza) è necessario aumentare la dimensione dei buffer e quindi il numero

totale di thread.

Nel test di benchmark effettuato sono state valutate le prestazioni del kernel in relazione al

numero totale di threads 'N' applicato. In particolare sono stati scelti valori di N uguali a 2n,

con n compreso tra 19 e 23, essendo queste le dimensioni tipiche utilizzate in produzione. Il

numero massimo di thread applicabile è dipendente dalle caratteristiche dell'hardware e nel

nostro caso ha un limite uguale a 223 = 8.388.608 thread.

La tabella 4.3 seguente illustra appunto la corrispondenza tra il numero di thread, il tempo di

coalescenza massimo e la frequenza iniziale minima del segnale, proveniente da due masse

69

4.2 BENCHMARK TEST

pari a 1,4 masse solari a distanza di 1Mpc e angolo d'inclinazione π/3 rispetto al detector, che

è possibile simulare. Come si evince dalla tabella 4.3, raddoppiare il numero di thread vuol

dire riuscire a simulare segnali di lunghezza temporale doppia circa.

Numero totale di thread Frequenza di taglio minima (Hz) Tempo di coalescenza massimo (s)

219 28,05 65,31220 21,6 130,93221 16,65 255,52222 12,85 521,35223 9,9 1043,48

Tabella 4.3

4.2.1 CUDA vs OpenCL

In questa sezione valutiamo le differenza prestazionali tra i due algoritmi di tipo GPGPU

proposti: CUDA e OpenCL.

Iniziamo però col fare alcune considerazioni generali sui tempi di esecuzione dell'algoritmo.

Come si vede dalla illustrazione 4.2, che si riferisce a due benchmark del codice CUDA per

valori di 'N' uguali a 222 e 222, si evince che:

• fissata la dimensione del buffer, il tempo di generazione è linearmente dipendente alla

durata del segnale generato (tempo di coalescenza) nell'intervallo considerato;

• cambiando la dimensione del buffer, quindi il numero di thread, il tempo di

generazione cambia proporzionalmente; quindi, come vedremo meglio in seguito, per

ottimizzare le prestazioni, bisogna scegliere, in rapporto al tempo di coalescenza del

sistema, il valore minimo di N in grado di generare il segnale nel suo intero arco

temporale.

70

4.2 BENCHMARK TEST

Ora passiamo più propriamente al confronto delle prestazioni tra le due implementazioni su

GPU proposte.

Dalla illustrazione 4.3 si denota che anche il codice OpenCL presenta un aumento lineare tale

che un aumento del tempo di coalescenza implica un aumento del tempo di generazione. Si

osserva, però, che i tempi generazione di OpenCL risultano sistematicamente maggiori

rispetto a CUDA.

Oltre al tempo di generazione, un altro fattore che descrive l'efficienza computazionale

dell'algoritmo è il numero di punti del segnale elaborati ogni millisecondo. Tale fattore è

calcolato dividendo il numero totale di punti del segnale da simulare (uguale alla frequenza di

campionamento moltiplicata per la durata del segnale) per il tempo di generazione.

L' illustrazione 4.4 mostra appunto la corrispondenza tra punti del segnale elaborati ogni

millisecondo (asse y) in funzione della durata del segnale stesso (asse x).

71

Illustrazione 4.2: Tempo di generazione in funzione della durata del segnale in CUDA

4.2 BENCHMARK TEST

72

Illustrazione 4.3: Tempo di esecuzione di CUDA e OpenCL

Illustrazione 4.4: Punti del segnale generati ogni millisecondo da CUDA e OpenCL

4.2 BENCHMARK TEST

Come è evidente, emerge ancora una superiorità prestazionale del codice CUDA rispetto ad

OpenCL. Si osserva inoltre un altro fenomeno in entrambe le implementazioni: col crescere

del tempo di coalescenza aumenta anche il numero di punti del segnale generati ad ogni

istante temporale. Ciò significa che seppure l'aumento della lunghezza del segnale, e quindi

del tempo di coalescenza, implichi un aumento assoluto del tempo di generazione, questa però

mostra anche un aumento delle prestazioni normalizzate per punti del segnale elaborati. Tale

aumento di performance è probabilmente dovuto ad effetti di coding.

Questa caratteristica è particolarmente evidente anche dalla illustrazione 4.5, in cui viene

messo in relazione il rapporto tra tempo di generazione e tempo di coalescenza (asse y) in

funzione del tempo di coalescenza stesso (asse x). Infatti la funzione è decrescente, ovvero il

rapporto tra tempo di generazione e tempo di coalescenza diminuisce all'aumentare della

lunghezza del segnale. Si osserva quindi un aumento prestazionale.

73

Illustrazione 4.5: Rapporto tra tempo di esecuzione e durata del segnale in CUDA e OpenCL

4.2 BENCHMARK TEST

4.2.2 CPU vs GPU

In questa sezione si mettono a confronto le prestazioni tra l'algoritmo CUDA e quello CPU.

Il codice CPU è eseguito sul processore della famiglia Intel Xeon E5520, che ha una

frequenza di clock di 2.27 Ghz, mentre ricordiamo che la GPU utilizzata per l'elaborazione

del codice CUDA è una scheda NVIDIA Tesla C1060, con 240 cores con frequenza di 1.3

Ghz.

L'illustrazione 4.6 mette in rapporto il tempo di esecuzione dell'algoritmo (asse y) in funzione

della durata del segnale (tempo di coalescenza). Il test è stato effettuato sullo stesso set di

parametri:

• m1 = m2 = 1.4;

• frequenza di campionamento = 4000 Hz;

• frequenza iniziale variabile: da 12 a 20 Hz.

In questo caso è stata presa in considerazione l'esecuzione dell'algoritmo CUDA impostando

la dimensione del buffer, ovvero il numero totale di threads, pari a N = 222.

74

Illustrazione 4.6: Confronto prestazionale CPU-GPU

4.2 BENCHMARK TEST

L'asse y è rappresentato in scala logaritmica in base 10. Quindi, come si nota dal grafico

dell'illustrazione 4.6, il guadagno prestazionale dell'algoritmo su GPU è di circa due ordini di

grandezza (un fattore di circa 100x) migliore di quello su CPU. Inoltre, un altro fattore che si

evidenzia dal grafico è che il guadagno prestazionale aumenta al crescere della durata del

segnale.

Queste caratteristiche sono numericamente espresse dalla tabella 4.4, in cui sono riportati i

tempi di esecuzione degli algoritmi valutati, in rapporto alla frequenza di taglio e quindi alla

durata del segnale. Nell'ultima colonna è riportato il fattore di guadagno prestazionale

dell'algoritmo su GPU rispetto a quello su CPU. Come si ben nota, l'algoritmo proposto ha un

fattore massimo di guadagno che è anche leggermente superiore a due ordini di grandezza

(106,95 x). Il guadagno prestazionale aumenta proporzionalmente insieme al tempo di

coalescenza.

Frequenza di taglio (Hz)

Tempo di coalescenza (secondi)

Tempo Generazione CPU (secondi)

Tempo Generazione GPU (secondi)

Guadagno prestazionale

13 505,498 1,91986 0,01795 106,9513,5 457,207 1,73440 0,01626 106,6614 415,043 1,57699 0,01565 100,7614,5 378,048 1,43836 0,01598 90,0115 345,443 1,31877 0,01492 88,3915,5 316,584 1,21118 0,01504 80,5316 290,941 1,11684 0,01390 80,3516,5 268,071 1,03043 0,01400 73,617 247,603 0,93572 0,01451 64,4917,5 229,225 0,87122 0,01347 64,6818 212,672 0,81376 0,01337 60,8618,5 197,720 0,75242 0,01415 53,1719 184,176 0,70169 0,01334 52,619,5 171,877 0,65514 0,01394 46,9920 160,680 0,61462 0,01302 47,2

Tabella 4.4

75

76

Capitolo 5

Conclusioni

Il lavoro svolto in questa testi analizza l’utilizzo delle GPU nell’ambito dell’analisi dati e

detection di segnali gravitazionali. In particolare queste nuove architetture many-core

permettono di ridurre notevolmente i tempi di elaborazione e di guadagnare quindi in capacità

di indagine scientifica. I risultati riportati in questa testi oltre a confermare le previsioni in

termini di incremento prestazionale, data la loro originalità saranno utilizzati concretamente

come base per l’analisi in produzione e sviluppo di progetti futuri.

In questo lavoro di tesi ho acquisito conoscenza in merito al modello di programmazione ed

esecuzione CUDA ed OpenCL, che sono i due framework più importanti per lo sviluppo di

applicazioni su GPU. Inoltre ho acquisito confidenza con il problema inerente l'elaborazione

digitale dei segnali ed in particolar modo con le problematiche di generazione di segnali

gravitazionali simulati su GPGPU. Il tipo di segnale simulato è stato quello proveniente da

binaria coalescenti calcolato con approssimazioni Post Newtoniane all’ordine 3.5 in fase e 2

in ampiezza.

Per lo scopo del mio lavoro, ho sviluppato una libreria di generazione di segnali che è

attualmente inclusa ed utilizzata all’interno del programma cuInspiral, primo prototipo

avanzato multi-GPU per la detection di segnali gravitazionali sui dati di detector

gravitazionali quali Virgo e LIGO. L’algoritmo da me realizzato prende in ingresso un set di

parametri fisici quali masse e distanza delle sorgenti, inizializza i parametri delle serie

5 CONCLUSIONI

utilizzando tecniche di approssimazioni, quali Newton-Raphson, e calcola gli elementi che

compongono le serie Post-Newtoniana in successione. La criticità di queste operazioni sta

nella complessità delle stesse, nella reale possibilità di overflow dovuto all’intervallo

numerico di lavoro e dalla necessità di introdurre alcuni elementi di ottimizzazione al fine di

cercare di utilizzare le potenzialità delle GPU nel calcolo in singola precisione su funzioni

aritmetiche. A tale scopo abbiamo introdotto trucchi di ricalibrazione numerica, di definizione

di variabili temporanee per la memorizzazione dei valori potenza su funzioni trascendentali e

trigonometriche.

Inoltre al fine di analizzare le prestazione dell’algoritmo ed accuratezza numerica ho

realizzato dei benchmark in cui si confrontano sia i risultati su GPU fra le due soluzioni

CUDA ed OpenCL che fra GPU e CPU.

Nell’attuale versione del codice CUDA si sono ottenuto risultati più performanti rispetto ad

OpenCL. Inoltre è da ricordare che OpenCL ha un livello di complessità di programmazione

superiore a CUDA. Sicuramente sul lato OpenCL ci sono ancora notevoli margini di

miglioramento.

Nel confrontare invece la soluzione CUDA con l’analogo algoritmo su CPU si sono ottenuti

risultati estremamente importanti che confermano la bontà della soluzione GPU. Infatti il

guadagno ottenuto nei test è nell’intervallo di 80-100 volte rispetto al codice su CPU. Questo

permette di dimostrare come queste nuove architetture many-core non solo offrono fattori di

guadagno notevoli ma si candidano come la probabile risposta nelle problematiche di HPC

future, sempre più bisognose di potenza di calcolo.

Nell’ambito del problema oggetto di questa testi, cioè l’analisi di onde gravitazionali, le GPU,

ed in prospettiva le architetture many-core, sono la probabile soluzione per i detector futuri

quali ad esempio l’Einstein Telescope, progetto europeo attualmente nella fase di design

finanziato dal 7° programma quadro (FP7/2007-2013) grant agreement n.211743. Questo

progetto infatti cambierà il modo di vedere l'universo introducendo le onde gravitazionali al

pari delle onde elettromagnetiche come strumento di osservazione. Infatti come oggi si guarda

il cosmo in funzione della luce visibile, X, onde radio ed altro, in futuro potremo studiarlo

anche in funzione delle onde gravitazionali con un approccio di osservazione completamente

nuovo. Questo tipo di approccio richiederà quindi un ingente sforzo anche computazionale,

dove le GPU, e questo lavoro di testi lo hanno dimostrato, potranno essere un’ottima

soluzione potenziale.

77

5 CONCLUSIONI

Ringrazio sentitamente i miei relatori per avermi permesso di compiere questa esperienza e

ringrazio inoltre l'esperimento INFN MaCGO (Manycore Computing for future gravitational

observatory) ed il progetto europeo Einstein Telescope design finanziato dal 7° programma

quadro (FP7/2007-2013) grant agreement n 211743 avendo fornito il supporto tecnico e

teorico per lo sviluppo di questa tesi

78

79

Appendice A

Implementazione CUDA

#include <cuInspiral.h>#include <cuInspiral_PN.h>#include <sys/time.h>

#define THREADXBLOCK 256

int main(int argc, char* argv[]){

//Definizione del set di parametri float m1 = atof(argv[3]); //prima massa float m2 = atof(argv[4]); //seconda massa float r = 1.; //distanza long N = pow(2,atoi(argv[1])); //numero totale di thread float dt = 1./atof(argv[2]); float fsampling = atof(argv[2]); //frequenza di campionamento float fmin = atof(argv[5]); //frequenza di taglio float omega0 = 15.; //frequenza iniziale float phase0 = .0; //fase iniziale scalef = CUINSPIRAL_SCALE_F; //Fattore di scala

float * hpt = (float *) malloc(sizeof(float) * N); float * hqt = (float *) malloc(sizeof(float) * N); //Dichiarazione dei parametri per il device float *d_omega0, *d_phase0; float *hpt_dev, *hqt_dev;

IMPLEMENTAZIONE CUDA

float* d_start; float* d_ct; int* d_N; float* d_dt; float *ws_dev; float scalef, * d_scalef;

int sizeofns = sizeof(float)*N; int sizeofws = sizeof(float)*73; int sizeoffloat = sizeof(float); double Tstart, Tstop;

//Definizione della dimensione della griglia int threadsPerBlock= THREADXBLOCK; int threadsPerGrid = (N + THREADXBLOCK-1 - 1) / THREADXBLOCK; int nthread = threadsPerBlock * threadsPerGrid;

workspace ws;

//Allocazione della memoria del device cudaMalloc((void**)&d_omega0,sizeoffloat); cudaMalloc((void**)&d_phase0,sizeoffloat); cudaMalloc((void**)&hpt_dev,sizeofns); cudaMalloc((void**)&hqt_dev,sizeofns); cudaMalloc((void**)&d_start,sizeoffloat); cudaMalloc((void**)&d_ct,sizeoffloat); cudaMalloc((void**)&d_dt,sizeof(float)); cudaMalloc((void**)&d_N, sizeof(int)); cudaMalloc((void**)&ws_dev,sizeofws); cudaMalloc((void**)&d_scalef,sizeof(float));

//Inizializzazione del workspace cuInspiral_PN6_init_workspace(&ws, m1, m2, r); //Calcolo del tempo di coalescenza float ct = cuInspiral_PN6_find_coalescence_time(&ws, fmin);

//Trasferimento dati sulla memoria del device cudaMemcpy(d_phase0,&phase0,sizeoffloat,cudaMemcpyHostToDevice); cudaMemcpy(d_omega0,&omega0,sizeoffloat,cudaMemcpyHostToDevice); cudaMemcpy(d_start,&start,sizeoffloat,cudaMemcpyHostToDevice);

80

IMPLEMENTAZIONE CUDA

cudaMemcpy(d_ct,&ct,sizeoffloat,cudaMemcpyHostToDevice); cudaMemcpy(d_dt,&dt,sizeoffloat,cudaMemcpyHostToDevice); cudaMemcpy(d_N,&N,sizeof(int), cudaMemcpyHostToDevice); cudaMemcpy(ws_dev,&ws,sizeofws,cudaMemcpyHostToDevice); cudaMemcpy(d_scalef, &scalef,sizeof(float),cudaMemcpyHostToDevice);

if (ct>N/fsampling) { printf("Increase vector size.\n"); }else { //Esecuzione del kernel cuInspiral_kernel_PN6_0_signal_proc<<<threadsPerGrid,threadsPerBlock>>>

ws_dev,d_N, d_dt, d_ct,d_omega0, d_phase0, d_scalef, hpt_dev, hqt_dev); } //Sincronizzazione tra host e device cudaThreadSynchronize();

//Trasferimento dati dal device all'host cudaMemcpy(hpt,hpt_dev,sizeofns,cudaMemcpyDeviceToHost); cudaMemcpy(hqt,hqt_dev,sizeofns,cudaMemcpyDeviceToHost);

//Liberazione della memoria cudaFree(ws_dev); cudaFree(hpt_dev);

free( hpt ); free( hqt );}

//Codice del kernel che genera il segnal al primo livello di approssimazione__global__ void cuInspiral_kernel_PN6_0_signal_proc(float* workspace, int *N, float *dt, float*ct, float* omega0, float* phase0, float *d_scalef, float *hpt, float *hqt){ //Indicizzazione dei thread int thid = blockDim.x*blockIdx.x+threadIdx.x;

//Definizione della struttura

float Tm =workspace[0]; //Tm = Gm/c^3

81

IMPLEMENTAZIONE CUDA

float mass_total =workspace[1]; //Massa totale del sistema in Kg float mass_reduced =workspace[2]; //Massa ridotta float dM =workspace[3]; //Differenza di massa tra i due corpi float nu =workspace[4]; //Rapporto tra massa totale e massa ridotta

float aw=workspace[5]; //Coefficienti per la frequenza istantanea float bw=workspace[6]; float cw=workspace[7]; float dw=workspace[8]; float ew=workspace[9]; float fw=workspace[10]; float gw=workspace[11]; float hw=workspace[12];

//Coeddicienti per la fase istantanea float ap=workspace[13]; float bp=workspace[14]; float cp=workspace[15]; float dp=workspace[16]; float ep=workspace[17]; float fp=workspace[18]; float gp=workspace[19]; float hp=workspace[20];

float h0=workspace[21];

float plus_c00=workspace[22]; //Coefficienti per la plus polarization float plus_c02=workspace[23]; //float plus_c11=workspace[24]; //float plus_c13=workspace[25]; //float plus_c22=workspace[26]; //float plus_c24=workspace[27]; //float plus_c31=workspace[28]; //float plus_c32=workspace[29]; //float plus_c33=workspace[30]; //float plus_c35=workspace[31]; //float plus_c41=workspace[32]; //float plus_c42=workspace[33]; //float plus_c43=workspace[34]; //float plus_c44=workspace[35]; //float plus_c46=workspace[36]; //float plus_s41=workspace[37]; //float plus_s43=workspace[38];

82

IMPLEMENTAZIONE CUDA

//float plus_c51=workspace[39]; //float plus_c52=workspace[40]; //float plus_c53=workspace[41]; //float plus_c54=workspace[42]; //float plus_c55=workspace[43]; //float plus_c57=workspace[44]; //float plus_s52=workspace[45]; //float plus_s54=workspace[46];

float cross_s02=workspace[47]; //Coefficienti per la cross polarization //float cross_s11=workspace[48]; //float cross_s13=workspace[49]; //float cross_s22=workspace[50]; //float cross_s24=workspace[51]; //float cross_s31=workspace[52]; //float cross_s32=workspace[53]; //float cross_s33=workspace[54]; //float cross_s35=workspace[55]; //float cross_s41=workspace[56]; //float cross_s42=workspace[57]; //float cross_s43=workspace[58]; //float cross_s44=workspace[59]; //float cross_s46=workspace[60]; //float cross_c41=workspace[61]; //float cross_c43=workspace[62]; //float cross_c50=workspace[63]; //float cross_c52=workspace[64]; //float cross_c54=workspace[65]; //float cross_s51=workspace[66]; //float cross_s52=workspace[67]; //float cross_s53=workspace[68]; //float cross_s54=workspace[69]; //float cross_s55=workspace[70]; //float cross_s57=workspace[71];

float normw =workspace[72]; //Normalizzazione di 'w'

float t, tau; float w, p, ph; float y, y2, y3, iy, iy2, iy3, x, Plus0, Cross0; float duration= *ct;

83

IMPLEMENTAZIONE CUDA

if (thid<=(*N)) { t = duration - (*dt)*thid; if (t>0) { tau = nu*t/(5.0*Tm); y = powf(tau,-0.125); iy = 1.0/y; y2 = y*y; y3 = y*y*y; iy2 = iy*iy; iy3 = iy*iy*iy; //Calculate omega w = normw*y*y*y*(1 + bw*y2 + cw*y3 + dw*y2*y2 + ew*y2*y3 + (fw + gw*logf(2.*y))*y3*y3 + hw*y3*y3*y); if (w>0) { //Calcolo della fase p = ap*iy3*iy2 + bp*iy3 + cp*iy2 + dp*iy + 8.0*ep*logf(iy)+ fp*y + (-8.)*gp*logf(2.*y) + hp*y2;

//Calcolo della fase in funzione di 'p' e 'w' ph = *phase0+p-2.0*Tm*w*logf(w/(*omega0)); ph - ceil(ph/(2.0*M_PI))*2.0*M_PI;

x = cbrt(Tm*w); // NOTE : x=(Tm*w)^2/3 float cos2 = cosf (2.*ph); float sin2 = sinf (2.*ph); plus_c00 = 0;

//Calcolo della plus e cross polarization

84

IMPLEMENTAZIONE CUDA

Plus0 = plus_c00 + plus_c02*cos2; Cross0= cross_s02*sin2; //Salavataggio dei dati finali hpt[thid] = (h0*x*x*Plus0) * (*d_scalef); //Inphase hqt[thid] = (h0*x*x*Cross0) * (*d_scalef); //Inquadrature } else

{ hpt[thid] = 0.0; hqt[thid] = 0.0; } } else { hpt[thid] = 0.0; hqt[thid] = 0.0; } }}

85

86

Appendice B

Implementazione OpenCL

#include <stdio.h>#include <stdlib.h>#include <math.h>#include <CL/cl.h>#include <string.h>#include <fcntl.h>#include <sys/time.h>

#define CUINSPIRAL_SCALE_F 1.0e21#define THREADXBLOCK 256

static double MPC = 3.0856775807e22; static double GRAVITATIONAL_G = 6.67259e-11; static double LIGHTSPEED = 2.99792458e8; static double SOLARMASS = 1.98892e30; static double GAMMAE = 0.577215664901532860607; static double lambda = -1987./3080.; static double theta = -11831./9240.; static double TMIN = 1e-2; static double TMAX = 1e8;

//Dichiarazione degli OpenCL objects cl_platform_id my_platform; // OpenCL platform cl_context my_context; // OpenCL context cl_command_queue my_command_queue; // OpenCL command queue cl_device_id* devices; // OpenCL device list cl_program my_program; // OpenCL program cl_kernel my_kernel; // OpenCL kernel

//Dichiarazione dei parametri del device

IMPLEMENTAZIONE OPENCL

cl_mem d_omega0; cl_mem d_phase0; cl_mem hpt_dev; cl_mem hqt_dev; cl_mem d_ct; cl_mem d_N; cl_mem d_dt; cl_mem ws_dev; cl_mem d_scalef;

cl_int ciErr1;

char* c_source_CL = NULL;

const char* ProgramSource = "inspiral_pn.cl"; //File del kernel size_t kernel_length;

int main(int argc, char *argv[]){

//Definizione dei parametri float m1 = atof(argv[3]); float m2 = atof(argv[4]); float r = 1.;//*MPC; int exp = atoi(argv[1]); long N = pow(2,atoi(argv[1])); float dt = 1./atof(argv[2]); float fcut = atof(argv[5]); int ID = atoi(argv[6]);

float omega0 = 15.; float phase0 = .0; float * hpt = (float *) malloc(sizeof(float) * N); float * hqt = (float *) malloc(sizeof(float) * N); float scalef = CUINSPIRAL_SCALE_F;

size_t sizeofns = sizeof(float)*N; size_t sizeofws = sizeof(float)*73; size_t sizeoffloat = sizeof(float);

float* _omega0 = (float*) malloc(sizeoffloat);

87

IMPLEMENTAZIONE OPENCL

float* _phase0 = (float*) malloc(sizeoffloat); float* _ct = (float*) malloc(sizeoffloat); long* _N = (long*) malloc(sizeof(long)); float* _dt = (float*) malloc(sizeoffloat); float* _scalef = (float*) malloc(sizeoffloat);

*_omega0 = omega0; *_phase0 = phase0; *_N = N; *_dt = dt; *_scalef = scalef;

//Inizializzazione del workspace workspace ws; cuInspiral_PN6_init_workspace(&ws, m1, m2, r);

// Calculolo del tempo di coalescenza float ct = cuInspiral_PN6_find_coalescence_time(&ws, fcut); *_ct = ct;

//Preparazione del device //Creazione della platform cl_uint numPlatforms = 0; ciErr1 = clGetPlatformIDs(0, 0, &numPlatforms); if (ciErr1 != CL_SUCCESS) { printf("Error in clGetPlatformID !!!\n\n"); } printf("Number of platforms = %d\n", numPlatforms);

cl_platform_id* platforms = NULL; platforms = (cl_platform_id*)malloc(sizeof(cl_platform_id)*numPlatforms); ciErr1 = clGetPlatformIDs(numPlatforms, platforms, &numPlatforms); if (ciErr1 != CL_SUCCESS) { printf("Error in clGetPlatformIDs !!!Err code: %d\n\n",ciErr1); } //Selezione del device cl_uint num_of_devices = 0; ciErr1 = clGetDeviceIDs(platforms[0], CL_DEVICE_TYPE_GPU, 0, 0,

&num_of_devices); char device_name[1024]; int x;

cl_device_id* devs = NULL;

88

IMPLEMENTAZIONE OPENCL

devs = (cl_device_id*)malloc(sizeof(cl_device_id)*num_of_devices); clGetDeviceIDs (platforms[0], CL_DEVICE_TYPE_GPU, num_of_devices, devs, NULL); if (ciErr1 != CL_SUCCESS) { printf("Error in clGetDeviceIDs !!!Err code :

%d\n\n", ciErr1); } printf("Number of devices = %d\n", num_of_devices); for ( x=0; x < num_of_devices; x++) { clGetDeviceInfo(devs[x], CL_DEVICE_NAME,sizeof(device_name), &device_name, NULL); printf("DEVICE %d = %s \n \n \n", x, device_name); } //Creazione del context cl_context_properties cp[3]; cp[0] = CL_CONTEXT_PLATFORM; cp[1] = (cl_context_properties)platforms[0]; cp[2] = 0;

cl_device_id devid; devid = devs[ID];

my_context = clCreateContext(cp, 1, &devid,NULL,NULL,&ciErr1); if (ciErr1 != CL_SUCCESS) { printf("Error in clCreateContext !!!\n\n" ); }

//Creazione della command queue my_command_queue = clCreateCommandQueue(my_context, devid,

CL_QUEUE_PROFILING_ENABLE, &ciErr1 ); if (ciErr1 != CL_SUCCESS) { printf("Error in clCreateCommandQueue !!!\n\n" ); }

//Allocazione e trasferimento dei dati sul device

d_omega0 = clCreateBuffer(my_context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR, sizeoffloat, (void*)_omega0, &ciErr1);

if (ciErr1 != CL_SUCCESS) { printf ("Error in clCreateBuffer!!!Error code: %d\n", ciErr1); }

d_phase0 = clCreateBuffer(my_context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR, sizeoffloat, (void*)_phase0, &ciErr1);

if (ciErr1 != CL_SUCCESS) { printf ("Error in clCreateBuffer!!!Error code: %d\n", ciErr1); }

d_ct = clCreateBuffer(my_context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR, sizeoffloat, (void*)_ct, &ciErr1);

89

IMPLEMENTAZIONE OPENCL

if (ciErr1 != CL_SUCCESS) { printf ("Error in clCreateBuffer!!!Error code: %d\n", ciErr1); }

d_dt = clCreateBuffer(my_context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR, sizeoffloat, (void*)_dt, &ciErr1);

if (ciErr1 != CL_SUCCESS) { printf ("Error in clCreateBuffer!!!Error code: %d\n", ciErr1); }

d_scalef = clCreateBuffer(my_context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR, sizeoffloat, (void*)_scalef, &ciErr1);

if (ciErr1 != CL_SUCCESS) { printf ("Error in clCreateBuffer!!!Error code: %d\n", ciErr1); }

d_N = clCreateBuffer(my_context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR, sizeof(long), (void*)_N, &ciErr1);

if (ciErr1 != CL_SUCCESS) { printf ("Error in clCreateBuffer!!!Error code: %d\n", ciErr1); }

ws_dev = clCreateBuffer(my_context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR, sizeofws, (void*)&ws, &ciErr1);

if (ciErr1 != CL_SUCCESS) { printf ("Error in clCreateBuffer!!!Error code: %d\n", ciErr1); }

hpt_dev = clCreateBuffer(my_context, CL_MEM_READ_WRITE, sizeofns, NULL, &ciErr1);

if (ciErr1 != CL_SUCCESS) { printf ("Error in clCreateBuffer!!!Error code: %d\n", ciErr1); }

hqt_dev = clCreateBuffer(my_context, CL_MEM_READ_WRITE, sizeofns, NULL, &ciErr1);

if (ciErr1 != CL_SUCCESS) { printf ("Error in clCreateBuffer!!!Error code: %d\n", ciErr1); }

//Creazione e costruzione del program objectc_source_CL = utils_load_prog_source(ProgramSource, "", &kernel_length);

const char* CLsrc = c_source_CL;my_program = clCreateProgramWithSource(my_context, 1, &CLsrc, &kernel_length,

&ciErr1); if (ciErr1 != CL_SUCCESS) { printf("Error in clCreateProgramWithSource !!!Err

code %d\n\n", ciErr1 ); }

clBuildProgram(my_program, 1, &devid, NULL, NULL, NULL);

90

IMPLEMENTAZIONE OPENCL

if (ciErr1 != CL_SUCCESS) { printf("Error in clBuildProgram !!!Err code %d\n\n", ciErr1 ); }

//Creazione del kernel my_kernel = clCreateKernel(my_program, "kernel_PN6_0_signal_proc", &ciErr1); if (ciErr1 != CL_SUCCESS) { printf("Error in clCreateKernel !!!Err code

%d\n\n", ciErr1 ); }

//Settaggio dei parametri del kernel

size_t sizeofclmem = sizeof(cl_mem);

ciErr1 = clSetKernelArg(my_kernel, 0, sizeofclmem, (void*)&ws_dev); if (ciErr1!=CL_SUCCESS) printf("Error in setKernelArg(ws), error code = %d\n",

ciErr1); ciErr1 = clSetKernelArg(my_kernel, 1, sizeofclmem, (void*)&d_N); if (ciErr1!=CL_SUCCESS) printf("Error in setKernelArg(N), error code = %d\n",

ciErr1); ciErr1 = clSetKernelArg(my_kernel, 2, sizeofclmem, (void*)&d_dt); if (ciErr1!=CL_SUCCESS) printf("Error in setKernelArg(dt), error code = %d\n",

ciErr1); ciErr1 = clSetKernelArg(my_kernel, 3, sizeofclmem, (void*)&d_ct); if (ciErr1!=CL_SUCCESS) printf("Error in setKernelArg(ct), error code = %d\n",

ciErr1); ciErr1 = clSetKernelArg(my_kernel, 4, sizeofclmem, (void*)&d_omega0); if (ciErr1!=CL_SUCCESS) printf("Error in setKernelArg(omega0), error code =

%d\n", ciErr1); ciErr1 = clSetKernelArg(my_kernel, 5, sizeofclmem, (void*)&d_phase0); if (ciErr1!=CL_SUCCESS) printf("Error in setKernelArg(phase0), error code =

%d\n", ciErr1); ciErr1 = clSetKernelArg(my_kernel, 6, sizeofclmem, (void*)&d_scalef); if (ciErr1!=CL_SUCCESS) printf("Error in setKernelArg(scalef), error code =

%d\n", ciErr1); ciErr1 = clSetKernelArg(my_kernel, 7, sizeofclmem, (void*)&hpt_dev); if (ciErr1!=CL_SUCCESS) printf("Error in setKernelArg(hpt), error code = %d\n",

ciErr1); ciErr1 = clSetKernelArg(my_kernel, 8, sizeofclmem, (void*)&hqt_dev); if (ciErr1!=CL_SUCCESS) printf("Error in setKernelArg(hqt), error code = %d\n",

ciErr1);

//Definizione della dimensionalità della griglia const size_t cnBlockSize = THREADXBLOCK;

91

IMPLEMENTAZIONE OPENCL

const size_t cnBlocks = (N + THREADXBLOCK-1 - 1) / THREADXBLOCK; const size_t cnDimension = cnBlocks * cnBlockSize;

cl_event event;

if(1/dt*ct >= N/2) printf("WARNING : Increase vector size!!! N = 2^%d\n", exp); //Esecuzione del kernel clEnqueueNDRangeKernel(my_command_queue, my_kernel, 1, 0, &cnDimension,

&cnBlockSize, 0, 0, &event); if (ciErr1 != CL_SUCCESS) printf("Error in Barrier()!!!Err code: %d\n",ciErr1); //Sincronizzazione tra CPU e GPU clWaitForEvents ( 1, &event);

// Trasferimento dei dati dal device all'host clEnqueueReadBuffer(my_command_queue, hpt_dev, CL_TRUE, 0, sizeofns, hpt, 0,

NULL, NULL); clEnqueueReadBuffer(my_command_queue, hqt_dev, CL_TRUE, 0, sizeofns, hqt, 0,

NULL, NULL);

float l = (1/dt)*ct; for (i=0;i<=l;i++) {

//Stampa i dati sullo schermo fprintf(stdout,"%12f:%12.10g:%12.10g\n",(float)i*dt,hpt[i]/

(double)scalef,hqt[i]/(double)scalef); } //Liberazione delle risorse

free( hpt ); free( hqt );

}

//Codice del kernel OpenCL che esegue il primo livello di approssimazione: PN0

__kernel void kernel_PN6_0_signal_proc( __global float* workspace, __global long *N, __global const float* dt,

92

IMPLEMENTAZIONE OPENCL

__global const float* ct, __global const float* omega0, __global const float* phase0, __global const float* d_scalef, __global float* hpt, __global float* hqt){ unsigned int gid = get_global_id(0);

//Definizione della struttura

float Tm =workspace[0]; // The time constant \f$T_m = Gm/c^3\f$. float mass_total =workspace[1]; // The total mass of the system, in Kg float mass_reduced =workspace[2]; // The reduced mass of the system, in Kg float dM =workspace[3]; // The mass difference between the two bodies

of the system, in Kg float nu =workspace[4]; // Ratio between reduced and total mass

//float aw=workspace[5]; // Coeff. for istantaneous frequency float bw=workspace[6]; float cw=workspace[7]; float dw=workspace[8]; float ew=workspace[9]; float fw=workspace[10]; float gw=workspace[11]; float hw=workspace[12];

float ap=workspace[13]; // Coeff. for istantaneous phase float bp=workspace[14]; float cp=workspace[15]; float dp=workspace[16]; float ep=workspace[17]; float fp=workspace[18]; float gp=workspace[19]; float hp=workspace[20];

float h0=workspace[21];

float plus_c00=workspace[22]; // Coeff. for the plus polarization float plus_c02=workspace[23]; //float plus_c11=workspace[24]; //float plus_c13=workspace[25];

93

IMPLEMENTAZIONE OPENCL

//float plus_c22=workspace[26]; //float plus_c24=workspace[27]; //float plus_c31=workspace[28]; //float plus_c32=workspace[29]; //float plus_c33=workspace[30]; //float plus_c35=workspace[31]; //float plus_c41=workspace[32]; //float plus_c42=workspace[33]; //float plus_c43=workspace[34]; //float plus_c44=workspace[35]; //float plus_c46=workspace[36]; //float plus_s41=workspace[37]; //float plus_s43=workspace[38]; //float plus_c51=workspace[39]; //float plus_c52=workspace[40]; //float plus_c53=workspace[41]; //float plus_c54=workspace[42]; //float plus_c55=workspace[43]; //float plus_c57=workspace[44]; //float plus_s52=workspace[45]; //float plus_s54=workspace[46];

float cross_s02=workspace[47]; // Coeff. for the cross polarization //float cross_s11=workspace[48]; //float cross_s13=workspace[49]; //float cross_s22=workspace[50]; //float cross_s24=workspace[51]; //float cross_s31=workspace[52]; //float cross_s32=workspace[53]; //float cross_s33=workspace[54]; //float cross_s35=workspace[55]; //float cross_s41=workspace[56]; //float cross_s42=workspace[57]; //float cross_s43=workspace[58]; //float cross_s44=workspace[59]; //float cross_s46=workspace[60]; //float cross_c41=workspace[61]; //float cross_c43=workspace[62]; //float cross_c50=workspace[63]; //float cross_c52=workspace[64]; //float cross_c54=workspace[65]; //float cross_s51=workspace[66];

94

IMPLEMENTAZIONE OPENCL

//float cross_s52=workspace[67]; //float cross_s53=workspace[68]; //float cross_s54=workspace[69]; //float cross_s55=workspace[70]; //float cross_s57=workspace[71];

float normw =workspace[72]; // Normalization of 'w'

float t, tau; float w, p, ph; float y, y2, y3, iy, iy2, iy3, x, Plus0, Cross0; float duration= *ct;

if (gid<=(*N)) {e

t = duration - (*dt)*gid; if (t>0) { //Calculate omega tau = nu*t/(5.0*Tm); y = pow(tau,-0.125); iy = 1.0/y; y2 = y*y; y3 = y*y*y; iy2 = iy*iy; iy3 = iy*iy*iy; w = normw*y*y*y*(1 + bw*y2 + cw*y3 + dw*y2*y2 + ew*y2*y3 + (fw + gw*log(2.*y))*y3*y3 + hw*y3*y3*y); if (w>0) { //Calculate phase p = ap*iy3*iy2 + bp*iy3 + cp*iy2 + dp*iy +

95

IMPLEMENTAZIONE OPENCL

8.0*ep*log(iy)+ fp*y + (-8.)*gp*log(2.*y) + hp*y2; ph = *phase0+p-2.0*Tm*w*log(w/(*omega0));

ph - ceil(ph/(2.0*M_PI))*2.0*M_PI;

//ALIASES x = cbrt(Tm*w); // NOTE : x=(Tm*w)^2/3 float cos2 = cos(2.*ph); float sin2 = sin(2.*ph); plus_c00 = 0; //Calculate plus and cross polarization Plus0 = plus_c00 + plus_c02*cos2; Cross0= cross_s02*sin2;

//SAVING FINAL RESULTS hpt[gid] = (h0*x*x*Plus0) * (*d_scalef); //Inphase hqt[gid] = (h0*x*x*Cross0) * (*d_scalef); //Inquadrature

}else { hpt[gid] = 0.0; hqt[gid] = 0.0; } } else { hpt[gid] = 0.0; hqt[gid] = 0.0; } }}

96

97

Ringraziamenti

Qualche breve e doveroso ringraziamento al termine di questo lavoro. In particolar modo

desidero ringraziare i miei genitori, a cui dedico questo lavoro, per avermi dato la possibilità

di intraprendere la carriera universitaria ed avermi sostenuto moralmente ed economicamente;

vorrei che questo mio traguardo raggiunto, per quanto possibile, fosse un premio anche per

loro.

Un grazie speciale va a Giovanna per essermi stata sempre vicina anche nei momenti di gran

difficoltà e per avermi sempre dato una parola d’incoraggiamento e ai miei fratelli Vincenzo,

Roberto ed Erminia che sono parte di me.

Voglio anche ringraziare tutti le persone, amici e familiari che sono stati presenti nella mia

vita e che con il loro affetto mi hanno aiutato ad arrivare fin qui.

Un particolare ringraziamento ai miei relatori, Dr. Leonello Servoli e Dr. Leone Bosi, che

sono stati estremamente disponibili nell’aiutarmi durante tutto il lavoro svolto per questa tesi

di laurea. Infine ringrazio l'esperimento INFN MaCGO (Manycore Computing for future

gravitational observatory) ed il progetto europeo Einstein Telescope design finanziato dal 7°

programma quadro (FP7/2007-2013) grant agreement n. 211743 avendo fornito il supporto

tecnico e teorico per lo sviluppo di questa tesi.

98

Bibliografia

[1] Albert Einstein, Come io vedo il mondo – La teoria della relatività, Newton Compton 2010

[2] http://www.einsteinathome.org

[3] http://it.wikipedia.org/wiki/PSR_J0737-3039

[4] Luc Blanchet, Post Newtonian Computatio for binary inspiral waveform, arxiv:gr-qc/0104084, Aprile 2001

[5] Curt Cuttler, Kip S. Thorne, An overview of Gravitaional-Wave Sources, arXiv:gr-qc/0204090v 1, Aprile 2002

[6] http://www.virgo.infn.it

[7] http://www.ligo.caltche.edu

[8] http://lisa.jpl.nasa.gov

[9] http://www.nvidia.it/object/tesla_c1060_it.html

[10] http://www.gpgpu.it

[11] http://gpgpu.org/developer

[12] NVIDIA, NVIDIA CUDA Programming Guide, 2009

[13] NVIDIA, OpenCL programming guide for the CUDA architecture, version 2.3, 2010

[14] Aafttab Munshi, Khronos OpenCL Working Group, The OpenCL specification, version 1.0, 2009

99

Indice delle illustrazioni

Illustrazione 1.1: Effetto di un'onda gravitazionale.............................7

Illustrazione 1.2: Moto di un sistema binario coalescente........................10

Illustrazione 1.3: Schema di un interferometro di Michelson......................12

Illustrazione 1.4: Forma d'onda del segnale nella fase di inspiral...................19

Illustrazione 2.1: Evoluzione di CPU e GPU a confronto........................24

Illustrazione 2.2: Architettura di una Tesla C1060.............................26

Illustrazione 2.3: Insieme di multiprocessori SIMT con memoria condivisa............27

Illustrazione 2.4: API di CUDA.........................................31

Illustrazione 2.5: Modello di esecuzione dei kernel e gestione dei threads.............32

Illustrazione 3.1: Schema di esecuzione dell'algoritmo..........................48

Illustrazione 3.2: Corrispondenza tra variabile e coeficente dell'approssimazione PN......54

Illustrazione 3.3: Tempo di coalescenza in funzione della frequenza iniziale............55

Illustrazione 3.4: Schema del kernel di generazione del segnale....................62

Illustrazione 4.1: Template generati da CUDA e OpenCL a confronto................66

Illustrazione 4.2: Tempo di generazione in funzione della durata del segnale in CUDA.....71

Illustrazione 4.3: Tempo di esecuzione di CUDA e OpenCL......................72

Illustrazione 4.4: Punti del segnale generati ogni millisecondo da CUDA e OpenCL......72

Illustrazione 4.5: Rapporto tra tempo di esecuzione e durata del segnale in CUDA e OpenCL 73

Illustrazione 4.6: Confronto prestazionale CPU-GPU...........................74