Παράλληλος Προγραμματισμός σε Κάρτες Γραφικών NVIDIA με Τεχνολογία CUDA

Τι είναι η GPU;

GPU = Graphics Processing Unit. Σε αντιδιαστολή με την CPU (Central Processing Unit), δηλαδή τον κεντρικό επεξεργαστή ο οποίος συχνά αποκαλείται και host, η GPU είναι μια κάρτα γραφικών, η οποία συχνά αποκαλείται και device. Η GPU διαθέτει μεγάλο πλήθος επεξεργαστικών μονάδων -συνήθως αρκετές δεκάδες ή εκατοντάδες- που καλούνται πυρήνες (cores). Οι πυρήνες είναι διαθέσιμοι στον προγραμματιστή ώστε μέσω μιας κατάλληλης βιβλιοθήκης μπορούν να χρησιμοποιηθούν για παράλληλη εκτέλεση προγραμμάτων.

Τι είναι η πλατφόρμα CUDA;

Η πλατφόρμα CUDA (Compute Unified Device Architecture) είναι ένα προγραμματιστικό μοντέλο της εταιρείας nVidia το οποίο υποστηρίζει την εκτέλεση παράλληλων προγραμμάτων χρησιμοποιώντας τους πυρήνες (cores) μιας ή περισσοτέρων καρτών GPU. Η CUDA επιτρέπει στον προγραμματιστή να δημιουργήσει εκατοντάδες ή και χιλιάδες νήματα τα οποία εκτελούνται στους πυρήνες μιας ή περισσοτέρων GPU. Με τον τρόπο αυτό μπορούν να υλοποιηθούν αλγόριθμοι μαζικής παράλληλης επεξεργασίας. Σειριακοί αλγόριθμοι ή αλγόριθμοι με μικρό παραλληλισμό δε συμφέρει να υλοποιούνται με CUDA.

Τι θα χρειαστεί να κατεβάσω στον υπολογιστή μου;

Από την ιστοσελίδα CUDA downloads θα χρειαστεί να κατεβούν και να εγκατασταθούν τρία βασικά πακέτα:

  • το CUDA Toolkit
  • οι CUDA drivers
  • το CUDA SDK (Software Development Kit)

Η τρέχουσα έκδοση CUDA 5.5 υποστηρίζει τα εξής λειτουργικά συστήματα:

Win-XP/Win7/Win8/Vista
Desktop
Win7/Win8/Vista
Nodebook
Linux RHEL
5.5 & 6
Fedora
18
OpenSUSE
12.2
Linux SLES
11
Ubuntu
10.04 & 12.04 & 12.10
Mac OS/X
10.7 & 10.8
32bit / 64bit 32bit / 64bit 64bit 64bit 64bit 64bit 32bit / 64bit 32bit / 64bit

Ποιες κάρτες γραφικών διαθέτουν την τεχνολογία CUDA;

Υπάρχει πληθώρα καρτών GPU με υποστήριξη CUDA. Δείτε πληροφορίες σχετικά με τις συμβατές κάρτες γραφικών στο https://developer.nvidia.com/cuda-gpus.

Αρχιτεκτονική Κάρτες
Kepler
(compute capability 3.x)
GeForce Σειρά 600 Quadro Σειρά Kepler Tesla Κ20
Tesla K10
Fermi
(compute capability 2.x)
GeForce Σειρά 500
GeForce Σειρά 400
Quadro Σειρά Fermi Tesla Σειρά 20
Tesla
(compute capability 1.x)
GeForce Σειρά 200
GeForce Σειρά 9
GeForce Σειρά 8
Quadro Σειρά FX
Quadro Σειρά Plex
Quadro Σειρά NVS
Tesla Σειρά 10
Τυπικές εφαρμογές: Διασκέδαση Επαγγελματικά Γραφικά Παράλληλη Επεξεργασία

Σε ποια γλώσσα προγραμματισμού μπορώ να γράψω εφαρμογές CUDA;

Στο πακέτο της CUDA περιλαμβάνεται ένα προγραμματιστικό περιβάλλον που επιτρέπει στον χρήστη να αναπτύξει εφαρμογές GPU χρησιμοποιώντας μια επέκταση της γλώσσας C. Ωστόσο υποστηρίζονται και άλλες γλώσσες όπως FORTRAN, DirectCompute, OpenACC ενώ υπάρχουν επίσης JavaΠχ. jcuda και PythonΠχ. pycuda WrappersWrapper: ένα αντικείμενο (object) που προσφέρει μια συγκεκριμένη λειτουργικότητα.

Ποιό είναι το αρχιτεκτονικό μοντέλο μιας κάρτας GPU;

Τα τελευταία χρόνια υπήρξαν ραγδαίες εξελίξεις στο αρχιτεκτονικό μοντέλο των καρτών GPU. Αν και υπήρχαν ήδη από το 1999 ο προγραμματισμός τους με σκοπό την υλοποίηση αλγορίθμων γενικού σκοπού (δηλ. όχι μόνο graphics) ήταν εξαιρετικά δύσκολος. Το μοντέλο ορόσημο που γέννησε την ιδέα των καρτών GPU για προγραμματισμό γενικού σκοπού ήταν η κάρτα GeForce 8800 που βασίστηκε στην αρχιτεκτονική G80 της nVidia. Συγκεκριμένα η αρχιτεκτονική G80

  • ήταν η πρώτη αρχιτεκτονική GPU που υποστήριζε τη γλώσσα C
  • διέθετε για πρώτη φορά ένα ενιαίο επεξεργαστή για υπολογισμούς graphics (vertex, geometry, pixel) και πράξεις γενικού σκοπού.
  • εισήγαγε για πρώτη φορά το προγραμματιστικό μοντέλο Single-Instruction Multiple-Thread (SIMT) όπου πολλοί κλώνοι του ίδιου προγράμματος εκτελούνται ως διαφορετικά νήματα με διαφορετικά id.
  • εισήγαγε την κοινή μνήμη (shared memory) και τον συγχρονισμό μέσω φραγμάτων (barrier synchronization) για την επικοινωνία μεταξύ νημάτων

