• Aucun résultat trouvé

4.4 La technologie Compute Unified Device Architecture (CUDA)

4.4.2 Structure et bases d’un code CUDA

Un programme CUDA consiste en plusieurs étapes exécutées sur le CPU, également appelé host, et sur un GPU, nommé device. En général, la partie du programme comportant peu ou pas de parallélisme sera lancée sur le host, tandis que la partie parallélisée sera exécutée sur le device. En résumé, un programme CUDA intègre toutes ces parties, qui sont ainsi unifiées au sein d’un même code. C’est le compilateur NVIDIA C, aussi appelé nvcc, qui s’occupe de scinder les deux parties du programme. Je vais montrer les différents concepts permettant de comprendre et de pouvoir développer un programme en CUDA.

4.4.2.1 Les qualificatifs

Les qualificatifs permettent de distinguer les parties du code qui doivent être exécutées sur le device ou sur le host. Il existe des qualificatifs sur les fonctions mais également sur les variables. Les qualificatifs associés aux fonctions sont les suivants :

device : permet d’appeler et d’exécuter une fonction sur le GPU,host : permet d’appeler et d’exécuter une fonction sur le CPU,

global : permet d’appeler une fonction sur le CPU pour l’exécuter sur le GPU. Les qualificatifs associés aux variables sont :

device : permet de définir une variable dans la mémoire globale du GPU. Elle existera durant toute la vie de l’application, et elle sera accessible depuis le CPU et le GPU, notamment pour le rapatriement des résultats sur le CPU,

constant : définit une variable dans la mémoire constante du CPU. Cette variable sera disponible durant toute la vie de l’application. Elle est écrite par le code CPU et lue par le code GPU,

shared : définit une variable sur la mémoire partagée d’un multiprocesseur, qui sera disponible durant toute la vie du bloc de threads. Cette variable sera uniquement disponible via le code GPU.

4.4.2.2 Les kernels

Un kernel est toujours défini avec le qualificatif device, en spécifiant le nombre de threads que va exécuter ce noyau. Pour faire appel au kernel, on utilise la syntaxe<<<...>>>. Chaque thread exécutant ce kernel est identifié grâce à un identifiant unique, accessible au sein du kernel, en utilisant la variable :threadId.

Afin d’illustrer la définition ainsi que l’appel d’un kernel, on présente le code suivant qui permet d’additionner deux tableaux a et b de taille N , et de stocker le résultat dans un tableau

c.

Code 4.3 – Exemple CUDA de la somme de deux tableaux.

1 // Définition du kernel

2 __global__ void AddVector(float* a, float* b, float* c) {

3 int i = threadIdx.x;

4 c[i] = a[i] + b[i];

5 }

6 7

8 int main() {

9 ... // Appel du kernel avec N threads

10 AddVector<<<1, N>>>(a, b, c);

11 ...

Dans cet exemple, chaque thread effectue une addition entre deux éléments du tableau. Là où un code CPU devrait se charger de faire l’addition des N éléments en utilisant une boucle, ici chaque thread effectue une opération unique en parallèle des autres.

4.4.2.3 Hiérarchie des threads

Nous avons vu dans la section précédente, qu’un thread est défini par un identifiant unique :

threadIdx. Cet identifiant est un vecteur comportant trois composantes. Lors de l’exécution d’un kernel, on spécifie la dimension du bloc de threads, entraînant la création d’un indice permettant de différencier les threads. Bien entendu, le choix de la dimension est étroitement lié à la structure du problème.

Les coordonnées d’un thread au sein d’un bloc et son indice sont liés. Dans le cas où le bloc de thread est à une dimension, l’indice est défini par une coordonnée unique sur l’axe x. Si le bloc de thread est à deux dimensions de taille (Nx, Ny) et l’identifiant d’un thread est défini par le couple (x, y), alors l’indice du thread au sein du bloc est : x + yNx.

Enfin, si le bloc de threads est de dimension trois avec comme taille (Nx, Ny, Nz) et l’identifiant d’un thread de ce bloc est (x, y, z), alors son indice est le suivant : x + yNx+ zNxNy.

Il y a bien entendu une limite au nombre de threads par bloc, car tous les threads appartenant à ce bloc partagent la même mémoire, et les mêmes ressources. Sur les GPU actuels, chaque bloc peut contenir au maximum 1024 threads.

Les blocs sont organisés d’une façon similaire au threads. Ils peuvent être regroupés dans des grilles de une, deux ou trois dimensions. Comme pour la taille des blocs, la taille des grilles est déterminée en fonction des caractéristiques du problème traité. Un bloc au sein d’une grille est identifié par un identifiant uniqueblockIdxqui peut être un entier si la grille est à une dimension, où un vecteur à plusieurs composantes, dans le cas ou la grille est de dimension deux ou trois. La taille d’un bloc est accessible au sein du kernel, en utilisant la variableblockDim. Au final, une grille de blocs constitués de threads peut être illustrée par la Figure 4.12.

Reprenons l’exemple de notre programme CUDA (Code 4.3), en l’appliquant à la somme de deux matrices de taille N × N. On stocke la matrice à deux dimensions dans un tableau à une dimension. On accède donc à un élément de la matrice grâce à la variableidx. Dans cet exemple, on définit des blocs de taille (16,16) = 256 threads, au sein d’une grille dont la taille est déterminée en fonction de N .

Figure 4.12 – Représentation d’une grille de blocs contenant des threads.

Les threads appartenant au même bloc peuvent partager des données à travers la mémoire partagée. De plus, on peut synchronizer leurs exécutions pour coordonner l’accès à la mémoire.

Pour cela, on fait appel à la fonction__syncthreads()qui fonctionne comme une barrière

au sein d’un bloc.

Code 4.4 – Exemple CUDA de la somme de deux tableaux

1 // Définition du kernel

2 __global__ void AddVector(float* a, float* b, float* c) {

3

4 int i = threadIdx.x;

5 int j = threadIdx.y;

6

7 int idx = i + j*blockDim.x

8

9 c[idx] = a[idx] + b[idx];

10 } 11 12 13 int main() { 14 ... 15 // Appel du kernel 16 dim3 threadsPerBlock(16, 16);

17 dim3 numBlocks(N / threadsPerBlock.x, N / threadsPerBlock.y);

18

20 ...

21 }