modelli di programmazione per architetture di calcolo ... · scuola politecnica e delle scienze di...
TRANSCRIPT
Scuola Politecnica e delle Scienze di Base Corso di Laurea in Ingegneria Informatica Elaborato finale in PROGRAMMAZIONE I
Modelli di programmazione per architetture di calcolo eterogenee Anno Accademico 2013/2014 Candidato: Salvatore Maresca matr. N46001047
Ai giorni andati e a quelli che verranno, alla voglia di scoprire, ai veri amici, alla mia squadra del cuore, a mio cugino,
ma soprattutto alla mia famiglia.
“ Stay hungry, stay foolish. ”
Steve Jobs
Indice Indice .................................................................................................................................................. III Introduzione ......................................................................................................................................... 4 Capitolo 1: GPGPU .............................................................................................................................. 6
1.1 GPU computing: oltre la grafica. ............................................................................................ 6 1.2 Analisi delle prestazioni .......................................................................................................... 8 1.3 Case Study: calcolo di gestione del rischio finanziario .......................................................... 9
Capitolo 2: Graphics Processing Unit ................................................................................................ 11 2.1 La storia .................................................................................................................................... 11 2.2 Architettura .............................................................................................................................. 13
2.2.1 Esempio : architettura GPU NVIDIA Tesla ..................................................................... 16 2.3 GPU vs CPU: le evoluzioni negli anni ..................................................................................... 17 2.4 Heterogeneous System Architecture ........................................................................................ 18
Capitolo 3: Modelli di programmazione GPU ................................................................................... 19 3.1 CUDA ...................................................................................................................................... 20
3.1.1 Scalabilità automatica e gerarchia dei threads .................................................................. 20 3.1.2 Paradigma di programmazione ......................................................................................... 21 3.1.3 Cenni sulla compilazione .................................................................................................. 23 3.1.4 Aree di memoria ................................................................................................................ 23 3.1.5 Specificatori, qualificatori di tipo e tipi di dato aggiuntivi ............................................... 24 3.1.6 Funzioni built-in per la creazione di un contesto .............................................................. 25 3.1.7 Funzioni built-in per la gestione della memoria ............................................................... 26 3.1.8 Funzioni built-in per i codici di errore .............................................................................. 27 3.1.9 Funzioni built-in per la gestione dei sistemi Multi-Device ............................................... 27 3.1.10 Esempio di un programma completo (1) ......................................................................... 28 3.1.11 Esempio di un programma completo (2) ......................................................................... 30
3.2 OpenCL .................................................................................................................................... 32 3.2.1 Tipi di dato e qualificatori ................................................................................................. 35 3.2.2 Funzioni built-in per la creazione di un contesto .............................................................. 36 3.2.3 Funzioni built-in per la creazione di code di comandi ...................................................... 37 3.2.4 Funzioni built-in per la gestione della memoria ............................................................... 37 3.2.5 Funzioni built-in per i program object e i kernel object ................................................... 38 3.2.6 Funzioni built-in per l’esecuzione del kernel .................................................................... 38 3.2.7 Funzioni built-in per la deallocazione di oggetti .............................................................. 38 3.2.8 Esempio di un programma completo ................................................................................ 39
3.3 CUDA e OpenCL a confronto ................................................................................................. 43 Conclusioni ........................................................................................................................................ 45 Bibliografia ........................................................................................................................................ 46
4
Introduzione
Nella società attuale ogni azienda ha la necessità di incrementare il proprio profitto e di ridurre al
minimo i costi del lavoro attraverso l’adozione di strategie innovative. Diminuire i costi significa
(quasi sempre) aumentare la velocità e la produttività del proprio lavoro, così da far crescere in
maniera netta il ricavo aziendale. Tuttavia, mentre una piccola o media azienda è in grado di
migliorare anche attraverso l’acquisto di pochi computer, i grandi enti hanno bisogno dell’utilizzo
di sofisticate attrezzature di calcolo; dunque sta diventando di vitale importanza, sia in ambito
industriale che scientifico, far riferimento a soluzioni architetturali in grado di garantire prestazioni
molto elevate in termini di calcolo. Per tale motivo si fa sempre più riferimento a tecnologie HPC
(High Performance Computing) basate tipicamente sul calcolo parallelo.
Questo elaborato, nel primo capitolo, si propone di illustrare l’importanza raggiunta dai processori
grafici nel calcolo general purpose facendo riferimento a reali casi di studio; nel secondo capitolo,
invece, verrà descritta in maniera accurata l’architettura di una generica GPU (Graphics Processing
Unit) ed inoltre verrà mostrato in che modo quest’ultima interagisce con la CPU
(Central Processing Unit) evidenziandone le principali differenze tra loro.
Successivamente, nel terzo capitolo, saranno introdotti due modelli di
programmazione molto diffusi nel settore GPGPU (General Purpose
computing on GPU): questi sono CUDA e OpenCL; il primo è proposto da
NVIDIA Corporation, mentre il secondo è indipendente da case SW/HW.
5
I suddetti linguaggi di programmazione saranno presentati tramite la definizione delle principali
primitive messe a disposizione dalle rispettive API e con alcuni esempi di programmi completi.
Dopo un’analisi delle principali differenze fra CUDA e OpenCL, infine, saranno riportate alcune
idee conclusive utili a riassumere l’importanza attuale del computing accelerato dalle GPUs.
6
Capitolo 1: GPGPU
GPGPU, acronimo di General-Purpose computing on graphics processing units, è un ramo
della ricerca informatica che ha come obbiettivo l’utilizzo delle GPUs per scopi ben diversi dalla
creazione di una semplice immagine tridimensionale; in tale ambito, infatti, le unità di elaborazione
grafica sono impiegate nell’esecuzione di applicazioni di diverso tipo che richiedono un livello di
prestazioni molto alto. Tale strategia è giustificata dal fatto che le tradizionali architetture CPU non
possiedono una capacità di elaborazione tale da raggiungere quei livelli prestazionali, e dunque si è
pensato di affiancarle alle GPUs.
1.1 GPU computing: oltre la grafica.
Le GPUs erano originariamente destinate all’esecuzione di applicazioni di grafica 3D e al
rendering di immagini durante il processo di rasterizzazione. Tuttavia, le risorse computazionali
delle moderne unità grafiche le rendono in grado di poter eseguire porzioni di codice parallelo
garantendo prestazioni altissime.
Per sfruttare al meglio tale potenzialità è stato ideato il computing accelerato dalle GPUs, ovvero
una strategia che consiste nell’affiancare ad una CPU una GPU per accelerare l’esecuzione di
applicazioni di vario tipo (ad esempio scientifiche, tecniche e di analisi).
Tuttavia il raggio di applicazione di tale strategia è molto ampio e sta crescendo ogni giorno di più;
infatti le GPUs, attualmente, sono in grado di ridurre i tempi di esecuzione di applicazioni presenti
su diverse piattaforme: dalle automobili ai telefoni cellulari e ai tablet, fino ai droni e ai robot.
Insomma, il GPU computing si sta diffondendo in maniera quasi silenziosa.
7
Fig. 1 Sistema di frenata automatico in ADAS
A tal proposito, e’ giusto far notare ad esempio che le persone oggi trascorrano una lasso di tempo
molto significativo nelle proprie automobili, che sia per recarsi sul posto di lavoro, oppure per
spostarsi in viaggio verso mete lontane. Dunque è diventato di primaria importanza dotare il
guidatore dei migliori sistemi di sicurezza all’interno dell’autovettura. Le nuove tecnologie, basate
sull’utilizzo delle GPUs, hanno permesso alle case automobilistiche di installare all’interno dei
propri veicoli avanzati sistemi di sicurezza realizzati in OpenCL (modello di programmazione
spiegato nei capitoli successivi).
ADAS, sigla di Advanced Driver
Assistance Systems, ne è un esempio; tale
sistema è l’insieme delle ultime
tecnologie in grado di garantire una
maggiore sicurezza per passeggeri, pedoni ed altri veicoli. Tale soluzione, infatti, è in grado di
monitorare, prevedere, e tentare di evitare incidenti grazie a tecniche di Active Safety Monitoring,
Collision Avoidance Systems e al riconoscitore di oggetti e/o pedoni. Questa idea, all’inizio, però
si basava su una combinazione di CPUs e DSPs (Digital Signal Processor); ma tale scelta
comportava una scarsa portabilità del codice poiché esso doveva essere scritto a mano per ogni
singolo componente, e ciò comportava scarse possibilità di riutilizzo. Grazie alle GPUs, invece, è
stato possibile arginare queste limitazioni scrivendo algoritmi basati sulle librerie offerte da
OpenCL; in questo modo il codice può essere riutilizzato su tutte le piattaforme in grado di
eseguire OpenCL, senza il bisogno di riprogettazione. Risolti i primi problemi di portabilità, la
nuova sfida degli ingegneri al lavoro di ADAS è quella di diminuire al minimo i tempi di risposta
del sistema e quindi di esecuzione degli algoritmi. Anche questo sarà possibile grazie alle APIs
messe a disposizione da OpenCL che aiutano il processo di eleborazione parallela delle
informazioni ricevute da telecamere, GPS/WiFi, accelerometro ed altri sensori. Dunque, questo
esempio di applicazione del GPU Computing testimonia il fatto che siamo ormai all’inizio di una
sorta di rivoluzione silenziosa che il consumatore finale avvertirà soltanto come un notevole
aumento di efficienza e prestazioni, ignorando completamente i dettagli alla base di tale processo di
trasformazione.
8
Fig. 2 Schema di esecuzione del computing accelerato dalle GPUs
1.2 Analisi delle prestazioni
Il computing accelerato dalle GPUs è in grado di offrire prestazioni senza precedenti demandando
porzioni di codice più impegnative alle GPUs, e destinandone la parte sequenziale alle CPUs. Dal
punto di vista dell'utente, l'unica differenza è che le applicazioni risultano essere nettamente più
rapide. Tale tipo di elaborazioni sono, per loro natura, di tipo parallelo, e quindi in grado di
beneficiare in maniera ottimale dell'architettura tipica delle GPUs; a tale caratteristica intrinseca, a
partire dal 2007, si è aggiunta anche l'estrema programmabilità supportata dagli ultimi prodotti in
commercio, che con le nuove generazioni aumentano non solo la propria potenza elaborativa ma
anche la propria versatilità.
Il modo più semplice per comprendere il motivo della nascita di questa idea è quello di analizzare
la principale differenza tra l’architettura di una generica CPU e di una generica GPU: mentre la
prima è costituita da un numero ridotto di unità di elaborazione, ciascuna delle quali dotata di alte
prestazioni, la seconda possiede migliaia di core con minore capacità eleborativa, ma ottimizzati
per il troughput complessivo. Destinando, quindi, all’unità di elaborazione grafica importanti
carichi di lavoro parallelo è possibile migliorare sensibilmente le prestazioni totali del sistema in
esecuzione.
9
Fig. 3 Confronto tra le prestazioni dell’acceleratore Xeon Phi ed una generica GPU
Inoltre l’adozione delle GPUs acceleratrici consentono di avere non solo miglioramenti dal punto
di vista prestazionale, ma anche in termini di efficienza energetica. Per tale motivo esse stanno
diventando la nuova norma nelle installazioni HPC e nel Supercomputing.
Consideriamo come esempio la differenza tra le prestazioni di una GPU e dell’acceleratore Intel
Xeon Phi nell’esecuzione di applicazioni HPC.
Sebbene Xeon Phi possa essere ottimizzato in modo tale da superare le prestazioni di una CPU, le
GPU lo surclassano in maniera netta su una vasta gamma di applicazioni di supercomputing. Ciò è
stato provato dal Department of Energy degli Stati Uniti che utilizza una serie di “mini-app” per
valutare le performance delle architetture di computing sulla base di carichi di lavoro HPC
altamente rappresentativi. Infatti come si evince dal grafico qui sopra, le GPUs dimostrano una
velocità da 2,5 a 5 volte superiore rispetto alle CPUs.
Nonostante Intel Xeon Phi possa essere ottimizzato, le prestazioni di una generica GPU rimangono,
nella media, almeno 2 volte superiori.
1.3 Case Study: calcolo di gestione del rischio finanziario
I calcoli di gestione del rischio rappresentano uno dei fattori che determinano elevati costi di lavori
per il settore dei servizi finanziari. Come principale banca di investimento mondiale, la J.P.
Morgan necessitava di migliorare la qualità del proprio lavoro per calcolare il rischio con
tempistiche ragionevoli senza aggiungere altri costi infrastrutturali al proprio bilancio.
Tale banca era intenzionata, quindi, a rinnovare le tecnologie dei propri data center per renderli
conformi alle nuove politiche di risparmio aziendale.
10
Fig. 4 Sede della J.P. Morgan a New York
Per tale motivo, la J.P. Morgan Equity Derivatives Group ha iniziato una fase di test delle GPUs
NVIDIA® Tesla™ nel 2009, scegliendo il modello Tesla M2070 come possibile alternativa per i
calcoli più complessi. La società ha deciso di installarle per la prima volta nella sua infrastruttura
globale di grid computing nel 2010, e tale scelta ha portato
immediatamente dei miglioramenti davvero notevoli. Le
GPUs, infatti, hanno permesso il calcolo del rischio per i
prodotti di investimento in pochi minuti rispetto alle solite
lunghe elaborazioni di calcolo notturne.
L’uso delle GPUs come co-processori ha accelerato le
prestazioni delle applicazioni sino a 40 volte rispetto all’uso delle soluzioni basate sulle sole CPUs
ed ha consentito risparmi di circa l’80% nel complesso. Forte delle conferme dell’attuale soluzione
adottata, nel prossimo futuro la società ha programmato di incrementare ulteriormente il numero di
calcoli affidati alle GPUs, oltre ad esaminare all’esecuzione di nuovi modelli precedentemente
considerati improponibili a causa della elevata capacità di computing necessaria.
11
Capitolo 2: Graphics Processing Unit
Le unità di elaborazione grafica (GPU) furono utilizzate all’inizio in supporto alle CPU per
l’elaborazione di dati, informazioni e istruzioni relative al campo grafico del computer. La
situazione, tuttavia, è cambiata all'inizio del 2001, quando è stata lanciata la prima GPU
programmabile: questo fu il primo passo verso inizio di una nuova era.
2.1 La storia
L’origine della GPU risale alla fine del 1970. I primi chip grafici avevano una funzione molto
semplice: prelevare i dati utili dalla memoria del sistema ed elaborarli tramite il controller video per
poi inviarli direttamente al dispositivo di output video.
Non molto tempo dopo, però, i controller video furono dotati di memoria interna per mostrare
direttamente i dati in output senza prelevarli. Tali controller, inoltre, erano in grado anche di
compiere alcune semplici operazioni di rasterizzazione come ad esempio il “bit Block Transfer”. Il
bitBLT prevedeva una combinazione di un’immagine bitmap con un’altra identica per evitare la
ripetizione di operazioni di rendering su attributi “fissi” in un immagine, come ad esempio in uno
sfondo. Poi, negli anni successivi, i controller furono potenziati dal punto di vista hardware per
supportare ulteriori funzionalità e iniziò a diffondersi sempre più l’idea di utilizzare schede video
per velocizzare le operazioni grafiche.
Tale soluzione prese il nome di “2D Hardware Acceleration”. Negli anni ’90, con l’accrescere
della domanda del “3D Hardware Acceleration” a causa della produzione dei primi videogames,
la situazione cominciò a diventare sempre più complessa. Mentre all’inizio l’elaborazione grafica
12
3D poteva avvenire soltanto attraverso l’aiuto della CPU, successivamente fu possibile farlo in
maniera separata tramite l’uso di schede aggiuntive. Ma ciò comportava un problema del punto di
vista dell’elaborazione 2D: vi era, comunque, bisogno di un altro controller video per realizzare sia
elaborazioni grafiche 3D che 2D.
Fu così che nel 1996 fu rilasciato il primo chipset che integrava entrambe le tecnlogie su un’unica
scheda. Come è facile ipotizzare, l’uso sempre sempre più diffuso delle GPUs portò alla
realizzazione di nuove funzionalità. Una di queste fu la tecnica del “Transform & Lighting”.
Transform si riferisce ad un’operazione che converte gli oggetti 3D per una vista 2D; Lighting,
invece, inserisce le luci e le ombre in un ambiente 3D per aumentare il tasso di realismo
dell’elaborazione grafica. Successivamente, nel 2001 furono aggiunti gli Shader alle GPU: una
serie di istruzioni per determinare l’aspetto fisico finale di un oggetto. Questo fu il primo passo che
permise, in seguito, l’utilizzo dell’unità di elaborazione grafica per scopi diversi dal solito.
Molte aziende, in quegli anni, hanno contribuito allo sviluppo delle GPUs; tra i primi si ricorda
Evans & Sutherland (E & S), una società leader nella produzione hardware per simulatori di volo.
Un dipendente di questa società, Jim Clark, ha fondato la Silicon Graphics, Inc. (SGI) nel 1982;
questa fu la prima azienda a svillupare, oltre che un dispositivo hardware grafico dedicato in grado
di raggiungere alte prestazioni (Clark Geometry Engine), un API proprietaria denominata IRIS
Graphics Language (IRIS GL). Nel 1992, dopo una fase importante di crescita, l’IRIS GL fu
acquistata a poco prezzo dai potenti competitors della SGI per poi essere rinominato in OpenGL.
Fino ad oggi, OpenGL, risulta essere l’unico standard real-time di grafica 3D eseguibile su diversi
sistemi operativi. Il suo principale rivale prodotto dalla Microsoft, Direct3D, è eseguibile soltanto
su macchine Windows.
Nel 1985, invece, ATI cominciò a produrre chipset per schede grafiche integrate per i grandi
prodottori di PC come IBM. Succcessivamente nel 1987 cominciò a produrre in maniera
indipendente schede e chipset grafici per le console. Attualmente, ATI è la seconda casa produttrice
di GPUs. Lo scettro spetta ad NVIDIA, fondata nel 1993.
13
Fig. 5 CPU vs GPU in termini di core
2.2 Architettura
Le GPUs, come già detto in precedenza, sono dei dispositvo inizialmente progettati per eseguire
applicazioni di sintesi grafica ad altissime prestazioni. Tali programmi, in genere, sono
caratterizzati da un numero molto alto di threads (anche più di 10'000) tutti istanze di uno stresso
thread. Tale numero di threads viene eseguito in parallelo, per cui è facilmente intuibile il fatto che
l’architettura di una generica GPU debba essere
predisposta a questa tipologia di carico di lavoro.
Per tale motivo è evidente la differenza tra CPU e
GPU. Nel primo caso si parla di architettura
multiple-core, indice della presenza ridotta di core
(oggi tipicamente in un numero compreso tra 4-8,
superando i 10 per quelli di fascia alta) sullo stesso
chip; tuttavia, ciascun core è dotato di un alta capacità elaborativa. Nel secondo caso, invece,
parliamo di architettura hundreds-of-core, denominazione che appunto indica l’elevata presenza in
numero di core sullo stesso chip (centinaia o anche migliaia sulle schede attualmente in
commercio). In questo caso, nonostante tutti i core posseggano una capacità elaborativa molto
semplice, essi riescono a garantire prestazioni molto elevate poichè l’architettura di una GPU è
ottimizzata per l’esecuzione in parallelo di un ingente numero di threads, a differenza di una CPU
che è ottimizzata per l’esecuzione di un singolo thread. Tale soluzione oltre che in prestazioni
(flop/s), apporta vantaggi anche in termini di consumo (flop/watt) e costo (flop/dollar). Quindi è
evidente il fatto che, ad una prima analisi, la GPU sia a tutti gli effetti un’architettura di tipo
parallelo. Precisamente, essa rientra nella categoria delle architetture SIMD (Single Instruction
Multiple Data), ovvero quelle architetture nelle quali una stessa istruzione viene eseguita su dati
diversi ricorrendo a più unità di elaborazione. Tuttavia, occorre precisare che operando su thread le
GPUs appartengono ad una variante del gruppo SIMD, ovvero va intesa come un’architettura di
tipo SIMT (Single Instruction Multiple Thread).
Analizzandone nello specifico l’architettura fisica, vediamo che una generica GPU è organizzata in
una serie di unità fondamentali denominate Thread Processing Cluster (TPC). Questi, a loro
14
Fig. 6 Esempio di un Thread Processing Cluster
Fig. 7 Esempio di uno Streaming Multiprocessor
Fig. 8 Visione globale dell’architettura di una GPU
volta, contengono una serie di unità chiamate Streaming Multiprocessor (SM) contenenti al loro
interno le unità di elaborazione fondamentali denominate Streaming Processor (SP).
Il Thread Processing Cluster è formato, come è visibile nell’immagine in alto, da 3 SM, una
memoria per la gestione delle textures ed una memoria cache disponibile per l’intero TPC. Lo
Streaming Multiprocessor, invece, è simile ad un processore vettoriale di larghezza 8 che opera
su vettori di larghezza 32; infatti esso è costituito da 8 processori scalari (SP) che eseguono
istruzioni aritmetico/logiche in virgola mobile e in virgola fissa. Possiede, inoltre, 2 Unità
Funzionali Speciali (SFU) che eseguono varie funzioni trascendenti e matematiche (seno, coseno,
etc.) oltre alla moltiplicazione in virgola mobile (in singola precisione). Infine possiede anche
un’unità di Doppia Precisione (DPU) che esegue operazioni aritmetiche in virgola mobile a 64 bit.
L’architettura di una GPU è quindi
costituita, in genere, dall’insieme dei
Thread Processing Clusters, dalla rete di
interconnessione e dai controllori della
memoria esterna.
L’esecuzione in parallelo di un numero così ingente di threads viene processata in maniera molto
semplice. Lo Streaming Multiprocessor ha il compito di gestire una serie di threads raggruppandoli
in unità chiamate warp, un insieme di 32 threads esguiti a gruppi di 8 sugli otto processori scalari
15
Fig. 9 Due chip di memoria GDDR3 su una GeForce 7600 GT
(SP). Tutti i thread di un warp eseguono la stessa istruzione in maniera indipendente, ma
“lockstepped” (sincronizzazione forzata). Ciò vuol dire che l’esecuzione tra i vari threads deve
essere in qualche modo sincronizzata; ad esempio se un thread si trovasse ad eseguire, in seguito ad
un salto condizionato, un’istruzione diversa da quella in esecuzione su un altro thread, questo dovrà
attendere che ogni elemento appartenente al warp abbia raggiunto quel punto di esecuzione.
Tale gestione sincronizzata è affidata ad un compentente interno al warp, denominato warp
scheduler: esso, in seguito ad una divergenza a causa di un salto condizionato, esegue in maniera
seriale ogni percorso intrapreso dal punto di diramazione, disabilitando di volta in volta i threads
che non si trovano in linea con il percorso. Quando l’esecuzione raggiunge lo stesso punto di
esecuzione in tutti i threads punto, essi rinconvergono tutti di nuovo nello stesso percorso di
esecuzione. E’ opportuno specificare che una divergenza viene gestita soltanto all’interno di un
solo warp, mentre warp differenti vengono eseguiti in maniera del tutto indipendente.
Un’unità di elaborazione grafica, infine, presenta in genere nella sua architettura una memoria
integrata sulla scheda denominata Graphics Double Data Rate (GDDR), basata sulla tecnlogia
Double Data Rate attualmente giunta alla quinta generazione (GDDR5).
Essa viene utilizzata per memorizzare in maniera
temporanea informarzioni utili al rendering grafico,
come ad esempio le texture, per evitare di doverle
memorizzare nella memoria centrale del sistema, cosa
che richiederebbe molto più tempo. Si parla di bande di
memoria intorno ai 300 GB/s, mentre con l’interazione
tra la CPU e la memoria centrale si arriva al massimo
sui 60 GB/s. Infine è giusto ricordare che, nel caso in cui
siano dotati di aree di memoria distinte, la connessione
tra GPU e CPU avviene di solito attraverso un bus PCI-Express ad alta velocità
16
Fig. 10 Logo GPUs NVIDIA TESLA
Fig. 11 Architettura globale di una GPU Tesla
Fig. 12 TPC di una GPU Tesla
2.2.1 Esempio : architettura GPU NVIDIA Tesla
L’architettura Tesla si basa su array di processori scalari.
La figura in basso mostra un diagramma a blocchi di una
GPU TESLA con 128 streaming-processor (SP) organizzati in 16 streaming
multiprocessors (SM) in 8 indipendenti unità di processo
Thread Processing Cluster (TPC).
Ogni Thread Processing Cluster possiede un controller geometrico, un
controller per gli Streaming Multiprocessors, 2 Streaming Multiprocessors
e un’unità per le Texture. Per bilanciare il rapporto operazioni
matematiche e operazioni grafiche, una unità per le texture serve 2 SM.
Gli SM sono formati da 8 Streaming Processors (SP), 2 unità di funzioni
speciali (SFU), una cache per le istruzioni, una cache costante read-only e
16 Kbyte read/write di memoria cache.
Con questa architettura, che utilizza una frequenza di clock a 1.5 GHz, è
possibile ottenere un picco massimo di prestazioni sui 36 GFlops per SM.
La GPU è connessa alla CPU tramite un bus PCI-Express 2.0 x16 ad alta
velocità (fino a 8GB/s).
17
Fig. 11 Architettura globale di una GPU Tesla
Fig. 13 Andamento prestazionale negli anni di GPUs NVIDIA, GPUs AMD e CPUs Intel
2.3 GPU vs CPU: le evoluzioni negli anni
Dopo aver analizzato le principali differenze a livello architetturale, ora è giusto confrontare le due
unità dal punto di vista delle evoluzioni e nelle relative performance. Negli ultimi anni sono stati
migliorati entrambi grazie alle nuove tecnologie, ma nonostante ciò un dato resta costante.
Con l’introduzione da parte di NVIDIA della tecnlogia Tesla c’è stata una vera e propria svolta:
nacquero, infatti, le prime schede prive di connettore video in grado di essere utilizzate anche per il
calcolo generico. Il grafico in alto, inoltre, evidenzia la fase di costante evoluzione che stanno
attraversando le GPU dal 2007 ad oggi. Nonostante le nuove soluzioni, il divario però tra le
prestazioni di una GPU rispetto ad una CPU è in certi casi imbarazzante; tale differenza espressa in
GigaFlop/s, infatti, sembra in crescita costante.
18
Fig. 14 Dispositivi mobili con Systems-on-Chip
2.4 Heterogeneous System Architecture
Nonostante le altissime prestazioni offerte dalle GPUs, non risulta possibile considerarle come
unità di elaborazione autonoma poichè vanno sempre accompagnate dall’uso congiunto delle
CPUs. La strategia di lavoro, quindi, è molto semplice: la CPU si occupa di svolgere funzioni di
controllo e di calcolo in maniera seriale, demandando all’acceleratore grafico compiti
computazionalmente molto onerosi e con un alto grado di parallelismo.
La comunicazione CPU – GPU può avvenire non solo attraverso l’uso di un bus ad alta velocità,
ma anche attraverso la condivisione di un’unica area di memoria per entrambi (fisica o virtuale).
Infatti, nel caso in cui entrambi i dispositivi non siano dotati di aree di memoria proprie, è possibile
far riferimento ad un’area di memoria comune utilizzando le API messe a disposizione dai vari
modelli di programmazione, come ad esempio CUDA e OpenCL. In questo modo si eviterà la
latenza dovuta al trasferimento dei dati tra i dispositivi, in quanto basterà un semplice scambio di
puntatori e non le copie intere degli oggetti.
Questo è il caso delle architetture eterogenee (Heterogeneous
System Architecture), nelle quali le applicazioni possono creare
le strutture dati in unico spazio di indirizzi e inviare il lavoro al
dispositivo hardware più appropriato per la risoluzione del
compito. Diversi task di elaborazione possono operare
tranquillamente sulle stesse regioni di dati evitando problemi di
coerenza, grazie alle operazioni atomiche.
Dunque, nonostante CPU e GPU sembrino non lavorare in
modo efficiente tra loro, tramite questo nuovo design è possibile ottimizzare la loro interazione.
Tale soluzione, infatti, è particolarmente presente sui dispositivi mobili (smartphone, tablet) che
montano dei veri e propri Systems-on-Chip che incorporano tutti gli elementi in unico chip.
19
Capitolo 3: Modelli di programmazione GPU
Le alte prestazioni ottenute tramite l’uso delle GPUs nell’ultimo decennio ha catturato l’attenzione
del settore scientifico e degli sviluppatori circa la possibilità di utilizzare i processori grafici come
unità per effettuare calcoli general purpose. Ciò ha sicuramente aiutato la nascita ed la rapida
crescita di diversi linguaggi di programmazioni per l’ambiente grafico.
Il GPU computing ha avuto inizio nel 2006 quando vennero presentati CUDA e Stream, due
interfacce di programmazione grafica progettate dai due principali produttori di GPUs, ovvero
NVIDIA e AMD. Qualche anno dopo fu avviato il progetto OpenCL che aveva come obbiettivo la
realizzazione di un framework d’esecuzione eterogeneo sul quale potessero lavorare sia GPU (di
diverse case), sia CPU. Con l’avanzare del tempo questi linguaggi sono stati migliorati, diventando
sempre più simili ai comuni linguaggi di programmazione general purpose con l’aggiunta di metodi
di controllo sempre più semplici ed efficaci.
Tuttavia, attualmente OpenCL e CUDA rappresentano le soluzioni più efficienti per sfruttare al
meglio le prestazioni offerte dalle unità di eleborazione grafica; per tale motivo verranno presentate
e confrontate qui di seguito.
20
3.1 CUDA
CUDA, acronimo di Computer Unifed Device Architecture, è un framework ideato da NVIDIA nel
2006; esso risulta applicabile su molte GPUs prodotte dalla stessa società americana, come ad
esempio le GeForce (dalla serie 8 in poi), Quadro e Tesla.
Il framework comprende un’architettura di calcolo parallela general purpose con la relativa
Application Programming Interface (API) supportata da un apposito compilatore (nvcc) per il
codice CPU/GPU. Nel toolkit fornito da CUDA, inoltre, sono presenti anche un ambiente di
sviluppo integrato (Nsight) e varie estensioni dei principali linguaggi per semplificare la
programmazione, come ad esempio C, C++, Fortran, Java e Phyton; in questo elaborato saranno
presentati esempi in CUDA C.
Risultano avere grande utilità, tuttavia, anche una serie di librerie particolari come ad esempio
funzioni per le operazioni di algebra lineare (cuBLAS), funzioni per le operazioni su matrici sparse
(cuSPARSE) e librerire per il calcolo della trasformata di Fourier (cuFFT).
3.1.1 Scalabilità automatica e gerarchia dei threads
Nonostante CUDA sia una soluzione proprietaria di NVIDIA, non è detto che tutte le GPUs che
supportano tale framework abbiano la stessa architettura. E’ possibile, infatti, che esse differiscano
ad esempio per il numero di core presenti sul chip; per evitare eventuali problemi di portabilità
delle applicazioni, NVIDIA propone con CUDA un modello altamente scalabile in grado di
adattarsi senza difficoltà a tutti i chip grafici che supportano tale framework. L’idea di base è quella
di suddividere il carico di lavoro in parti più piccole, ovvero partizionare un programma
multithread in una serie di blocchi paralleli ed indipendenti tra loro formanti una griglia e
contenenti i vari threads. In questo modo ogni programma compilato in CUDA si adatterà run-
time all’architettura su cui è in esecuzione, in modo del tutto trasparente rispetto al numero di core
presenti. Il programmatore, infatti, setta una configurazione di esecuzione su un certo numero di
blocchi, ma solo a tempo di esecuzione la suddivisione logica in blocchi corrisponderà
all’assegnazione fisica agli Streaming Processors presenti sulla GPU. E’ facile intuire che se un
chip grafico possiede più SM rispetto ad un altro, esso elaborerà i processi in maniera più rapida.
21
Fig. 15 Sistema altamente scalabile proposto da CUDA
I blocchi in cui viene suddiviso il programma, tuttavia, vanno a comporre una griglia che può
essere sia monodimensionale e
sia bidimensionale, contenente al
massimo in ogni dimensione
65535 blocchi.
Ogni blocco, inoltre, può avere
una struttura monodimensionale,
bidimensionale o tridimensionale
con dimensione massima di 1024
threads in totale sulle tre
dimensioni quindi con al
massimo una configurazione del
tipo (32,16,2). Nella fase di
esecuzione, i blocchi sono indipendenti tra loro, mentre i thread al loro interno possono interagire
tra loro con i dati condivisi sulla shared memory ma sempre in maniera sincronizzata; inoltre ogni
thread ed ogni blocco possiedono un ID all’interno della struttura utile alla loro identificazione
durante l’esecuzione.
3.1.2 Paradigma di programmazione
Un programma scritto in CUDA possiede parti di codice che vengono eseguite in maniera
sequenziale dalla CPU, che d’ora in avanti sarà identificata come HOST, e altre parti di codice
parallelo che vengono gestite dalla GPU, che d’ora in poi identificheremo come DEVICE.
Solitamente, il listato di codice dell’host segue tre passaggi fondamentali:
passo#1: allocazione aree di memoria del device
passo#2: trasferimento per e dalla memoria del device
passo#3: stampa dei risultati.
La GPU viene vista, in pratica, come un coprocessore per la CPU che esegue un numero elevato di
threads in parallelo e possiede, quindi, una propria memoria (device memory).
I threads vengono lanciati sul Device da particolari funzioni, chiamate kernel, che quando invocate
22
Fig. 16 Esempio di griglia e blocchi di esecuzione dei Threads in CUDA
vengono eseguite N volte in parallelo da N differenti CUDA threads, seguendo la gerarchia
illustrata prima.
Un kernel viene definito tramite il qualificatore __global__ ed il numero di CUDA threads che lo
esegue viene definito tramite la seguente sintassi di esecuzione:
KernelFunc <<< numBlocchi, numThreadsPerBlocco >>>
Ogni thread che esegue il kernel possiede un ID che è accessibile all’interno della funzione lanciata
sulla GPU dalla variabile di sistema threadIdx. Questa è un vettore a tre dimensioni di un nuovo
tipo, introdotto da CUDA C, dim3: ciò serve ad identificare elementi come vettori, matrici o
volumi. Si può accedere alle varie dimensioni aggiungendo a threadIdx il suffisso .x, .y o .z.
Ogni blocco all’interno della griglia può essere identificato dalla variabile di sistema blockIdx
(anch’essa di tipo dim3) e si può accedere alla sua dimensione tramite la variabile blockDim.
Un programma scritto in CUDA C può contenere più di un Kernel; questi vengono eseguiti in
maniera sequenziale, ma la CPU dove aver lanciato il Kernel sulla GPU ed è in attesa del risultato
23
può compiere altre operazioni. Quindi l’host è libero di fare ciò che vuole mentre la GPU lavora,
creando così una sorta di parallelismo. La chiamata al kernel è quindi asincrona; è possibile,
inoltre, mettere in attesa il processore durante le operazioni della GPU tramite l’uso della primitiva
cudaThreadSynchronize() inserita dopo la chiamata della funzione kernel che la renderà così
bloccante.
3.1.3 Cenni sulla compilazione
Le funzioni kernel, oltre ad essere realizzate in CUDA C, possono anche essere scritte in set di
istruzioni dell'architettura, chiamate PTX (Parallel Thread Execution). In ogni caso, esse devono
essere compilate in codice binario dal compilatore nvcc, che riconosce i file sorgenti che includono
sia codice dell’ Host che il codice del Device.
Il compilatore prima separa il codice host da quello del device, e poi :
- Compila il codice del device in assembly;
- Modifica il codice dell'host sostituendo la sintassi <<<...>>> da chiamate di funzione CUDA C
che riconoscano, lancino ed eseguino il kernel compilato in codice PTX.
Tutto il codice compilato viene salvato in un file con estensione .cu da cui si andrà ad ottenere il
nostro eseguibile. 3.1.4 Aree di memoria
Le devices CUDA agiscono su diverse aree di memoria:
- Register Memory: le informazioni salvate qui sono visibili solo al thread che le ha
scritte ed hanno una durata pari a quella del thread;
- Local Memory: ha le stesse caratteristiche della memoria dei registri, solo un po’ più
lenta;
- Shared Memory: le informazioni presenti sono visibili a tutti i thread all’interno di un
blocco e hanno durata pari a quella del blocco. E’ un’area di memoria fondamentale che
permette la comunicazione tra i threads e la condivisione dei dati tra loro;
- Global Memory: tutte le informazioni salvate all’interno di quest’area sono visibili a
tutti i threads all’interno dell’applicazione e la loro durata coincide con le decisioni
24
prese dall’host.
- Constant Memory: è utilizzata per salvare le informazioni che resterrano costanti
durante tutta l’esecuzione del thread e sono di sola-lettura.
- Texture Memory: è un altro tipo di memoria read-only e risiede sul device.
3.1.5 Specificatori, qualificatori di tipo e tipi di dato aggiuntivi
Per distinguere porzioni di codice riferite all’host (CPU) o al device (GPU) vengono introdotti i
seguenti qualificatori:
__device__float DeviceFunc()
Indica una funzione che sarà eseguita dal device e sarà richiamabile dal device stesso;
__host__float HostFunc()
Indica una funzione che sarà eseguibile e richiamabile dall’host;
__global__void KernelFunc()
permette la definizione dei kernel, ovvero delle funzioni che possono essere richiamate dall’host
per essere eseguite sul device. E’ necessario che abbia un tipo di ritorno void e per la sua
esecuzione è prevista una sintassi aggiuntiva rispetto alla semplice chiamata a funzioni in C, in
quanto permette la configurazione della sua esecuzione indicando il numero di blocchi e il numero
dei threads per blocco:
KernelFunc<<<numBlocchi,numThreadsPerBlocco>>>
Per quanto riguarda i qualificatori di tipo, invece, sono presenti alcuni utili all’allocazione dei dati
in specifiche aree di memoria:
__device__ (fa riferimento alla Global Memory del device)
__shared__ (fa riferimento alla Shared Memory del device)
__constant__ (fa riferimento alla Constant Memory del device)
25
In CUDA C, inoltre, sono presenti alcuni tipi di dato comuni ottimizzati per la loro gestione; questi,
infatti, possono essere usati in maniera del tutto indifferente sia nel codice GPU che CPU con
forma sia di tipo scalare che vettoriale*:
- [u]char; *
- [u]short; *
- [u]int; *
- [u]long; *
- float; *
- double;
Infine, oltre al tipo dim3 che abbiamo già analizzato in precedenza, sono definite anche altre
strutture dati aggiuntive che non hanno bisogno di essere istanziate in maniera (vengono create
automaticamente dalla GPU al momento della chiamata del kernel ):
- blockIdx : una struttura dati a 2 campi che fa riferimento alle dimensioni .x .y ,
contenente l’indice di un blocco all’interno della griglia rispetto all’asse specificato;
- threadIdx : una struttura dati a 3 campi che fa riferimento alle dimensioni .x .y .z
contenente l’indice di in thread all’interno di un blocco nella direzione indicata;
- gridDim : una struttura dati a 2 campi indicanti le dimensioni della griglia nelle 2
direzioni;
- blockDim : una struttura dati a 3 campi indicanti le dimensioni di un blocco nelle 3
direzioni.
Per ottenere l’indice di un particolare thread all’interno della griglia, ricavando un riferimento
assoluto a partire dalla posizione del thread all’interno del blocco e dalla dimensione del blocco
all’interno della griglia si può usare questa formula:
ThID = threadIdx.x + threadIdx.y*blockDim.x + threadIdx.z*blockDim.y*blockDim.x
3.1.6 Funzioni built-in per la creazione di un contesto
CUDA C non possiede funzioni di inizializzazione poiché avviene tutto automaticamente al
momento della chiamata di una funzione del device. Quando ciò accade, runtime si crea il primary
26
context CUDA per ogni GPU presente nell’architettura e viene condiviso tra tutti i threads
dell’applicazione. Per rimuovere il contesto primario corrente è possibile utilizzare la primitiva
cudaDeviceReset() ed attendere, poi, la prossima chiamata di una funzione del device che andrà a
creare un nuovo contesto primario per la GPU a cui si fa riferimento.
3.1.7 Funzioni built-in per la gestione della memoria
Il framework CUDA, come già evidenziato in precedenza, propone che il sistema sia composto da
un Host ed un Device, ciascuno con un’area di memoria dedicata.
Analogamente alle funzioni malloc() e free() presenti nel linguaggio C, in CUDA C troviamo sia la
funzione per l’allocazione di memoria cudaMalloc() nella memoria globale e sia la funzione
cudaFree() per rilasciare la memoria allocata prima.
- cudaMalloc() ha come parametri di ingresso l’indirizzo del puntatore all’oggetto allocato (void) e
la dimensione dell’oggetto allocato.
- cudaFree(), invece, riceve in ingresso il puntatore dell’oggetto da rimuovere.
La memoria lineare può essere allocata anche tramite altre funzioni come ad esempio
cudaMallocPitch() e cudaMalloc3D(). Queste funzioni sono specifiche per l’allocazione di
oggetti tipo matrici e volumi, poiché sono in grado di garantire le migliori performance di accesso
agli indirizzi di memoria di strutture in due o tre dimensioni.
Per quanto riguarda i trasferimenti dei dati tra CPU e GPU viene utilizzata la funzione
cudaMemcpy(). Tramite questa funzione possiamo inviare i dati da e per la memoria globale,
avendo come parametri di ingresso:
- Puntatore alla destinazione;
- Puntatore alla sorgente;
- Numero di bytes da trasferire;
- Tipo di trasferimento;
La variabile tipo di trasferiemento è una costante simbolica che può assumere 3 diversi valori:
- cudaMemcpyHostToDevice (trasferimento da Host al Device);
27
- cudaMemcpyDeviceToHost (trasferimento dal Device all’Host);
- cudaMemcpyDeviceToDevice (trasferimento da Device a Device);
3.1.8 Funzioni built-in per i codici di errore
Le funzioni in CUDA C, escludendo i lanci dei kernel, ritornano un codice di errore di tipo
cudaError_t. A tal proposito esiste una funzione che può essere utilizzata per riportare tale codice
alla CPU: cudaError_t cudaGetLastError(void)
In questo modo si ottiene il codice dell’ultimo errore (anche nel caso in cui non vi fosse “alcun
errore” viene inviato un codice di errore) e ed inoltre è possibile tener conto degli eventuali errori
avvenuti durante l’esecuzione di un kernel.
3.1.9 Funzioni built-in per la gestione dei sistemi Multi-Device
Un sistema può essere composto anche da più devices e per tale motivo CUDA C, attraverso l’uso
di alcune semplici primitive, ne permette la gestione:
- cudaGetDeviceCount(*int) memorizza in un intero il numero dei devices presenti nel
sistema, identificando ogni chip grafico con un altro intero;
- cudaGetDeviceProperties(*cudaDeviceProp, int) preleva le caratteristiche del chip
grafico selezionato tramite la variabile intera, salvandole nella variabile di ritorno di tipo
cudaDeviceProp.
- cudaSetDevice() permette la selezione del device sul quale si vuole andare ad elaborare.
28
3.1.10 Esempio di un programma completo (1)
Il seguente programma è scritto in CUDA C e calcola la seguente operazione log(h_a[i]*h_b[i])
su due vettori costituiti 100000 elementi, riportandone il risultato in un terzo vettore.
Prima della chiamata del kernel vengono definite le dimensioni della griglia e dei rispettivi blocchi,
utilizzando il numero di threads utili per completare in maniera ottimale l’esecuzione del
programma. #include <stdio.h> // implementazione del kernel __global__ void Kernel(float *d_a,float *d_b,float *d_c) { // calcolo dell'indice di thread int idx = blockIdx.x*blockDim.x + threadIdx.x; if(idx<100000) d_c[idx] =log(d_a[idx]*d_b[idx]); } // Dichiariamo il main int main( int argc, char** argv) { int n=100000; time_t begin,end; // puntatore per la struttura dati sull'host float *h_a=(float*) malloc(sizeof(float)*n); float *h_b=(float*) malloc(sizeof(float)*n); float *h_c=(float*) malloc(sizeof(float)*n); //inizializzo il vettore numeri casuali for(int i=0;i<n;i++) { h_a[i]=rand(); h_b[i]=rand(); } begin = clock(); for(int i=0;i<n;i++) h_c[i] =log(h_a[i]*h_b[i]); end=clock(); float time_cpu = (double)(end-begin)/CLOCKS_PER_SEC; printf("CPU time %.20lf\n",time_cpu); // puntatore per la struttura dati sul device float *d_a=NULL; float *d_b=NULL; float *d_c=NULL;
29
//verifico al secondo lancio del kernel for(int i=0;i<2;i++) { begin = clock(); //malloc e memcopy host to device cudaMalloc( (void**) &d_a, sizeof(float)*n) ; cudaMalloc( (void**) &d_a, sizeof(float)*n) ; cudaMalloc( (void**) &d_a, sizeof(float)*n) ; cudaMemcpy( d_a, h_a, sizeof(float)*n, cudaMemcpyHostToDevice) ; cudaMemcpy( d_b, h_b, sizeof(float)*n, cudaMemcpyHostToDevice) ; cudaMemcpy( d_c, h_c, sizeof(float)*n, cudaMemcpyHostToDevice) ; // definizione della grandezza della griglia e dei blocchi int numBlocks = 196; int numThreadsPerBlock = 512; // Lancio del kernel dim3 dimGrid(numBlocks); dim3 dimBlock(numThreadsPerBlock); Kernel<<< dimGrid, dimBlock >>>( d_a,d_b,d_c ); // blocca la CPU fino al completamento del kernel sul device cudaThreadSynchronize(); // Esegue la copia dei risultati //dalla memoria del device a quella dell'host cudaMemcpy( h_c, d_c, n, cudaMemcpyDeviceToHost ); end = clock(); } float time_gpu = (double)(end-begin)/CLOCKS_PER_SEC; printf("GPU time %.20lf\n",time_gpu); // libera la memoria sul device cudaFree(d_a); cudaFree(d_b); cudaFree(d_c); // libera la memoria sull'host free(h_a); free(h_b); free(h_c); return 0; }
Il seguente listato di codice ci consente di illustrare in maniera semplice quasi tutte le principali
primitive proposte dal linguaggio CUDA C, facendo anche un confronto diretto tra i tempi di
esecuzione della stessa operazione eseguita prima dalla CPU e poi dalla GPU.
30
3.1.11 Esempio di un programma completo (2)
Il seguente programma scritto in CUDA C calcola il prodotto di due matrici quadrate A e B di
dimensione n=3, e ne riporta il risultato in una terza matrice C.
#include <stdio.h> // implementazione del kernel __global__ void sommaarray(float *A_d, float *B_d, float *C_d, int N){ int k; float prod; prod=0; for(k=0; k<N; k++){ prod = prod + A_d[threadIdx.y*N + k] * B_d[k*N + threadIdx.x]; } C_d[threadIdx.y*N + threadIdx.x] = prod; } // Dichiariamo il main int main( int argc, char** argv) { //dichiarazioni __global__ void sommaarray(float*, float*, float*, int ); float *A, *B, *C, *A_d, *B_d, *C_d; int size, i, N; //allocazione memorie dell'host e del device N=3; size=sizeof(float); A=(float*)malloc(size*N*N); B=(float*)malloc(size*N*N); C=(float*)malloc(size*N*N); cudaMalloc((void**)&A_d, size*N*N); cudaMalloc((void**)&B_d, size*N*N); cudaMalloc((void**)&C_d, size*N*N); //inizializzazione for(i=0; i<N*N; i++) { *(A+i)=i-1; *(B+i)=i+1; } //copia nella memoria del device cudaMemcpy(A_d, A, size*N*N, cudaMemcpyHostToDevice); cudaMemcpy(B_d, B, size*N*N, cudaMemcpyHostToDevice);
31
// definizione della grandezza della griglia e dei blocchi dim3 DimGrid(1,1); dim3 DimBlock(N,N,1); // Lancio del kernel sommaarray<<<DimGrid,DimBlock>>>(A_d, B_d, C_d, N); // Esegue la copia dei risultati //dalla memoria del device a quella dell'host cudaMemcpy(C, C_d, size*N*N, cudaMemcpyDeviceToHost); //Stampa a video dei risultati for(i=0; i<N*N; i++) printf("C= %f\n", *(C+i)); printf("---------\n"); // libera la memoria sull'host cudaFree(A); cudaFree(B); cudaFree(C); // libera la memoria sul device cudaFree(A_d); cudaFree(B_d); cudaFree(C_d);
}
Il seguente listato di codice illustra come è possibile fare un prodotto tra 2 matrici quadrate.
In questo caso, le righe di A e B vengono caricate n volte nella memoria globale ed ogni thread
calcola un elemento della matrice risultante C; quindi è possibile utilizzare un solo blocco per la
matrice C. Ogni thread del blocco, quindi, carica una riga della matrice A, una colonna della
matrice B e ne esegue il prodotto scalare riportandolo nella matrice C.
32
3.2 OpenCL
OpenCL (Open Computing Language) è un framework per lo sviluppo di applicazioni eseguibili su
architetture eterogenee progettate in maniera indipendenti rispetto alla scelta dell’hardware.
Infatti è possibile eseguire il codice su dispositivi come le CPUs, le GPUs e i
DSPs, anche se prodotti da aziende diverse. Questa soluzione è stata ideata dalla
Apple, ma è stata sviluppata e tenuta in commercio da un consorzio no-profit con
nome di Khronos Group. OpenCL è, quindi, l’alternativa principale al framework proposto da
NVIDIA nel campo del GPU computing. Tuttavia, OpenCL basa la sua strategia di mercato su
un’idea radicalmente opposta a quella usata da CUDA; infatti mentre quest’ultima propone come
suo punto di forza la specializzazione dei prodotti (architettura ideata, sviluppata e compatibile solo
con NVIDIA) garantendo ottime prestazioni a discapito della portabilità su architetture diverse,
OpenCL invece propone una soluzione compatibile con tutti i dispositivi compatibili presenti sul
mercato. Infatti un’applicazione scritta in OpenCL può essere eseguita da componenti prodotti da
varie case produttrici, come ad esempio Intel, NVIDIA, IBM, AMD.
OpenCL include, tuttavia, anche un linguaggio di programmazione per scrivere kernel basato su
C99 che consente di sfruttare in maniera diretta le potenzialità dell’hardware che si ha
disposizione; inoltre sono presenti un’interfaccia di programmazione (API) ed un supporto
runtime per lo sviluppo delle applicazioni. In tale modello, comunque, sono messe a disposizione
degli sviluppatori una serie di primitive di sincronizzazione per l’esecuzione in ambiente altamente
parallelo, dei qualificatori per le regioni di memoria ed alcuni meccanismi di controllo per le
diverse piattaforme di esecuzione.
Tuttavia, è importante far notare che l’elevato tasso di portabilità del codice non implica,
ovviamente, il raggiungimento dello stesso livello di prestazioni su diverse risorse hardware.
E’ altresì importante adattare, quindi, l’applicazione facendo sempre riferimento alla piattaforma
d’esecuzione, così da ottimizzare il codice in base alle caratteristiche del dispositivo.
In particolare, il modello di piattaforma presentata da OpenCL richiede la presenza di un
processore Host collegato ad uno o più OpenCL Device. Ciascun device, inoltre, deve essere
inteso come un aggregato di Compute Unit (unità di calcolo), le quali a loro volta devono essere
33
Fig. 17 Modello architetturale proposto da OpenCL
viste come una serie di Processing Element.
Nella nostra analisi, identificheremo
l’OpenCL Device con una GPU.
Quindi, ricordando gli elementi base
dell’architettura di una generica GPU, le
Compute Unit coincideranno con gli
Streaming Multiprocessor e i
Processing Element con gli Streaming Processor.
Il modello di esecuzione proposto da OpenCL si presenta scisso in due parti, ovvero il codice viene
diviso nel programma Host eseguito dalla CPU e nei kernel eseguiti dalle OpenCL Device.
Una regola di base della programmazione di un kernel è che esso deve essere sempre definito
all’interno del contesto gestito dall’Host; per contesto si intende l’insieme di:
! Funzioni Kernel con il valore dei relativi argomenti, tutti contenuti all’interno di
strutture chiamate Kernel object;
! Program objects: il codice sorgente e l’eseguibile del programma che implementa
il Kernel;
! Memory objects, ovvero una serie di oggetti di memoria visibili all’host e alle
OpenCL devices durante l’esecuzione del kernel;
! Devices accessibili dall’Host.
Le interazioni tra Host e Device avvengono attraverso le code di comandi (command-queue), ne
viene associata una ad ogni singolo device. I comandi che possono essere inviati dall’Host si
dividono in 3 categorie: kernel enqueue commands (comandi per l’inserimento in coda di un
kernel), memory commands (comandi per il trasferimento di dati tra host e device) e
synchronization commands (comandi per la sincronizzazione dell’esecuzione). La coda di
comandi può essere risolta sia seguendo l’ordine di ricezione (in-order execution) e sia senza alcun
ordine (out-of-order execution). Nell’istante in cui viene inviato il comando di esecuzione di un
kernel allo stesso tempo viene definita anche la sua matrice di esecuzione, ovvero uno spazio di
indici N-dimensionale denominato NDRange (N-Dimensional Range con N=1,2,3).
Tale matrice di computazione permette di suddividere efficacemente il carico di lavoro in maniera
34
Fig. 18 Esempio di NDRange
n-dimenionale; un kernel object, infatti, verrà eseguito in un numero variabile di work-group,
contenenti a loro volta i work-item, ovvero le istanze di esecuzione del kernel.
E’ possibile identificare i work-item o tramite un ID globale o tramite una coppia di ID formata
dall’ID del work-group e dall’ID locale del work-item all’interno del gruppo.
Il programmatore può definire l’NDRange tramite 3 vettori di interi:
• Grandezza dello spazio globale in ciascuna dimensione (G);
• Un offset che setta il valore iniziale degli indici in ciascuna dimensione (F);
• La grandezza dei work-group in ogni dimensione (S);
L’operazione di suddivisione del carico di lavoro in diversi work-group, tenendo conto della
capacità di calcolo parallelo del dispositivo in uso, risulta essere uno dei parametri essenziali per il
raggiungimento di ottime performance durante l’esecuzione delle applicazioni. Un bilanciamento
errato del carico di lavoro, insieme alle specifiche del dispositivo, (latenza trasferimenti,
throughput, bandwidth) può portare ad un calo notevole in termini di prestazioni.
Questa organizzazione elaborativa, inoltre, comporta una gerarchia di memoria piuttosto
particolare: ogni work-item possiede sia una private memory e sia un accesso alla local memory
condivisa da tutti gli elementi appartenenti al work-group. Inoltre ogni work-item è in grado di
35
Fig. 19 Aree di memoria in da OpenCL
accedere sia ad un’area di memoria globale e sia ad una riservata alle costanti (global/constant
memory), entrambe condivise da tutti i devices presenti in uno stesso contesto.
Così come mostrato nella figura qui di seguito, è possibile migliorare i tempi di accesso alla
memoria globale anche attraverso la presenza delle cache.
3.2.1 Tipi di dato e qualificatori
Il linguaggio di programmazione su cui si basa OpenCL è una variante di C99, pertanto è logico
trovare similitudini con il linguaggio C. Oltre alla presenza dei principali tipi di dato elementari, vi
è anche la possibilità di trattarli in ottica vettoriale con n campi (n=2,3,4,8,16); sono presenti,
inoltre, anche tipi di dato grafici per immagini 2D e 3D e rispettive versioni array.
Risultano avere grande importanza i tipi di dato caratterizzanti l’esecuzione di un kernel sul device:
cl_context che indica contesto di esecuzione, cl_kernel e cl_program per la definizione
rispettivamente del kernel e del program-object. Vi sono anche tipi di dato specifici per le aree di
memoria come ad esempio cl_mem, oppure tipi di dato relativi all’ID di un device come
cl_device_id, ed infine tipi di dato per le code di messaggi come cl_command_queue.
Avendo a disposizione diverse aree di memoria su cui poter operare, il linguaggio OpenCL fornisce
36
vari qualificatori di tipo per specificare l’area di memoria in cui si vuole allocare un oggetto.
Tra questi troveremo:
__global
__local
__constant
__private
Il qualificatore __global è utilizzato per allocare oggetti nell'area di memoria globale; questi sono
persistenti per l'intera durata del programma e non possono essere condivisi tra i vari devices se
dichiarati all'interno del programma (program scope).
Il qualificatore __local permette l'allocazione di oggetti nell'area di memoria locale, che quindi
sono condivisi tra tutti i work-item appartenenti allo stesso work-group; il tempo di vita di questi
oggetti coincide con quello del gruppo.
Il qualificatore __constant permette di allocare oggetti nella memoria globale; questi però sono
utilizzabili in modalità di sola lettura nei kernel.
Il qualificatore __private, infine, permette l’allocazione di oggetti all’interno della memoria privata
di un singolo work-item.
Per quanto riguarda i qualificatori delle funzioni assume particolare rilievo il qualificatore
__kernel (o anche senza prefisso “ __ ” ) che definisce una funzione chiamabile dall'host ed
eseguibile su uno o più devices. Nella dichiarazione del kernel, inoltre, è necessario specificare per
ciascun argomento l’area di memoria in cui sono allocati tramite i qualificatori.
3.2.2 Funzioni built-in per la creazione di un contesto
L’esecuzione di una funzione kernel avviene all’interno di un contesto; per tale motivo si usa la
primitiva clCreateContext(...) che permette la creazione di un contesto e di specificare il numero
di device contenuti all’interno. Gli argomenti della primitiva sono, in ordine, una variabile per le
proprietà della piattaforma d'esecuzione, un variabile indicante il numero di dispositivi presenti
all’interno del contesto, un puntatore ad una struttura contenente l'ID dei device da inserire,
un'eventuale funzione (NULL se non specificata) per la gestione di errori ed, infine, un puntatore
ad una variabile di tipo cl_int indicante il codice di errore nel caso in cui la creazione del contesto
Sono utilizzabili in maniera del tutto equivalente anche senza il prefisso “ __ ” {
37
fallisse; se il contesto viene creato correttamente, la funzione restituisce il valore CL_SUCCESS
(un intero).
3.2.3 Funzioni built-in per la creazione di code di comandi
La creazione di una coda di comandi avviene tramite la primitiva clCreateCommandQueue (...).
Gli argomenti di tale funzione sono una variabile che fa riferimento al contesto d'esecuzione, il
device a cui sarà associata la coda, una serie di proprietà con rispettivi valori (come ad esempio
l’ordine di esecuzione dei comandi) ed infine un puntatore ad una variabile indicante il codice di
errore caso mai se ne verificasse qualcuno durante la creazione della coda..
3.2.4 Funzioni built-in per la gestione della memoria
Un buffer non è altro che un memory object visto come un’area di memoria contentente un insieme
lineare di oggetti. La primitiva per la creazione di un’istanza è clCreateBuffer(...); essa richiede i
seguenti argomenti: il contesto OpenCL dove creare l'area di memoria, un flag indicante la
modalità di accesso (in lettura e/o scrittura), la dimensione in byte dell'area di memoria da allocare,
un puntatore a eventuali dati già presenti ed infine il solito puntatore ad una variabile contenente il
codice d'errore nel caso l’allocazione non andasse a buon fine. Il valore di ritorno, in caso di
successo, è un oggetto buffer (appartenente al tipo cl_mem).
Per leggere e/o scrivere all’interno dei buffer si usano altre due primitive:
clEnqueueReadBuffer(...) e clEnqueueWriteBuffer(...)
Esse richiedono gli stessi argomenti che però assumono un significato diverso a seconda
dell'operazione scelta. Il primo argomento fa riferimento alla coda dei comandi in cui inserire il
comando; il secondo è il buffer di memoria su cui leggere o scrivere i dati; il terzo indica se le
operazioni siano bloccanti o meno; il quarto è un offset in byte che individua un'eventuale
spiazzamento dalla posizione di partenza del buffer; inoltre vi è indicata la grandezza in byte dei
dati da leggere/scrivere; infine, è richiesto poi un puntatore (a void) a un buffer contenuto nella
memoria dell'host in cui si trovano i dati da scrivere o dove verranno inseriti i dati letti.
In caso di successo viene restituito il valore CL_SUCCESS.
38
3.2.5 Funzioni built-in per i program object e i kernel object
Ogni kernel deve essere inserito all’interno di un program object, ma per istanziare quest’ultimo
viene usata la funzione clCreateProgramWithSource(...), i cui argomenti rappresentano il
contesto d'esecuzione, il numero di stringhe che rappresentano il codice del programma e le
stringhe stesse. Una volta costruito il program object, un programma deve attraversare le fasi di
compilazione e linking con l’uso della primitiva clBuildProgram(...). In entrambe i casi, se
entrambe le primitive concludono senza alcun errore viene restituito il valore CL_SUCCESS.
Adesso è possibile definire il kernel object attraverso la primitiva clCreateKernel(...), il quale
prende in ingresso rispettivamente il program object compilato e linkato, il nome della funzione
kernel presente all'interno del programma ed infine un puntatore ad una variabile intera per
memorizzare un'eventuale codice d'errore. Infine, prima di poter eseguire il kernel è necessario
impostare i valori degli argomenti, e ciò è possibile farlo grazie alla funzione clSetKernelArg(...).
3.2.6 Funzioni built-in per l’esecuzione del kernel
La primitiva clEnqueueNDRangeKernel(...) rende possibile l’esecuzione di un kernel in forma di
kernel object: il comando viene inserito nella command-queue del device, che lo eseguirà secondo
le politiche fissate all'atto della creazione coda. Il primo parametro in ingresso alla funzione è la
coda di comandi, il secondo è il kernel object da eseguire e il terzo indica le dimensioni
dell’NDRange, sia in termini di work-group che di work-item.
Il valore di ritorno è uguale a CL_SUCCESS in caso di successo. 3.2.7 Funzioni built-in per la deallocazione di oggetti
L’allocazione in memoria di vari oggetti in memoria ne implica la loro deallocazione alla fine del
programma. A tal proposito sono utili diverse primitive, come ad esempio:
clReleaseContext(cl_int context) che permette l’eliminazione di un contesto creato in precedeza
e non più utile; clReleaseCommandQueue(cl_command_queue queue), invece, permette
l’eliminazione di una coda di comandi; clReleaseMemObject(cl_mem memobj) garantisce la
deallocazione un buffer di memoria. Le primitive clReleaseProgram(cl_program program) e
39
clReleaseKernel(cl_kernel kernel), inoltre, permettono l'eliminazione del program object e dei
kernel object ad esso associati. 3.2.8 Esempio di un programma completo
Il seguente programma funge da esempio a quanto descritto sino ad ora mostrando come viene
eseguito l’elevazione al quadrato degli elementi di un array. Il compito di tale eleborazione è
destinato alla GPU sfruttando il numero massimo di work-group concessi dal dispositivo.
// Uso di un data size statico per semplicità #define DATA_SIZE (1024) // Semplice kernel che eleva al quadrato ogni valore contenuto in un array const char *KernelSource = "\n" \ "__kernel void square( \n" \ " __global float* input, \n" \ " __global float* output, \n" \ " const unsigned int count) \n" \ "{ \n" \ " int i = get_global_id(0); \n" \ " if(i < count) \n" \ " output[i] = input[i] * input[i]; \n" \ "} \n" \ "\n"; int main(int argc, char** argv) { int err; // codice di errore ritornato dalle api float data[DATA_SIZE]; // dati di partenza su cui eseguire le operazioni float results[DATA_SIZE]; // risultati ricevuti dal device unsigned int correct; // numero di risultati corretti ricevuti size_t global; // grandezza del dominio globale size_t local; // grandezza del dominio locale cl_device_id device_id; // id del device cl_context context; // contesto di esecuzione cl_command_queue commands; // coda di comandi cl_program program; // program object cl_kernel kernel; // kernel object cl_mem input; // memory object che istanzia memoria sul device per input cl_mem output; // memory object che istanzia memoria sul device per result // Inizializzazione del vettori a valori Random int i = 0; unsigned int count = DATA_SIZE; for(i = 0; i < count; i++) data[i] = rand() / (float)RAND_MAX; // Connessione ad un Device (GPU) int gpu = 1; err = clGetDeviceIDs(NULL, CL_DEVICE_TYPE_GPU, 1, &device_id, NULL); if (err != CL_SUCCESS) { printf("Error: Fallita la creazione di un gruppo di device!\n");
40
return EXIT_FAILURE; } // Creazione di un contesto context = clCreateContext(0, 1, &device_id, NULL, NULL, &err); if (!context) { printf("Error: Fallita la creazione di un contesto!\n"); return EXIT_FAILURE; } // Creazione di una coda di comandi associata al device all’interno del contesto commands = clCreateCommandQueue(context, device_id, 0, &err); if (!commands) { printf("Error: Fallita la creazione della coda di comandi!\n"); return EXIT_FAILURE; } // Creazione del program object dalla stringa definita all’inizio program = clCreateProgramWithSource(context, 1, (const char **) & KernelSource, NULL, &err); if (!program) { printf("Error: Fallita la creazione del program object!\n"); return EXIT_FAILURE; } // Compilazione e linking del programma err = clBuildProgram(program, 0, NULL, NULL, NULL, NULL); if (err != CL_SUCCESS) { printf("Error: Fallita la creazione del programma da eseguire!\n"); return EXIT_FAILURE; } // Creazione del kernel all’interno del programma che vogliamo eseguire kernel = clCreateKernel(program, "square", &err); if (!kernel || err != CL_SUCCESS) { printf("Error: Fallita la creazione del Kernel!\n"); exit(1); } // Creazione degli array di input e output nella memoria del device input = clCreateBuffer(context, CL_MEM_READ_ONLY, sizeof(float) * count, NULL, NULL); output = clCreateBuffer(context, CL_MEM_WRITE_ONLY, sizeof(float) * count, NULL, NULL); if (!input || !output) { printf("Error: Fallita l’allocazione nella memoria del device!\n"); exit(1); } // Scrittura dei dati all’interno dell’array di input nella memoria del Device err = clEnqueueWriteBuffer(commands, input, CL_TRUE, 0, sizeof(float) * count, data, 0, NULL, NULL); if (err != CL_SUCCESS) { printf("Error: Fallita la scrittura nell’array di input del Device!\n"); exit(1); }
41
// Settaggio valori da dare agli argomenti del kernel err = 0; err = clSetKernelArg(kernel, 0, sizeof(cl_mem), &input); err |= clSetKernelArg(kernel, 1, sizeof(cl_mem), &output); err |= clSetKernelArg(kernel, 2, sizeof(unsigned int), &count); if (err != CL_SUCCESS) { printf("Error: Fallito il settaggio degli argomenti del Kernel! %d\n", err); exit(1); } // Calcolo della dimensione massima del work group per l’esecuzione del kernel sul device err = clGetKernelWorkGroupInfo(kernel, device_id, CL_KERNEL_WORK_GROUP_SIZE, sizeof(local), &local, NULL); if (err != CL_SUCCESS) { printf("Error: Fallita il calcolo della dimensione MAX di un work group! %d\n", err); exit(1); } // Esecuzione del kernel sull’array di input // usando il massimo numero di work group per il dispositivo global = count; err = clEnqueueNDRangeKernel(commands, kernel, 1, NULL, &global, &local, 0, NULL, NULL); if (err) { printf("Error: Fallita l’esecuzione del kernel!\n"); return EXIT_FAILURE; } // Attendo la terminazione dei comandi in coda prima di leggere i risultati clFinish(commands); // Leggo i risultati dal device e ne verifico la correttezza err = clEnqueueReadBuffer( commands, output, CL_TRUE, 0, sizeof(float) * count, results, 0, NULL, NULL ); if (err != CL_SUCCESS) { printf("Error: Fallita la lettura dei risultati! %d\n", err); exit(1); } // Validazione dei risultati correct = 0; for(i = 0; i < count; i++) { if(results[i] == data[i] * data[i]) correct++; } // Resoconto della validità dei risultati printf("Calcolati '%d/%d' valori corretti!\n", correct, count); // Deallocazione in memoria degli oggetti istanziati clReleaseMemObject(input); clReleaseMemObject(output); clReleaseProgram(program); clReleaseKernel(kernel); clReleaseCommandQueue(commands); clReleaseContext(context); return 0; }
42
Il codice del kernel viene definito all’interno di una stringa per poi essere passato come argomento
alla primitiva della creazione del kernel object all’interno del program object. Il tutto viene eseguito
all’interno di un contesto creato tramite la primitive clCreateContext().
La funzione clGetKernelWorkGroupInfo() permette il recupero di informazioni strutturali di uno
specifico device, come ad esempio il numero massimo di work-group permessi dal dispositivo per
l’esecuzione di quel kernel. La funzione clFinish() permette il blocco dell’host in attesa della
terminazione dei comandi in coda sul device.
Infine si procede alla deallocazione degli oggetti e dell’aree di memoria utilizzate nell’esecuzione
del programma.
43
3.3 CUDA e OpenCL a confronto
Analizzando con cura entrambi i frameworks, è possibile evidenziare alcune sostanziali differenze
sotto diversi aspetti.
Vendors e applicabilità
Come già anticipato prima, per quanto concerne CUDA,è possibile applicarlo soltanto su unità di
eleborazione grafica prodotte dalla NVIDIA Corporation. Invece, a proposito di OpenCL, non c’è
tale limitazione ed inoltre è possibile implementare tale linguaggio su dispositivi diversi (CPUs,
DSPs…) e non solo su GPUs. Inoltre questi ultimi possono essere di vari produttori, purchè
compatibili con OpenCL: ad esempio le AMD GPUs (Radeon serie 5xxx, 6xxx, 7xxx e R9xxx),
alcune GPUs NVIDIA, Intel ( CPUs e GPUs) ed anche architetture Apple (solo con MacOS X
supportate da schede grafiche NVIDIA della serie GeForce e ATI Radeon)
Terminologia
E’ facile notare la differenza all’interno di Kernel OpenCL e CUDA, ad esempio per il primo
troviamo il prefisso “cl_” e per il secondo “cu_” ; tuttavia, differiscono anche nei vari termini usati
e a tal proposito riportiamo una tabella che ne riassume le differenze:
E’ chiaro che i termini utilizzati da CUDA si riferiscono esclusivamente all’uso sulle GPU, mentre
quelli di OpenCL fanno riferimento ad ogni tipo di device (CPU, GPU, DSP).
CUDA OpenCL
GPU Device
Multiprocessor Compute Unit
Scalar core Processing element
Kernel Program
Block Work-Group
Thread Work Item
Shared Memory Local Memory
Local Memory Private Memory
44
Portabilità
Il fatto che OpenCL sia compatibile con un’ampia gamma di devices, non implica affatto che lo
stesso codice venga eseguito in maniera ottimale e allo stesso modo su diversi chip.
Infatti anche se risulta essere eseguibile su tutte le periferiche che supportano OpenCL, molto
spesso, per ottimizzare le prestazioni è necessario modificare il codice del kernel in base alle
caratteristiche del device su cui deve essere eseguibile. Per quanto riguarda CUDA, invece, tale
problema risulta essere quasi inesistente poichè tutte le periferiche NVIDIA sono in grado di
supportare tale framework senza sostanziali differenze.
Facilità di programmazione
CUDA possiede tool molto più maturi rispetto ad OpenCL, come ad esempio un debugger e librerie
dedicate come CUBLAS (operazioni di algebra lineare ) e CUBFFT (calcolo della trasformata di
Fourier). Ad un programmatore C risulterà più semplice l’uso delle API di CUDA rispetto a quelle
di OpenCL, talvolta ricche di limitazioni.
Compilazione
OpenCL permette la compilazione del kernel soltanto a runtime, aggiungendo in alcuni casi un
overhead e quindi peggiorando le prestazioni; CUDA, invece, prevede una compilazione statica
tramite il compilatore nvcc.
CUDA o OpenCL ?
Non è possibile decretare il vincitore della contesa. Come sempre in informatica, il programmatore
deve valutare i pro e i contro che entrambi comportano facendo riferimento all’hardware che si ha
a disposizione; infatti ogni kernel non sarà mai eseguito con le stesse prestazioni su diverse
architetture.
45
Conclusioni
La società attuale si basa sull’idea di completare task in maniera velocissima e con un alto tasso
prestazionale. Il computing accelerato dalle GPUs permette di ottenere ottimi vantaggi in
quest’ottica, ma è giusto far notare che in caso di task con un alto tasso di cooperazione e stretta
dipendenza tra i dati è consigliabile l’esecuzione su una CPU multicore.
Quindi, come sempre, è giusto per il programmatore valutare quale parte del programma deve
essere eseguita sulla CPU e quale sulla GPU, al fine di raggiungere prestazioni elevate.
Tuttavia con l’uso delle GPUs, come menzionato con cura nella prima parte dell’elaborato, si
riescono ad ottenere altissime prestazioni in casi di elaborazioni parallele; è giusto parlare, quindi,
di una rivoluzione silenziosa che sarà avvertita dal cliente finale “soltanto” attraverso un notevole
aumento di prestazioni, ignaro di tutto il processo di trasformazione presente in sottofondo. Una
rivoluzione dettata dalla necessità di raggiungere altissime prestazioni in ogni ambito del mondo
reale, ad esempio nel caso di sistemi di sicurezza real-time oppure nel caso di elaborazioni e
processi bancari.
GPGPU. Una scelta sì sofisticata, ma necessaria per il progresso tecnologico.
46
Bibliografia
[1] NVIDIA,
http://www.nvidia.com/object/what-is-gpu-computing.html
visualizzato il 4/02/2015
[2] UTSA,
http://cs.utsa.edu/~qitian/seminar/Spring11/03_04_11/GPU.pdf
visualizzato il 5/02/2015
[3] GPUTECHCONF,
http://on-demand.gputechconf.com/gtc/2013/presentations/S3413-Advanced-Driver-Assistance-
Systems-ADAS.pdf
visualizzato il 6/02/2015
[4] TECHSPOT,
http://www.techspot.com/article/650-history-of-the-gpu/
visualizzato il 7/02/2015
[5] School of Computer Science: UMass CS,
http://people.cs.umass.edu/~emery/classes/cmpsci691st/readings/Arch/gpu.pdf
visualizzato l’8/02/2015
[6] AMD,
http://developer.amd.com/resources/heterogeneous-computing/what-is-heterogeneous-computing/
visualizzato il 9/02/2015
[7] NVIDIA,
http://www.nvidia.com/object/cuda_home_new.html
visualizzato il 12/02/2015
47
[9] San Diego SuperComputer Center,
http://www.sdsc.edu/us/training/assets/docs/NVIDIA-02-BasicsOfCUDA.pdf
visualizzato il 13/02/2015
[10] NVIDIA,
http://docs.nvidia.com/cuda/
visualizzato il 14/02/2015
[11] Khronos,
https://www.khronos.org/registry/cl/specs/opencl-1.0.pdf
visualizzato il 15/02/2015
[12] Georgia Tech – College of computing,
http://www.cc.gatech.edu/~vetter/keeneland/tutorial-2011-04-14/06-intro_to_opencl.pdf
visualizzato il 16/02/2015
[13] OpenCL programming guide for MAC
https://developer.apple.com/library/mac/documentation/Performance/Conceptual/OpenCL_MacPro
gGuide
visualizzato il 17/02/2015
[14] GPGPU HUB
http://gpgpu.org
visualizzato il 10/02/2015
[15] A. S. Tanenbaum, Architettura dei calcolatori.
Un approccio strutturale, Prentice Hall, 2006.