Αν και η αρχιτεκτονική G80 βελτιώθηκε με το λανσάρισμα της G200 το 2008, η δεύτερη γενιά αρχιτεκτονικής GPU θεωρείται η Αρχιτεκτονική Fermi. Το παρακάτω σχήμα δείχνει το αρχιτεκτονικό διάγραμμα ενός chip GPU που ακολουθεί την Αρχιτεκτονική Fermi.

Στο chip αυτό υπάρχουν

  • 16 Streaming Processors (SM) με 32 πυρήνες (cores) ο καθένας οπότε συνολικά υπάρχουν 512 πυρήνες. Οι SM είναι τοποθετημένοι γύρω από μια κοινή μνήμη cache 2ου επιπέδου μεγέθους 768 KB.
  • 6 τράπεζες μνήμης 64bit που υποστηρίζουν μέχρι 6GB μνήμης τύπου GDDR5 DRAM.
  • Ένα host-interface που συνδέει την GPU με την CPU μέσω διαύλου PCI Express.
  • Ο δρομολογητής GigaThread engine ο οποίος κατανέμει τα blocks των νημάτων στους SM.

Η ιεραρχία των threads της CUDA (grid → blocks of threads → threads) αντιστοιχεί στην ιεραρχία των επεξεργαστών της GPU (GPU → SMs → cores):

Ένα πλέγμα (grid)εκτελείται σεμια GPU
Ένα block νημάτων (threads)ένα SM
Ένα νήμα (thread)ένα πυρήνα (core)

 

Η Αρχιτεκτονική Kepler είναι η τρίτη και πιο πρόσφατη γενιά GPU.
Σύγκριση αρχιτεκτονικών Fermi, Kepler:

Fermi
GF100
Fermi
GF104
Kepler
GK104
Kepler
GK110
Compute capability 2.0 2.1 3.0 3.5
Threads / Warp 32 32 32 32
Max Warps / SM 48 48 64 64
Max Threads / SM 1536 1536 2048 2048
Max Blocks / SM 8 8 16 16
Max Threads / Block 1024 1024 1024 1024
32 bit Registers / SM 32768 32768 65536 65536
Max Registers / Thread 63 63 63 255

Τι είναι o Streaming Multiprocessor;

O Streaming Multiprocessor εκτελεί ένα ή περισσότερα block νημάτων. Αποτελείται από πολλούς πυρήνες (cores) όπως φαίνεται στο παρακάτω σχήμα. Το σχήμα δείχνει το αρχιτεκτονικό διάγραμμα ενός SM της αρχιτεκτονικής Fermi.

Ένας SM της αρχιτεκτονικής Fermi περιέχει:

  • 32 πυρήνες (cores). Κάθε πυρήνας είναι ένας πολύ απλός επεξεργαστής ο οποίος περιέχει μια πλήρως pipelined Integer Arithmetic Logic Unit (ALU) και μια Floating Point Unit (FPU). Το διάγραμμα ενός πυρήνα δίνεται στο παρακάτω σχήμα:
  • 16 μονάδες Load/Store
  • 4 Special Function Units (SFU) οι οποίες εκτελούν πολύπλοκες μαθηματικές συναρτήσεις όπως ημίτονο (sine), συνημίτονο (cosine), αντίστροφο (1/x) και τετραγωνική ρίζα.
  • 32768 Καταχωρητές (Registers) μήκους 32 bit.
  • Μια Κοινή μνήμη μεγέθους 64 KB
  • Μια κοινή Data Cache επιπέδου 1 και μια Instruction Cache
  • Ένα κύκλωμα δικτύου για διασύνδεση με τους υπόλοιπους SM
  • Δύο warp schedulers και δύο instruction dispatch units.

Ένας SM δρομολογεί νήματα σε groups που καλούνται warps. Το πλήθος των νημάτων σε ένα warp σχετίζεται άμεσα με το πλήθος των πυρήνων που διαθέτει ο SM. Στο συγκεκριμένο παράδειγμα κάθε warp έχει 32 νήματα. Υπάρχουν 2 warp-schedulers και δύο μονάδες ανάθεσης εντολών (instruction dispatch units). Δηλαδή, ο Fermi SM εκτελεί ταυτόχρονα 2 εντολές προερχόμενες από 2 διαφορετικά warps. Μια εντολή από ένα warp μπορεί, ανάλογα με το είδος της, να ανατεθεί

  • είτε σε ένα πυρήνα από μια 16-άδα πυρήνων
  • είτε σε μια από τις 16 Load/Store units
  • είτε σε ένα από τα 4 Special Function Units

Ποιο είναι το μοντέλο μνήμης της CUDA;

Ένα kernel προκαλεί την εκτέλεση ενός Πλέγματος (Grid)

  • Ένα Πλέγμα αποτελείται από Ν Blocks νημάτων CUDA (Block0, ..., BlockN-1).
    • Ένα Block αποτελείται από Τ Νήματα CUDA (Νήμα0, ..., ΝήμαT-1).

