modelli di programmazione per architetture di calcolo ... · scuola politecnica e delle scienze di...

47
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

Upload: truongxuyen

Post on 16-Feb-2019

219 views

Category:

Documents


0 download

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.