Έτσι συνολικά υπάρχουν ΝΤ Νήματα.

Kernel:
Πλέγμα/Grid
Block νημάτων 0
Νήμα 0
. . .
Νήμα T-1
Κοινή Μνήμη
(Shared Memory)
Block νημάτων 1
Νήμα 0
. . .
Νήμα T-1
Κοινή Μνήμη
(Shared Memory)
...
Block νημάτων Ν-1
Νήμα 0
. . .
Νήμα T-1
Κοινή Μνήμη
(Shared Memory)
Καθολική Μνήμη (Global Memory)

Τα νήματα μέσα σε ένα block μπορούν να συνεργαστούν

  • είτε μοιραζόμενα την κοινή μνήμη του block (shared memory)

  • είτε κάνοντας συγχρονισμό εκτέλεσης χρησιμοποιώντας την εντολή __synchthreads(). Ένα νήμα που εκτελεί την εντολή αυτή σταματάει και περιμένει όλα τα νήματα του ιδίου block να την εκτελέσουν επίσης για να συνεχίσει. (Προσοχή στα if-then-else: κίνδυνος αδιεξόδου!!)

Νήματα σε διαφορετικά blocks δεν μπορούν να συνεργαστούν ούτε να συγχρονιστούν με την __synchthreads(). Μπορούν ωστόσο να προσπελάσουν την καθολική μνήμη (global memory) που είναι κοινή για όλα τα νήματα ανεξαρτήτως block. Η CPU βλέπει επίσης την καθολική μνήμη την οποία μπορεί να γράψει ή να διαβάσει χρησιμοποιώντας την εντολή cudaMemcpy().

Όλα τα νήματα εκτελούν τον ίδιο κώδικα, με τη διαφορά ότι έχουν ξεχωριστή ταυτότητα (id). Η ταυτότητα ενός νήματος καθορίζεται από 6 αριθμούς:

  • την ταυτότητα του block όπου βρίσκεται το νήμα: blockIdx.x, blockIdx.y, blockIdx.z
  • την ταυτότητα του νήματος μέσα στο block: threadIdx.x, threadIdx.y, threadIdx.z

Ο λόγος που χρησιμοποιούνται τρισδιάστες (3-D) ταυτότητες για τα blocks και για τα νήματα είναι ότι αυτό βολεύει συχνά όταν γράφουμε αλγορίθμους για επεξεργασία εικόνων (2-D προβλήματα) ή όγκων (3-D προβλήματα). Η ταυτότητα ενός νήματος ή η ταυτότητα του block στο οποίο εμπεριέχεται αυτό, μπορεί να χρησιμοποιηθεί για να γίνει προσπέλαση σε διαφορετικές θέσεις μνήμης και για να παίρνοται διαφορετικές αποφάσεις if-then-else σε σχέση με τα υπόλοιπα νήματα. Έτσι διαφοροποιούνται οι πράξεις που εκτελεί κάθε νήμα. Προφανώς, σε διαφορετική περίπτωση, αν όλα τα νήματα εκτελούσαν τις ίδιες πράξεις στα ίδια δεδομένα δεν θα υπήρχε παράλληλη επεξεργασία...
Το υλικό (hardware) δρομολογεί τα blocks σε οποιονδήποτε streaming multiprocessor (αν υπάρχουν περισσότεροι από 1 διαθέσιμοι).

Τι είναι η CUBLAS;

Η βιβλιοθήκη CUBLASCUDA Basic Linear Algebra Subprograms αποτελείται από παράλληλες συναρτήσεις CUDA που υλοποιούν βασικές ρουτίνες γραμμικής άλγεβρας, όπως ανάγνωση και εγγραφή διανυσμάτων και πινάκων, πολλαπλασιασμό διανυσμάτων με πίνακες, πολλαπλασιασμό πινάκων με πίνακες, κλπ. Πλήρης τεκμηρίωση της βιβλιοθήκης καθώς και παραδείγματα κώδικα δίνονται εδώ. Η CUBLAS προσφέρεται ήδη ενσωματωμένη στο NVIDIA CUDA SDK οπότε δεν απαιτείται κάποιο επί πλέον βήμα εγκατάστασης. Ο πλήρης κατάλογος των συναρτήσεων της βιβλιοθήκης είναι ο παρακάτω. Χρησιμοποιείται η συντόμευση <t> που αφορά τον τύπο της εκάστοτε συνάρτησης. Οι δυνατές συντομεύσεις είναι:

<t> Τύπος Σημασία
's' or 'S' float real single-precision
'd' or 'D' double real double-precision
'c' or 'C' cuComplex complex single-precision
'z' or 'Z' cuDoubleComplex complex double-precision

Έτσι, γα παράδειγμα, η συνάρτηση cublasIdamax() βρίσκει τον δείκτη του στοιχείου με την μεγαλύτερη απόλυτη τιμή σε ένα διάνυσμα τύπου double.

Πώς γράφω ένα απλό πρόγραμμα CUDA σε Windows;

Η συγγραφή προγραμμάτων CUDA σε Windows γίνεται με χρήση της πλατφόρμας Visual Studio 2005, Visual Studio 2008 ή Visual Studio 2010 σε γλώσσα C/C++. H CUDA χρησιμοποιεί μια επέκταση της γλώσσας C προσθέτοντας επί πλέον σύμβολα και λέξεις κλειδιά (keywords) έτσι ώστε να γίνεται εκτέλεση προγραμμάτων στους επεξεργαστές GPU. Το NVIDIA GPU Computing Toolkit (CUDA Toolkit) προσφέρει τον μεταφραστή nvcc ο οποίος είναι απαραίτητος για την μετάφραση κώδικα CUDA.

Για να γράψουμε ένα πρόγραμμα C/C++ χρησιμοποιώντας τις δυνατότητες της βιβλιοθήκης CUDA ακολουθούμε τα εξής βήματα:

  1. Από το Visual Studio 2010 δημιουργούμε νέο project: New Project → Visual C++ → Win32 Console Application. Επιλέγουμε Win32 Console Application ακόμη και σε έκδοση Windows 64bit
  2. Δίνουμε όνομα στο project, πχ. cuFirst. Έτσι στον φάκελο Source Files δημιουργούνται αυτόματα τα αρχεία cuFirst.cpp και stdafx.cpp
  3. Μετονομάζουμε το cuFirst.cpp σε cuFirst.cu. Επίσης αφαιρούμε το αρχείο stdafx.cpp από το project: [δεξί κλικ] Exclude From Project
  4. Στο παράθυρο Solution Explorer επιλέγουμε το project cuFirst [δεξί κλικ] Build Customizations... τσεκάρουμε την επιλογή CUDA 4.2
  5. Ανοίγουμε το μενού Properties του project: στο παράθυρο Solution Explorer επιλέγουμε το project cuFirst [δεξί κλικ] Properties. Στο μενού αυτό κάνουμε τις παρακάτω ρυθμίσεις:
    • VC++ Directories → Include Directories: προσθήκη του $(DXSDK_DIR)\include
    • CUDA C/C++ → Common → Additional Include Directories: προσθήκη των
      ./
      C:/ProgramData/NVIDIA Corporation/NVIDIA GPU Computing SDK 4.2/C/common/inc
      C:/ProgramData/NVIDIA Corporation/NVIDIA GPU Computing SDK 4.2/shared/inc

    • CUDA C/C++ → Device → Code Generation: προσθήκη των
      compute_10,sm_10
      compute_20,sm_20
      compute_30,sm_30
    • CUDA C/C++ → Host → Runtime Library: επιλογή του Multi-Threaded Debug (/MTd)
    • Linker → General → Additional Library Directories: προσθήκη των
      $(CudaToolkitLibDir)
      C:/NVIDIA/CUDA/CUDASamples/common/lib/$(PlatformName)
    • Linker → Input → Additional Dependencies: προσθήκη των cuda.lib και cublas.lib (εφόσον χρησιμοποιηθεί η CUBLAS).

Γράφουμε τον κώδικά μας στο αρχείο cuFirst.cu. Ο παρακάτω κώδικας προσθέτει δύο διανύσματα a, b μήκους N και βάζει το αποτέλεσμα στο διάνυσμα c.

	// cuDokimi.cu : Παράλληλη πρόσθεση διανυσμάτων σε GPU με χρήση CUDA
	//
	#include <stdlib.h>
	#include <stdio.h>
	#include <cuda.h>

	/*
	 * Συνάρτηση πυρήνα (kernel). Εκτελείται στους επεξεργαστές της GPU
	 */
	__global__ void add_vectors(float *a, float *b, float *c) {
		int i = blockIdx.x;
		
		c[i] = a[i] + b[i];
	}


	int main(void)
	{
		int count, i;
		
		// Βρες πόσες GPU διαθέτει το σύστημα (συνήθως 1)
		cudaGetDeviceCount(&count);
		printf("There are %d GPU devices in your system\n", count);

		int N = 10;	// Μήκος διανυσμάτων

		/*
		 * Δημιούργησε τα διανύσματα "a", "b", "c" στον host (CPU)
		 */
		float *a = (float *)malloc(N*sizeof(float));
		float *b = (float *)malloc(N*sizeof(float));
		float *c = (float *)malloc(N*sizeof(float));
		// Δώσε αρχικές τιμές στα "a", "b"
		for (i=0; i<N; i++) {
			a[i] = i - 0.5;
			b[i] = i*i - 3;
		}

		/*
		 * Δημιούργησε τα διανύσματα "a_dev", "b_dev", "c_dev" στο device (GPU)
		 */
		float *a_dev, *b_dev, *c_dev;
		cudaMalloc((void **)&a_dev, N*sizeof(float));
		cudaMalloc((void **)&b_dev, N*sizeof(float));
		cudaMalloc((void **)&c_dev, N*sizeof(float));
		
		/*
		 * Αντιγραφή των διανυσμάτων "a", "b" από τον host (CPU) στο device (GPU)
		 * δηλ. a --> a_dev,  b --> d_dev
		 */
		cudaMemcpy(a_dev, a, N*sizeof(float), cudaMemcpyHostToDevice);
		cudaMemcpy(b_dev, b, N*sizeof(float), cudaMemcpyHostToDevice);
		
		// Παράλληλη πρόσθεση c_dev[i] = a_dev[i] + b_dev[i]
		add_vectors<<< N, 1 >>>(a_dev, b_dev, c_dev);

		// Αντιγραφή του αποτελέσματος από το device (GPU) στον host (CPU)
		cudaMemcpy(c, c_dev, N*sizeof(float), cudaMemcpyDeviceToHost);
		
		/*
		 * Απελευθέρωση της μνήμης που δεσμεύεται από τα a_dev, b_dev, c_dev
		 */
		cudaFree(a_dev);
		cudaFree(b_dev);
		cudaFree(c_dev);

		// Εκτύπωση αποτελέσματος από τον host (CPU)
		printf("\n\n\nVector Addition Result:\n");
		for (i=0; i<N; i++) {
			printf("a[%d] : %0.2f \t+\t", i, a[i]);
			printf("b[%d] : %0.2f \t=\t", i, b[i]);
			printf("c[%d] : %0.2f\n", i, c[i]);
		}
		
		return 0;
	}
	

Αφού κάνουμε Build Project το εκτελέσιμο αρχείο cuFirst.exe βρίσκεται στον φάκελο Τα έγγραφά μου > Visual Studio 2010 > Projects > cuDokimi > Debug. Η εφαρμογή μας δεν είναι παραθυρική. Εκτελείται σε Command Window (Γραμμή Εντολών).

Τι ακριβώς κάνει το παραπάνω πρόγραμμα; Πώς λειτουργεί η CUDA;

Περιληπτικά:

Το πρόγραμμα προσθέτει δύο διανύσματα a και b και γράφει το αποτέλεσμα στο διάνυσμα c. Όλα διανύσματα έχουν μήκος N. Τα αθροίσματα των στοιχείων εκτελούνται σε ξεχωριστά νήματα στους πυρήνες της GPU.
 

Λεπτομέρειες:

__global__ Δήλωση ότι η συνάρτηση θα εκτελεστεί από τους πυρήνες (cores) της GPU void add_vectors Συνάρτηση πυρήνα (kernel). ( float *a, float *b, float *c Οι μεταβλητές αναφέρονται στην μνήμη της συσκευής GPU και όχι στην κεντρική μνήμη της CPU ) {
     int i = blockIdx.x; Δείχνει την θέση x του block στο 2-Δ πλέγμα των blocks στο οποίο ανήκει το συγκεκριμένο νήμα
    
     c[i] = a[i] + b[i];
}

Tο πρόθεμα __global__ δηλώνει ότι η συνάρτηση add_vectors() θα εκτελεστεί στους πυρήνες (cores) της GPU. Γι' αυτό το λόγο η συνάρτηση λέγεται και συνάρτηση πυρήνα (kernel function ή απλά kernel). Προφανώς το πρόθεμα __global__ δεν ανήκει στη standard C, επομένως αν αυτό το πρόγραμμα μεταφραστεί από έναν κλασσικό C-compiler θα λάβουμε μήνυμα σφάλματος. Το πρόγραμμά μας γράφεται σε ένα αρχείο με κατάληξη .cu και θα μεταφραστεί από τον μεταφραστή nvcc που προσφέρει η NVIDIA. Ο μεταφραστής αυτός αποτελεί υπερσύνολο του κλασικού μεταφραστή C γνωρίζοντας επί πλέον τις έξτρα λέξεις κλειδιά και τις εντολές που απαιτούνται από την CUDA. Έτσι γνωρίζει την λέξη κλειδί __global__ και θα παράξει τον κατάλληλο κώδικα μηχανής ώστε η συνάρτηση να τρέξει στους πυρήνες της GPU. Σημειώνεται ότι ο nvcc θα μπορούσε επίσης να χρησιμοποιηθεί σαν ένας κλασικός μεταφραστής C ακόμη και σε προγράμματα που δεν χρησιμοποιούν CUDA.
 

int main(void) Εκτελείται στην CPU
{
     int count, i;
     // Βρες πόσες GPU διαθέτει το σύστημα (συνήθως 1)
     cudaGetDeviceCount(&count); Βρίσκει πόσες κάρτες GPU διαθέτει ο υπολογιστής.
     printf("There are %d GPU devices in your system\n", count);

Ο κώδικας της συνάρτησης main() εκτελείται στον host (CPU). Κατ'αρχήν ρωτάμε πόσες συσκευές GPU διαθέτει ο υπολογιστής μας χρησιμοποιώντας την συνάρτηση cudaGetDeviceCount()Αν και η συνάρτηση δίνει πληροφορίες σχετικα με την GPU εκτελείται στην CPU και όχι στη GPU..
 

     int N = 10; // Μήκος διανυσμάτων
    
     /*
     * Δημιούργησε τα διανύσματα "a", "b", "c" στον host (CPU)
     */
     float *a = (float *)malloc(N*sizeof(float));
     float *b = (float *)malloc(N*sizeof(float));
     float *c = (float *)malloc(N*sizeof(float));
     // Δώσε αρχικές τιμές στα "a", "b"
     for (i=0; i<N; i++) {
         a[i] = i - 0.5;
         b[i] = i*i - 3;
     }

Δημιουργούμε στη μνήμη της CPU τα διανύσματα a[], b[] και c[] μεγέθους N*sizeof(float). Γίνεται δέσμευση της κατάλληλης ποσότητας μνήμης με χρήση της standard συνάρτησης της C, malloc(). Οι πίνακες a[], b[] αρχικοποιούνται σύμφωνα με τον τύπο a[i] = i-0.5 και b[i] = i^2-3.
 

     /*
     * Δημιούργησε τα διανύσματα "a_dev", "b_dev", "c_dev" στο device (GPU)
     */
     float *a_dev, *b_dev, *c_dev;
     cudaMalloc((void **)&a_dev, N*sizeof(float));
     cudaMalloc((void **)&b_dev, N*sizeof(float));
     cudaMalloc((void **)&c_dev, N*sizeof(float));

Δημιουργούμε στη μνήμη της συσκευής GPU αντίστοιχους πίνακες a_dev[], b_dev[] και c_dev[] , χρησιμοποιώντας τη συνάρτηση cudaMalloc. Αυτό είναι απαραίτητο διότι η συνάρτηση πυρήνα add_vectors() μπορεί να χρησιμοποιήσει μόνο τη μνήμη της συσκευής.
 

     /*
     * Αντιγραφή των διανυσμάτων "a", "b" από τον host (CPU) στο device (GPU)
     * δηλ. a --> a_dev, b --> d_dev
     */
     cudaMemcpy(a_dev, a, N*sizeof(float), cudaMemcpyHostToDevice);
     cudaMemcpy(b_dev, b, N*sizeof(float), cudaMemcpyHostToDevice);

Αντιγράφουμε τους πίνακες a, b από τη μνήμη της CPU στους αντίστοιχους πίνακες a_dev, b_dev που βρίσκονται στη μνήμη της GPU χρησιμοποιώντας τη συνάρτηση cudaMemcpy(). Είμαστε έτοιμοι για την παράλληλη εκτέλεση της πρόσθεσης των διανυσμάτων a_dev, b_dev.
 

     // Παράλληλη πρόσθεση c_dev[i] = a_dev[i] + b_dev[i]
     add_vectors<<< N, 1 >>> Εκτέλεση της συνάρτησης πυρήνα add_vectors() στην GPU σε Ν blocks νημάτων με 1 νήμα ανά block (a_dev, b_dev, c_dev);

Γενικά, μια συνάρτηση πυρήνα (kernel) εκτελείται παράλληλα σε πολλαπλά αντίγραφα. Σε κάθε αντίγραφο αντιστοιχεί και ένα διαφορετικό νήμα (thread) οπότε δημιουργείται ένα πλήθος νημάτων ίσο με το πλήθος των αντιγράφων που καλούμε να εκτελέσουμε. Αυτό το μοντέλο παράλληλου προγραμματισμού είναι γνωστό ως Single Program Multiple Threads (SPMT). Τα νήματα (threads) οργανώνονται σε 3-Δ blocks νημάτων και τα blocks οργανώνονται σε ένα 3-Δ πλέγμα (grid) από blocks. Κάθε νήμα διακρίνεται από τα υπόλοιπα χάρη

  • (α) στις συντεταγμένες του νήματος μέσα στο block, που δίνονται από τριάδα των ακεραίων threadIdx.x, threadIdx.y, threadIdx.z, και
  • (β) στις συντεταγμένες του block μέσα στο πλέγμα blockIdx.x, blockIdx.y, blockIdx.z.

Για παράδειγμα, στον παρακάτω κώδικα εκτελείται η συνάρτηση πυρήνα f() σε ένα πλέγμα διαστάσεων [4 blocks] x [6 blocks] x [3 blocks], όπου κάθε block αποτελείται από ένα 3-Δ array νημάτων με διαστάσεις: [7 νήματα] x [2 νήματα] x [4 νήματα]:

dim3 gridDim(4,6,3);
dim3 blockDim(7,2,4);
f<<< gridDim, blockDim >>>();

Συνολικά λοιπόν δημιουργούνται 4 x 6 x 3 x 7 x 2 x 4 = 4032 νήματα. Κάθε νήμα διακρίνεται από τα υπόλοιπα χάρη στην εξάδα δεικτών [blockIdx.x, blockIdx.y, blockIdx.z, threadIdx.x, threadIdx.y, threadIdx.z]. Κάθε δείκτης μεταβάλλεται από την τιμή 0 έως την αντίστοιχη διάσταση - 1. Στο συγκεκριμένο παράδειγμα, το εύρος τιμών των διαφόρων δεικτών είναι:

Δείκτης  Διάσταση  Εύρος τιμών
blockIdx.x 4 0,1,2,3
blockIdx.y 6 0,1,2,3,4,5
blockIdx.z 3 0,1,2
threadIdx.x 7 0,1,2,3,4,5,6
threadIdx.y 2 0,1
threadIdx.z 4 0,1,2,4

Η κλήση της συνάρτησης πυρήνα: add_vectors<<< N, 1 >>>(a_dev, b_dev, c_dev); ισοδυναμεί με τον παρακάτω κώδικα:

dim3 gridDim(N,1,1);
dim3 blockDim(1,1,1);
add_vectors<<< gridDim, blockDim >>>(a_dev, b_dev, c_dev);

Δημιουργείται συνεπώς ένα 1-Δ array νημάτων μήκους N=10. Μόνο ο δείκτης blockIdx.x μεταβάλλεται μεταξύ των τιμών 0,...,9. Όλοι οι άλλοι δείκτες είναι καρφωμένοι στο μηδέν.

Νήμα Εργασία που εκτελείται
blockIdx.x == 0 c_dev[0] = a_dev[0] + b_dev[0];
blockIdx.x == 1 c_dev[1] = a_dev[1] + b_dev[1];
blockIdx.x == 2 c_dev[2] = a_dev[2] + b_dev[2];
... ...
blockIdx.x == 9 c_dev[9] = a_dev[9] + b_dev[9];

Με τον τρόπο αυτό εκτελούνται παράλληλα οι 10 αθροίσεις. Η δρομολόγηση των νημάτων και η ανάθεσή τους στους κατάλληλους πυρήνες της GPU γίνεται από την CUDA χωρίς τον έλεγχο του χρήστη.
 

     // Αντιγραφή του αποτελέσματος από το device (GPU) στον host (CPU)
     cudaMemcpy(c, c_dev, N*sizeof(float), cudaMemcpyDeviceToHost);
    
     /*
     * Απελευθέρωση της μνήμης που δεσμεύεται από τα a_dev, b_dev, c_dev
     */
     cudaFree(a_dev);
     cudaFree(b_dev);
     cudaFree(c_dev);

Αντιγράφουμε το αποτέλεσμα από το array c_dev[] που βρίσκεται στη μνήμη της GPU στο array c[] που βρίσκεται στη μνήμη της CPU. Κατόπιν απελευθερώνουμε την μνήμη της GPU από τους πίνακες a_dev[], b_dev[], c_dev[] που δεν χρειάζονται πλέον.
 

     // Εκτύπωση αποτελέσματος από τον host (CPU)
     printf("\n\n\nVector Addition Result:\n");
     for (i=0; i<N; i++) {
         printf("a[%d] : %0.2f \t+\t", i, a[i]);
         printf("b[%d] : %0.2f \t=\t", i, b[i]);
         printf("c[%d] : %0.2f\n", i, c[i]);
     }
    
     return 0;
}

Η CPU τυπώνει τις πράξεις που γίνονται καθώς και το αποτέλεσμα έτσι ώστε να ελέγξουμε την ορθότητα του προγράμματος.
 

Υπάρχουν περιορισμοί στο πλήθος των Νημάτων, Blocks, κλπ;

Ναι υπάρχουν περιορισμοί. Ο πιο απλός τρόπος αποτύπωσης των περιορισμών είναι η ανάγνωση των ιδιοτήτων της συσκευής μέσω της συνάρτησης cudaGetDeviceProperties. Η συνάρτηση αυτή επιστρέφει ένα struct τύπου cudaDeviceProp που περιλαμβάνει όλες τις πληροφορίες σχετικά με την κάρτα GPU. Για παράδειγμα, το παρακάτω πρόγραμμα κάνει χρήση αυτής της συνάρτησης και εμφανίζει στην οθόνη όλες τις πληροφορίες που επιστρέφονται στο struct prop.

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

	int main(void)
	{
		int count, i;
		cudaDeviceProp prop;

		cudaGetDeviceCount(&count);

		printf("There are %d GPU devices in your system\n", count);

		for (i=0; i<count; i++) {
			printf("----------\nDevice #%d description\n", i);
			// Get device properties
			cudaGetDeviceProperties(&prop, i);
			// Display device properties
			printf(" Device name: %s\n",
				prop.name);
			printf(" Amount of global memory on device: %d Bytes\n",
				prop.totalGlobalMem);
			printf(" Maximum amount of shared memory a single block may use: %d Bytes\n",
				prop.sharedMemPerBlock);
			printf(" Number of 32-bit registers available per block %d\n",
				prop.regsPerBlock);
			printf(" Number of threads in a warp: %d\n",
				prop.warpSize);
			printf(" Maximum pitch allowed for memory copies: %d Bytes\n",
				prop.memPitch);
			printf(" Maximum number of threads that a block may contain: %d\n",
				prop.maxThreadsPerBlock);
			printf(" Maximum number of threads allowed along each block dimension: %d x %d x %d\n",
				prop.maxThreadsDim[0], prop.maxThreadsDim[1], prop.maxThreadsDim[2]);
			printf(" Number of blocks allowed along each grid dimension: %d x %d x %d\n",
				prop.maxGridSize[0], prop.maxGridSize[1], prop.maxGridSize[2]);
			printf(" Amount of available constant memory: %d Bytes\n",
				prop.totalConstMem);
			printf(" Major revision of the device's compute capability: %d\n", 
				prop.major);
			printf(" Minor revision of the device's compute capability: %d\n",
				prop.minor);
			printf(" Device's requirement for texture alignment: %d\n",
				prop.textureAlignment);
			printf(" Device can simultaneously perform a cudaMemcpy() and kernel execution: %s\n",
				(prop.deviceOverlap ? "yes" : "no"));
			printf(" Number of multiprocessors on device: %d\n",
				prop.multiProcessorCount);
			printf(" Runtime limit for kernels executed on this device: %s\n",
				(prop.kernelExecTimeoutEnabled ? "yes" : "no"));
			printf(" Device is an integrated GPU (part of the chipset and not a discrete GPU): %s\n",
				(prop.integrated ? "yes" : "no"));
			printf(" Device can map host memory into the CUDA device address space: %s\n",
				(prop.canMapHostMemory ? "yes" : "no"));
			printf(" Device's computing mode: default, exclusive, or prohibited: %d\n",
				prop.computeMode);
			printf(" Maximum size supported for 1D textures: %d\n",
				prop.maxTexture1D);
			printf(" Maximum dimensions supported for 2D textures: %d x %d\n",
				prop.maxTexture2D[0], prop.maxTexture2D[1]);
			printf(" Maximum dimensions supported for 3D textures: %d x %d x %d\n",
				prop.maxTexture3D[0], prop.maxTexture3D[1], prop.maxTexture3D[2]);
			/*
			printf(" Maximum dimensions supported for 2D texture arrays: %d x %d x %d\n",
				prop.maxTexture2DArray[0], prop.maxTexture2DArray[1], prop.maxTexture2DArray[2]);
			*/
			printf(" Device supports executing multiple kernels within the same context simultaneously: %s\n",
				(prop.concurrentKernels ? "yes" : "no"));
		}

		return 0;
	}
	

Ανάλογα με την κάρτα GPU που διαθέτουμε, μια πιθανή έξοδος του παραπάνω προγράμματος είναι η παρακάτω. Όπως βλέπουμε υπάρχει όριο διαστάσεων 65535 x 65535 x 65535 για το πλέγμα των blocks και όριο 1024 x 1024 x 64 για το array των νημάτων μέσα σε κάθε block. Παρατηρούμε επίσης ότι υπάρχει όριο 1024 νημάτων για κάθε block. Με άλλα λόγια, ένα array νημάτων 1024 x 1 x 1 για κάθε block είναι αποδεκτό, αλλά τα arrays νημάτων με διαστάσεις 1024 x 2 x 1, 512 x 4 x 1, 512 x 2 x 2, 1024 x 1 x 2, δεν είναι αποδεκτά διότι το πλήθος των νημάτων υπερβαίνει το άνω όριο, 1024. Επίσης, για παράδειγμα, δεν είναι αποδεκτό το array νημάτων 1 x 2 x 128 διότι η τρίτη διάσταση υπερβαίνει το όριο 64.

	There are 1 GPU devices in your system
	----------
	Device #0 description
	 Device name: GeForce GT 520M
	 Amount of global memory on device: 1073545216 Bytes
	 Maximum amount of shared memory a single block may use: 49152 Bytes
	 Number of 32-bit registers available per block 32768
	 Number of threads in a warp: 32
	 Maximum pitch allowed for memory copies: 2147483647 Bytes
	 Maximum number of threads that a block may contain: 1024
	 Maximum number of threads allowed along each block dimension: 1024 x 1024 x 64
	 Number of blocks allowed along each grid dimension: 65535 x 65535 x 65535
	 Amount of available constant memory: 65536 Bytes
	 Major revision of the device's compute capability: 2
	 Minor revision of the device's compute capability: 1
	 Device's requirement for texture alignment: 512
	 Device can simultaneously perform a cudaMemcpy() and kernel execution: yes
	 Number of multiprocessors on device: 2
	 Runtime limit for kernels executed on this device: yes
	 Device is an integrated GPU (part of the chipset and not a discrete GPU): no
	 Device can map host memory into the CUDA device address space: yes
	 Device's computing mode: default, exclusive, or prohibited: 0
	 Maximum size supported for 1D textures: 65536
	 Maximum dimensions supported for 2D textures: 65536 x 65535
	 Maximum dimensions supported for 3D textures: 2048 x 2048 x 2048
	 Device supports executing multiple kernels within the same context simultaneously: yes
	

Τι δηλώνει η λέξη κλειδί __global__ μπροστά από την δήλωση μιας συνάρτησης; Υπάρχουν και άλλες τέτοιες λέξεις κλειδιά;

Το πρόθεμα __global__ δηλώνει ότι η συνάρτηση που ακολουθεί θα κληθεί μεν από την CPU αλλά θα εκτελεστεί από τους πυρήνες της GPU. Οι σχετικές λέξεις κλειδιά είναι οι παρακάτω

Λέξη κλειδί Παράδειγμα Η f()
καλείται από
Η f()
εκτελείται στη
__device__ __device__ void f() { ... } GPU (device) GPU (device)
__global__ __global__ float f() { ... } CPU (host) GPU (device)
__host__ __host__ int f() { ... } CPU (host) CPU (host)

Είναι δυνατόν να χρησιμοποιηθούν μαζί τα δύο προθέματα __device__ και __host__ σε μια δήλωση συνάρτησης, πχ.
__device__ __host__ void f() { ... }
Στην περίπτωση αυτή ο μεταφραστής δημιουργεί δύο εκδόσεις της συνάρτησης f(): η μια θα καλείται μόνο από την CPU και θα εκτελείται στην CPU ενώ η δεύτερη θα καλείται από την GPU και θα εκτελείται στην GPU.

Μπορώ να γράψω ότι κώδικα θέλω μέσα σε μια συνάρτηση πυρήνα;

Σε μια συνάρτηση πυρήνα μπορούν να γίνουν οποιεσδήποτε πράξεις μεταξύ μεταβλητών, ανάγνωση και εγγραφή από τη μνήμη της συσκευής, ενώ μπορούν επίσης να χρησιμοποιηθούν όλα τα είδη βρόχων και γενικά όλες οι εντολές της C. Υπάρχουν ωστόσο κάποιοι περιορισμοί:

  • Δεν μπορούν να χρησιμοποιηθούν συναρτήσεις που χρησιμοποιούν συσκευές I/O (οθόνη, πληκτρολόγιο, κάρτα δικτύου, κλπ). Για παράδειγμα, δεν μπορεί να χρησιμοποιηθεί η εντολή printf() η οποία γράφει στην οθόνη, ούτε η εντολή scanf() η οποία διαβάζει από το πληκτρολόγιο.
  • Oι εντολές σε μια συνάρτηση πυρήνα δεν μπορούν να προσπελάσουν απ' ευθείας την κύρια μνήμη της CPU (host). Αν θέλουμε να προσπελάσουμε τα δεδομένα της κύριας μνήμης πρέπει πρώτα να τα μεταφέρουμε στη μνήμη της GPU χρησιμοποιώντας την εντολή cudaMemcpy().
  • Δεν υποστηρίζεται ο τύπος δεδομένων double. Σε μια συνάρτηση πυρήνα, οι μεταβλητές αυτού του τύπου μετατρέπονται αυτομάτως κατά την μετάφραση σε float.

Οι παραπάνω περιορισμοί ισχύουν για συναρτήσεις τύπου __device__ και __global__ οι οποίες εκτελούνται στην GPU. Προφανώς δεν ισχύουν για συναρτήσεις τύπου __host__ οι οποίες εκτελούνται στην CPU όπως οι κλασικές συναρτήσεις C / C++.

Πού μπορώ να βρω περισσότερη τεκμηρίωση και βοήθεια;

Υπάρχουν διάφορες πηγές πληροφόρησης σχετικά με την CUDA.

Κ. Διαμαντάρας (Tελευταία ενημέρωση 16/10/2013)