CUDA tutorial/fr: Difference between revisions

From Alliance Doc
Jump to navigation Jump to search
(Created page with "=Modèle de programmation= Voyons d'abord quelques termes importants : '''Hôte''' : désigne le CPU et sa mémoire (mémoire hôte) '''Composant''' : désigne le GPU et sa m...")
No edit summary
 
(80 intermediate revisions by 3 users not shown)
Line 1: Line 1:
<languages />
<languages />
[[Category:Software]]
[[Category:Software]]
=Introduction=
=Introduction=
Dans ce tutoriel, nous présentons le composant de calcul hautement parallèle qu'est le processeur graphique (ou GPU pour ''graphics processing unit'') et nous abordons le modèle CUDA avec quelques-unes de ses librairies numériques utilisées en calcul de haute performance.
Dans ce tutoriel, nous présentons la composante de calcul hautement parallèle qu'est le processeur graphique (ou GPU pour ''graphics processing unit''); le langage de programmation parallèle [[CUDA/fr|CUDA]]; et quelques-unes des librairies numériques CUDA utilisées en calcul de haute performance.
{{Prerequisites
{{Prerequisites
|title=Prérequis
|title=Prérequis
|content=
|content=
Ce tutoriel montre comment utiliser CUDA pour accélérer des programmes en C ou en C++; une bonne connaissance d'un de ces langages vous permettra d’en tirer le meilleur profit. Si CUDA sert aussi aux programmes en Fortran, nous nous limiterons ici à CUDA pour C/C++ et utiliserons le terme CUDA C. Il s'agit essentiellement de produire des fonctions en C/C++ pouvant être exécutées par les CPUs et les GPUs.
Ce tutoriel montre comment utiliser CUDA pour accélérer des programmes en C ou en C++; une bonne connaissance d'un de ces langages vous permettra d’en tirer le meilleur profit. Si CUDA sert aussi aux programmes en Fortran, nous nous limiterons ici à CUDA pour C/C++ et utiliserons le terme '''CUDA C'''. Il s'agit essentiellement de produire des fonctions en C/C++ pouvant être exécutées par les CPUs et les GPUs.
}}
}}
{{Objectives
{{Objectives
Line 22: Line 23:


=Qu'est-ce que CUDA?=
=Qu'est-ce que CUDA?=
CUDA est l'abréviation de ''compute unified device architecture''. Il s'agit d'un environnement logiciel flexible et d'un modèle de programmation pour le traitement de calculs parallèles intensifs permettant l'accès aux instructions et à la mémoire d'un GPU.
CUDA (''compute unified device architecture'') est un environnement logiciel et un modèle de programmation scalable pour le traitement de calculs parallèles intensifs sur GPU.


=Architecture du GPU=
=Architecture du GPU=
Un GPU se conmpose &nbsp;:
Un GPU se compose &nbsp;:
* d'une mémoire globale
* d'une mémoire globale
** semblable à la mémoire du CPU
** semblable à la mémoire CPU
** accessible par un CPU et un GPU
** accessible par CPU et GPU
* des multiprocesseurs en continu (SMs pour ''streaming multiprocessors'')
* des multiprocesseurs en continu (SM pour ''streaming multiprocessors'')
** chaque SM est formé de plusieurs processeurs en continu (SPs pour ''streaming processors'')
** chaque SM est composé de plusieurs processeurs en continu (SP pour ''streaming processors'')
** ces SMs effectuent les calculs
** ils effectuent les calculs
**chaque SM est doté d'une unité de contrôle, de registres, de pipelines d'exécution, etc. qui lui sont propres
**chaque SM est doté d'une unité de contrôle, de registres, de pipelines d'exécution, etc. qui lui sont propres
=Modèle de programmation=
Voyons d'abord quelques termes importants&nbsp;:
*'''Hôte''' : désigne le CPU et la mémoire  principale
*'''Composant''' :  désigne le GPU et sa mémoire


=Modèle de programmation=
=Modèle de programmation=
Voyons d'abord quelques termes importants :
Voyons d'abord quelques termes importants :
'''Hôte''' : désigne le CPU et sa mémoire (mémoire hôte)
*'''Hôte''' : désigne le CPU et sa mémoire (mémoire hôte).
'''Composant''' : désigne le GPU et sa mémoire (mémoire du composant)
*'''Carte graphique''' : désigne le GPU et sa mémoire (mémoire de la carte graphique).


The CUDA programming model is a heterogeneous model in which both the CPU and GPU are used.
Le modèle CUDA est un modèle hétérogène où à la fois le CPU et le GPU sont utilisés. Le code CUDA peut gérer les deux types de mémoires : la mémoire hôte et la mémoire de la carte graphique. Le code exécute aussi les fonctions du GPU appelées ''kernels'' (noyaux). Ces fonctions sont exécutées en parallèle par plusieurs fils GPU. Le processus comporte cinq étapes&nbsp;:  
CUDA code is capable of managing memory of both the CPU and the GPU as well as executing GPU functions, called kernels. Such kernels are executed by many GPU threads in parallel. Here is a five step recipe for a typical CUDA code:
#Déclaration et allocation de la mémoire hôte et de la mémoire de la carte graphique.
* Declare and allocate both the host and device memories
#Initialisation de la mémoire hôte.
* Initialize the host memory
#Transfert des données de la mémoire hôte à la mémoire de la carte graphique.
* Transfer data from Host memory to device memory
#Exécution des fonctions GPU (''kernels'').
* Execute GPU functions (kernels)  
#Retour des données à la mémoire hôte.
* Transfer data back to the host memory


<div class="mw-translate-fuzzy">
=Modèle d'exécution=
=Modèle d'exécution=
Le modèle CUDA est un modèle hétérogène qui utilise à la fois le CPU et le GPU.
Le code CUDA simple exécuté dans un GPU s'appelle ''kernel''. Il faut se demander :
Le code CUDA peut gérer les deux types de mémoires, la mémoire principale du CPU et la mémoire du composant GPU le code exécute aussi les fonctions du GPU appelées noyaux (''kernels''). Ces fonctions  sont exécutées en parallèle par plusieurs fils GPU. Le processus comporte cinq étapes :
* comment faire pour exécuter un kernel sur un groupe de multiprocesseurs en continu?
# déclaration et allocation de la mémoire principale et de la mémoire du composant
* comment faire pour que ce kernel soit exécuté de façon parallèle intensive?
# initialisation de la mémoire principale
Voici la recette en réponse à ces questions :
# transfert des données de la mémoire principale à la mémoire du composant
* chaque cœur GPU (processeur en continu) exécute un fil (''thread'') séquentiel, ce qui est le plus petit ensemble discret d'instructions géré par l'ordonnanceur du système d'exploitation
# exécution des fonctions GPU (noyaux)  
* tous les cœurs GPU exécutent le ''kernel'' de manière simultanée selon le modèle SIMT (''single instruction, multiple threads'')
# retour des données à la mémoire principale
La procédure suivante est recommandée ː
</div>
#Copier les données en entrée de la mémoire CPU à la mémoire GPU.
#Charger puis lancer le programme GPU (le ''kernel'').
#Copier les résultats de la mémoire GPU à la mémoire CPU.


= Block-threading model =
= Blocs de fils =


[[File:Cuda-threads-blocks.png|thumbnail|CUDA block-threading model where threads are organized into blocks while blocks are further organized into grid. ]]
[[File:Cuda-threads-blocks.png|thumbnail|Les fils sont groupés en blocs qui forment des grilles. ]]
Given a very large number of threads - in order to achieve massive parallelism one has to use all the threads possible - in a CUDA kernel, one needs to organize them somehow. In CUDA, all the threads are structured in threading blocks, the blocks are further organized into grids, as shown in the accompanying figure. In distributing the threads we must make sure that the following conditions are satisfied:
Pour obtenir un parallélisme intensif, on doit utiliser le plus le fils possible; puisqu'un ''kernel'' CUDA comprend un très grand nombre de fils, il faut bien les organiser. Avec CUDA, les fils sont groupés en blocs de fils, eux-mêmes formant une grille. Diviser les fils fait en sorte que :
* threads within a block cooperate via the shared memory
*les fils groupés coopèrent via la mémoire partagée,
* threads in different blocks can not cooperate
*les fils d'un bloc ne coopèrent pas avec les fils des autres blocs.
In this model the threads within a block work on the same set of instructions (but perhaps with different data sets) and exchange data between each other via shared memory. Threads in other blocks do the same thing (see the figure).  
Selon ce modèle, les fils dans un bloc travaillent sur le même groupe d'instructions (mais peut-être avec des jeux de données différents) et s'échangent les données via la mémoire partagée. Les fils dans les autres blocs font de même (voir la figure).
[[File:Cuda_threads.png|thumbnail|Threads within a block intercommunicate via shared memory. ]]
[[File:Cuda_threads.png|thumbnail|Intercommunication via la mémoire partagée des fils dans un bloc. ]]


Each thread uses IDs to decide what data to work on:
Chaque fil utilise des identifiants (IDs) pour décider quelles données utiliser :
* Block IDs: 1D or 2D (blockIdx.x, blockIdx.y)
* IDs des blocs : 1D ou 2D (blockIdx.x, blockIdx.y)
* Thread IDs: 1D, 2D, or 3D (threadIdx.x, threadIdx.y, threadIdx.z)
* IDs des fils : 1D, 2D, ou 3D (threadIdx.x, threadIdx.y, threadIdx.z)
Such a model simplifies memory addressing when processing multi-dimensional data.
Ce modèle simplifie l'adressage de la mémoire lors du traitement de données multidimensionnelles.


= Thread scheduling =
= Ordonnancement des fils =
Usually a streaming microprocessor (SM) executes one threading block at a time. The code is executed in groups of 32 threads (called warps). A hardware scheduller is free to assign blocks to any SM at any time. Furthermore, when an SM gets the block assigned to it, it does not mean that this particular block will be executed non-stop. In fact, the scheduler can postpone/suspend execution of such blocks under certain conditions when e.g. data becomes unavailable (indeed, it is quite time-consuming to read data from the global GPU memory). When it happens, the scheduler executes another threading block which is ready for execution. This is a so-called zero-overhead scheduling which makes the execution more streamlined so that SMs are not idle.
Un processeur en continu (SM) exécute habituellement un bloc de fils à la fois. Le code est exécuté en groupes de 32 fils (appelés ''warps''). Un ordonnanceur physique est libre d'assigner des blocs à tout SM en tout temps. De plus, quand un SM reçoit le bloc qui lui est assigné, ceci ne signifie pas que ce bloc en particulier sera exécuté sans arrêt. En fait, l'ordonnanceur peut retarder/suspendre l'exécution de tels blocs selon certaines conditions, par exemple si les données ne sont plus disponibles (en effet, la lecture de données à partir de la mémoire globale du GPU exige beaucoup de temps). Lorsque ceci se produit, l'ordonnanceur exécute un autre bloc de fils qui est prêt à être exécuté. Il s'agit en quelque sorte d'ordonnancement ''zero-overhead'' favorisant un flux d'exécution plus régulier afin que les SMs ne demeurent pas inactifs.


= Types of GPU memories=
= Types de mémoire GPU =
There are several types of memories available for CUDA operations:
Plusieurs types de mémoire sont disponibles aux opérations CUDA&nbsp;:
* Global memory
* mémoire globale
** off-chip, good for I/O, but relatively slow
** non sur la puce (''off-chip''), efficace pour opérations I/O, mais relativement lente
* Shared memory
* mémoire partagée
** on-chip, good for thread collaboration, very fast
** sur la puce (''on-chip''), permet une bonne collaboration des fils, très rapide
* Registers and Local Memory
* registres et mémoire locale
** thread work space , very fast
** espace de travail des fils, très rapide
* Constant memory
* mémoire constante


= A few basic CUDA operations =
= Quelques opérations de base =
== CUDA memory allocation ==
== Allocation de la mémoire ==
* cudaMalloc((void**)&array, size)
* cudaMalloc((void**)&array, size)
** Allocates object in the device memory. Requires address of a pointer of allocated array and size.
** Allocation d'objet dans la mémoire de la carte graphique. Exige l'adresse d'un pointeur vers les données allouées et la taille.
* cudaFree(array)
* cudaFree(array)
** Deallocates object from the memory. Requires just a pointer to the array.
** Désallocation de l'objet dans la mémoire. Exige uniquement le pointeur vers les données.


== Data transfer ==
== Transfert de données ==
* cudaMemcpy(array_dest, array_orig, size, direction)
* cudaMemcpy(array_dest, array_orig, size, direction)
** Copy the data from either device to host or host to device. Requires pointers to the arrays, size and the direction type (cudaMemcpyHostToDevice, cudaMemcpyDeviceToHost, cudaMemcpyDeviceToDevice, etc.)
** Copie les données de la carte graphique vers l'hôte ou de l'hôte vers la carte graphique. Exige les pointeurs vers les données, la taille et le type de direction (cudaMemcpyHostToDevice, cudaMemcpyDeviceToHost, cudaMemcpyDeviceToDevice, etc.)
* cudaMemcpyAsync
* cudaMemcpyAsync
** Same as cudaMemcpy, but transfers the data asynchronously which means it doesn't block the execution of other processes.
** Identique à cudaMemcpy, mais transfère les données de manière asynchrone, ce qui signifie que l'exécution des autres processus n'est pas bloquée.


= A simple CUDA C program=
= Exemple d’un programme CUDA C simple=
The following example shows how to add two numbers on the GPU using CUDA. Note that this is just an exercise, it's very simple, so it will not scale at all.
Dans cet exemple, nous additionnons deux nombres. Il s'agit d'un exemple très simple et il ne faut pas s'attendre à observer une grande accélération.
<syntaxhighlight lang="cpp" line highlight="1,5">
<syntaxhighlight lang="cpp" line highlight="1,5">
__global__  void add (int *a, int *b, int *c){
___global__ void add (int *a, int *b, int *c){
  *c = *a + *b;
}


*c = *a + *b;
}
int main(void){
int main(void){
int a, b, c;
  int a, b, c;
int *dev_a, *dev_b, *dev_c;
  int *dev_a, *dev_b, *dev_c;
int size = sizeof(int);
  int size = sizeof(int);


//  allocate device copies of a,b, c
  //  allocate device copies of a,b, c
cudaMalloc ( (void**) &dev_a, size);
  cudaMalloc ( (void**) &dev_a, size);
cudaMalloc ( (void**) &dev_b, size);
  cudaMalloc ( (void**) &dev_b, size);
cudaMalloc ( (void**) &dev_c, size);
  cudaMalloc ( (void**) &dev_c, size);


a=2; b=7;
  a=2; b=7;
//  copy inputs to device
  //  copy inputs to device
cudaMemcpy (dev_a, &a, size, cudaMemcpyHostToDevice);
  cudaMemcpy (dev_a, &a, size, cudaMemcpyHostToDevice);
cudaMemcpy (dev_b, &b, size, cudaMemcpyHostToDevice);
  cudaMemcpy (dev_b, &b, size, cudaMemcpyHostToDevice);


// launch add() kernel on GPU, passing parameters
  // launch add() kernel on GPU, passing parameters
add <<< 1, 1 >>> (dev_a, dev_b, dev_c);
  add <<< 1, 1 >>> (dev_a, dev_b, dev_c);


// copy device result back to host
  // copy device result back to host
cudaMemcpy (&c, dev_c, size, cudaMemcpyDeviceToHost);
  cudaMemcpy (&c, dev_c, size, cudaMemcpyDeviceToHost);


cudaFree ( dev_a ); cudaFree ( dev_b ); cudaFree ( dev_c );  
  cudaFree ( dev_a ); cudaFree ( dev_b ); cudaFree ( dev_c );
}
}
</syntaxhighlight>
</syntaxhighlight>


Are we missing anything ?
Il manque certainement quelque chose; ce code n’a pas une allure parallèle…  Comme solution, modifions le contenu du ''kernel'' entre les chevrons triples (<<<  >>>).
That code does not look parallel!
Solution: Let's look at what's inside the triple brackets in the kernel call and make some changes :
<syntaxhighlight lang="cpp" line highlight="1,5">
<syntaxhighlight lang="cpp" line highlight="1,5">
add <<< N, 1 >>> (dev_a, dev_b, dev_c);
add <<< N, 1 >>> (dev_a, dev_b, dev_c);
</syntaxhighlight>
</syntaxhighlight>
Here we replaced 1 by N, so that N different CUDA blocks will be executed at the same time. However, in order to achieve parallelism we need to make some changes to the kernel as well:
Ici, nous avons remplacé 1 par N pour que N blocs CUDA différents soient exécutés en même temps. Pour paralléliser cependant, il faut aussi faire des modifications au ''kernel'' :
<syntaxhighlight lang="cpp" line highlight="1,5">
<syntaxhighlight lang="cpp" line highlight="1,5">
__global__  void add (int *a, int *b, int *c){
__global__  void add (int *a, int *b, int *c){
 
  c[blockIdx.x] = a[blockIdx.x] + b[blockIdx.x];
c[blockIdx.x] = a[blockIdx.x] + b[blockIdx.x];
</syntaxhighlight>
</syntaxhighlight>
where blockIdx.x is the unique number identifying a CUDA block. This way each CUDA block adds a value from a[ ] to b[ ].
blockIdx.x est le numéro unique identifiant un bloc CUDA. De cette manière, chaque bloc CUDA ajoute une valeur de a[ ] à b[ ].
[[File:Cuda-blocks-parallel.png|thumbnail|CUDA blocks-based parallelism. ]]
[[File:Cuda-blocks-parallel.png|thumbnail|Parallélisation basée sur les blocs. ]]


Can we again make some modifications in those triple brackets ?
Modifions à nouveau le contenu entre les chevrons triples.
<syntaxhighlight lang="cpp" line highlight="1,5">
<syntaxhighlight lang="cpp" line highlight="1,5">
add <<< 1, '''N''' >>> (dev_a, dev_b, dev_c);
add <<< 1, '''N''' >>> (dev_a, dev_b, dev_c);
</syntaxhighlight>
</syntaxhighlight>
Now instead of blocks, the job is distributed across parallel threads. What is the advantage of having parallel threads ? Unlike blocks, threads can communicate between each other: in other words, we parallelize across multiple threads in the block when heavy communication is involved. The chunks of code that can run independently, i.e. with little or no communication, are distributed across parallel blocks.
La tâche est maintenant distribuée sur des fils parallèles plutôt que sur des blocs. Quel est l'avantage des fils parallèles? Contrairement aux blocs, les fils peuvent communiquer ensemble; autrement dit, nous parallélisons sur plusieurs fils dans le bloc quand la communication est intense. Les portions de code qui peuvent être exécutées indépendamment, soit avec peu ou pas de communication, sont distribuées sur des blocs parallèles.


= Advantages of shared memory=
= Avantages de la mémoire partagée=
So far all the memory transfers in the kernel have been done via the regular GPU (global) memory which is relatively slow. Often we have so many communications between the threads that the performance decreases significantly. In order to address this issue there exists another type of memory called '''shared memory''' which can be used to speed-up the memory operations between the threads. However the trick is that only the threads within a block can communicate.  In order to demonstrate the usage of such shared memory we consider the dot product example where two vectors are multiplied together element by element and then summed. Below is the kernel:
Jusqu'ici, tous les transferts en mémoire dans le ''kernel'' ont été via la mémoire régulière (globale) du GPU, ce qui est relativement lent. Il y a souvent tellement de communication entre fils que la performance est significativement diminuée.   Pour contrer ce problème, nous pouvons utiliser la mémoire partagée qui peut accélérer les transferts en mémoire entre les fils. Le secret par contre est que seuls les fils du même bloc peuvent communiquer. Pour illustrer l'utilisation de cette mémoire, voyons l'exemple du produit scalaire où deux vecteurs sont multipliés élément par élément et additionnés par la suite, ainsi :
<syntaxhighlight lang="cpp" line highlight="1,5">
<syntaxhighlight lang="cpp" line highlight="1,5">
__global__  void dot(int *a, int *b, int *c){
__global__  void dot(int *a, int *b, int *c){
        int temp = a[threadIdx.x]*b[threadIdx.x];  
  int temp = a[threadIdx.x]*b[threadIdx.x];  
}
}
</syntaxhighlight>
</syntaxhighlight>
After each thread computes its portion, we need to add everything together: each thread has to share its data. However, the problem is that each copy of thread's temp variable is private. This can be resolved by the use of shared memory. Below is the kernel with the modifications to use shared memory:
Après que chaque fil a exécuté sa portion, il faut tout additionner; chaque fils doit partager ses données. Toutefois, le problème est que chaque copie de la variable temporaire du fil est privée. La solution est d'utiliser la mémoire partagée avec les modifications suivantes au kernel :
<syntaxhighlight lang="cpp" line highlight="1,4">
<syntaxhighlight lang="cpp" line highlight="1,4">
#define N 512
#define N 512
__global__  void dot(int *a, int *b, int *c){
__global__  void dot(int *a, int *b, int *c){
  __shared__ int temp[N];
  __shared__ int temp[N];
  temp[threadIdx.x] = a[threadIdx.x]*b[threadIdx.x];
  temp[threadIdx.x] = a[threadIdx.x]*b[threadIdx.x];
  __syncthreads();  
  __syncthreads();
  if(threadIdx.x==0){
  if(threadIdx.x==0){
int sum; for(int i=0;i<N;i++) sum+= temp[i];
    int sum; for(int i=0;i<N;i++) sum+= temp[i];
*c=sum; }  
    *c=sum;
  }
}
}
</syntaxhighlight>
</syntaxhighlight>


= Basic performance considerations =
= Facteurs de performance de base =
== Memory transfer ==
== Transferts entre mémoires ==
* PCI-e is extremely slow (4-6 GB/s) compared to both host and device memories
* PCI-e est extrêmement lent (4-6Go/s) en comparaison à la mémoire hôte et la mémoire de la carte graphique.
* Minimize host-to-device and device-to-host memory copies
* Minimisez les copies de mémoire dans les deux directions.
* Keep data on the device as long as possible
* Gardez les données sur la carte graphique le plus longtemps possible.
* Sometimes it is not effificient to make the host (CPU) do non-optimal jobs; executing it on the GPU may still be faster than copying to CPU, executing, and copying back
* Il n'est parfois pas efficace d'utiliser l'hôte (le CPU) pour des tâches non optimales; il pourrait être plus rapide de les exécuter avec le GPU que de copier vers le CPU, exécuter et retourner le résultat.
* Use memcpy times to analyse the execution times
* Utilisez les temps mémoire pour analyser les temps d'exécution.


== Bandwidth ==
== Bande passante ==
* Always keep CUDA bandwidth limitations in mind when changing your code
* Tenez toujours compte des limites de la bande passante quand vous modifiez votre code.
* Know the theoretical peak bandwidth of the various data links
* Connaissez la bande passante de pointe théorique des divers liens de données.
* Count bytes read/written and compare to the theoretical peak
* Faites le décompte des octets écrits/lus et comparez avec la pointe théorique.
* Utilize the various memory spaces depending on the situation: global, shared, constant
* Utilisez les divers types de mémoire selon le cas : globale, partagée, constante.


== Common GPU programming strategies ==
== Stratégies usuelles de programmation ==
* Constant memory also resides in DRAM - much slower access than shared memory
* La mémoire constante réside aussi dans DRAM; l'accès est beaucoup plus lent que pour la mémoire partagée.
** BUT, it’s cached !!!
** MAIS, elle est cachée !!!
** highly efficient access for read-only, broadcast
** accès hautement efficace en lecture seule, transmission.
* Carefully divide data acording to access patterns:
* Répartissez bien les données selon le mode d'accès :  
** read-only:   constant memory (very fast if in cache)
** lecture seule :   mémoire constante (très rapide si dans la cache)
** read/write within block: shared memory (very fast)
** lecture/écriture dans le bloc : mémoire partagée (très rapide)
** read/write within thread: registers (very fast)
** lecture/écriture dans le fil : registres (très rapide)
** read/write input/results: global memory (very slow)
** lecture/écriture en entrée/résultats : mémoire globale (très lent)

Latest revision as of 16:34, 20 January 2022

Other languages:

Introduction

Dans ce tutoriel, nous présentons la composante de calcul hautement parallèle qu'est le processeur graphique (ou GPU pour graphics processing unit); le langage de programmation parallèle CUDA; et quelques-unes des librairies numériques CUDA utilisées en calcul de haute performance.

Prérequis

Ce tutoriel montre comment utiliser CUDA pour accélérer des programmes en C ou en C++; une bonne connaissance d'un de ces langages vous permettra d’en tirer le meilleur profit. Si CUDA sert aussi aux programmes en Fortran, nous nous limiterons ici à CUDA pour C/C++ et utiliserons le terme CUDA C. Il s'agit essentiellement de produire des fonctions en C/C++ pouvant être exécutées par les CPUs et les GPUs.


Objectifs d'apprentissage
  • Comprendre l'architecture d'un GPU
  • Comprendre le déroulement d'un programme CUDA
  • Comprendre et gérer les différents types de mémoires GPU
  • Écrire et compiler un exemple de code CUDA


Qu'est-ce qu'un GPU?

Un GPU (pour graphics processing unit) est un processeur monopuce capable d'effectuer des calculs mathématiques rapidement pour produire des rendus d'images. Depuis quelques années, la puissance du GPU sert aussi à accélérer l'exécution de calculs intensifs dans plusieurs domaines de la recherche scientifique de pointe.

Qu'est-ce que CUDA?

CUDA (compute unified device architecture) est un environnement logiciel et un modèle de programmation scalable pour le traitement de calculs parallèles intensifs sur GPU.

Architecture du GPU

Un GPU se compose  :

  • d'une mémoire globale
    • semblable à la mémoire CPU
    • accessible par CPU et GPU
  • des multiprocesseurs en continu (SM pour streaming multiprocessors)
    • chaque SM est composé de plusieurs processeurs en continu (SP pour streaming processors)
    • ils effectuent les calculs
    • chaque SM est doté d'une unité de contrôle, de registres, de pipelines d'exécution, etc. qui lui sont propres

Modèle de programmation

Voyons d'abord quelques termes importants :

  • Hôte : désigne le CPU et sa mémoire (mémoire hôte).
  • Carte graphique : désigne le GPU et sa mémoire (mémoire de la carte graphique).

Le modèle CUDA est un modèle hétérogène où à la fois le CPU et le GPU sont utilisés. Le code CUDA peut gérer les deux types de mémoires : la mémoire hôte et la mémoire de la carte graphique. Le code exécute aussi les fonctions du GPU appelées kernels (noyaux). Ces fonctions sont exécutées en parallèle par plusieurs fils GPU. Le processus comporte cinq étapes :

  1. Déclaration et allocation de la mémoire hôte et de la mémoire de la carte graphique.
  2. Initialisation de la mémoire hôte.
  3. Transfert des données de la mémoire hôte à la mémoire de la carte graphique.
  4. Exécution des fonctions GPU (kernels).
  5. Retour des données à la mémoire hôte.

Modèle d'exécution

Le code CUDA simple exécuté dans un GPU s'appelle kernel. Il faut se demander :

  • comment faire pour exécuter un kernel sur un groupe de multiprocesseurs en continu?
  • comment faire pour que ce kernel soit exécuté de façon parallèle intensive?

Voici la recette en réponse à ces questions :

  • chaque cœur GPU (processeur en continu) exécute un fil (thread) séquentiel, ce qui est le plus petit ensemble discret d'instructions géré par l'ordonnanceur du système d'exploitation
  • tous les cœurs GPU exécutent le kernel de manière simultanée selon le modèle SIMT (single instruction, multiple threads)

La procédure suivante est recommandée ː

  1. Copier les données en entrée de la mémoire CPU à la mémoire GPU.
  2. Charger puis lancer le programme GPU (le kernel).
  3. Copier les résultats de la mémoire GPU à la mémoire CPU.

Blocs de fils

Les fils sont groupés en blocs qui forment des grilles.

Pour obtenir un parallélisme intensif, on doit utiliser le plus le fils possible; puisqu'un kernel CUDA comprend un très grand nombre de fils, il faut bien les organiser. Avec CUDA, les fils sont groupés en blocs de fils, eux-mêmes formant une grille. Diviser les fils fait en sorte que :

  • les fils groupés coopèrent via la mémoire partagée,
  • les fils d'un bloc ne coopèrent pas avec les fils des autres blocs.

Selon ce modèle, les fils dans un bloc travaillent sur le même groupe d'instructions (mais peut-être avec des jeux de données différents) et s'échangent les données via la mémoire partagée. Les fils dans les autres blocs font de même (voir la figure).

Intercommunication via la mémoire partagée des fils dans un bloc.

Chaque fil utilise des identifiants (IDs) pour décider quelles données utiliser :

  • IDs des blocs : 1D ou 2D (blockIdx.x, blockIdx.y)
  • IDs des fils : 1D, 2D, ou 3D (threadIdx.x, threadIdx.y, threadIdx.z)

Ce modèle simplifie l'adressage de la mémoire lors du traitement de données multidimensionnelles.

Ordonnancement des fils

Un processeur en continu (SM) exécute habituellement un bloc de fils à la fois. Le code est exécuté en groupes de 32 fils (appelés warps). Un ordonnanceur physique est libre d'assigner des blocs à tout SM en tout temps. De plus, quand un SM reçoit le bloc qui lui est assigné, ceci ne signifie pas que ce bloc en particulier sera exécuté sans arrêt. En fait, l'ordonnanceur peut retarder/suspendre l'exécution de tels blocs selon certaines conditions, par exemple si les données ne sont plus disponibles (en effet, la lecture de données à partir de la mémoire globale du GPU exige beaucoup de temps). Lorsque ceci se produit, l'ordonnanceur exécute un autre bloc de fils qui est prêt à être exécuté. Il s'agit en quelque sorte d'ordonnancement zero-overhead favorisant un flux d'exécution plus régulier afin que les SMs ne demeurent pas inactifs.

Types de mémoire GPU

Plusieurs types de mémoire sont disponibles aux opérations CUDA :

  • mémoire globale
    • non sur la puce (off-chip), efficace pour opérations I/O, mais relativement lente
  • mémoire partagée
    • sur la puce (on-chip), permet une bonne collaboration des fils, très rapide
  • registres et mémoire locale
    • espace de travail des fils, très rapide
  • mémoire constante

Quelques opérations de base

Allocation de la mémoire

  • cudaMalloc((void**)&array, size)
    • Allocation d'objet dans la mémoire de la carte graphique. Exige l'adresse d'un pointeur vers les données allouées et la taille.
  • cudaFree(array)
    • Désallocation de l'objet dans la mémoire. Exige uniquement le pointeur vers les données.

Transfert de données

  • cudaMemcpy(array_dest, array_orig, size, direction)
    • Copie les données de la carte graphique vers l'hôte ou de l'hôte vers la carte graphique. Exige les pointeurs vers les données, la taille et le type de direction (cudaMemcpyHostToDevice, cudaMemcpyDeviceToHost, cudaMemcpyDeviceToDevice, etc.)
  • cudaMemcpyAsync
    • Identique à cudaMemcpy, mais transfère les données de manière asynchrone, ce qui signifie que l'exécution des autres processus n'est pas bloquée.

Exemple d’un programme CUDA C simple

Dans cet exemple, nous additionnons deux nombres. Il s'agit d'un exemple très simple et il ne faut pas s'attendre à observer une grande accélération.

___global__ void add (int *a, int *b, int *c){
  *c = *a + *b;
}

int main(void){
  int a, b, c;
  int *dev_a, *dev_b, *dev_c;
  int size = sizeof(int);

  //  allocate device copies of a,b, c
  cudaMalloc ( (void**) &dev_a, size);
  cudaMalloc ( (void**) &dev_b, size);
  cudaMalloc ( (void**) &dev_c, size);

  a=2; b=7;
  //  copy inputs to device
  cudaMemcpy (dev_a, &a, size, cudaMemcpyHostToDevice);
  cudaMemcpy (dev_b, &b, size, cudaMemcpyHostToDevice);

  // launch add() kernel on GPU, passing parameters
  add <<< 1, 1 >>> (dev_a, dev_b, dev_c);

  // copy device result back to host
  cudaMemcpy (&c, dev_c, size, cudaMemcpyDeviceToHost);

  cudaFree ( dev_a ); cudaFree ( dev_b ); cudaFree ( dev_c );
}

Il manque certainement quelque chose; ce code n’a pas une allure parallèle… Comme solution, modifions le contenu du kernel entre les chevrons triples (<<< >>>).

add <<< N, 1 >>> (dev_a, dev_b, dev_c);

Ici, nous avons remplacé 1 par N pour que N blocs CUDA différents soient exécutés en même temps. Pour paralléliser cependant, il faut aussi faire des modifications au kernel :

__global__   void add (int *a, int *b, int *c){
  c[blockIdx.x] = a[blockIdx.x] + b[blockIdx.x];

où blockIdx.x est le numéro unique identifiant un bloc CUDA. De cette manière, chaque bloc CUDA ajoute une valeur de a[ ] à b[ ].

Parallélisation basée sur les blocs.

Modifions à nouveau le contenu entre les chevrons triples.

add <<< 1, '''N''' >>> (dev_a, dev_b, dev_c);

La tâche est maintenant distribuée sur des fils parallèles plutôt que sur des blocs. Quel est l'avantage des fils parallèles? Contrairement aux blocs, les fils peuvent communiquer ensemble; autrement dit, nous parallélisons sur plusieurs fils dans le bloc quand la communication est intense. Les portions de code qui peuvent être exécutées indépendamment, soit avec peu ou pas de communication, sont distribuées sur des blocs parallèles.

Avantages de la mémoire partagée

Jusqu'ici, tous les transferts en mémoire dans le kernel ont été via la mémoire régulière (globale) du GPU, ce qui est relativement lent. Il y a souvent tellement de communication entre fils que la performance est significativement diminuée. Pour contrer ce problème, nous pouvons utiliser la mémoire partagée qui peut accélérer les transferts en mémoire entre les fils. Le secret par contre est que seuls les fils du même bloc peuvent communiquer. Pour illustrer l'utilisation de cette mémoire, voyons l'exemple du produit scalaire où deux vecteurs sont multipliés élément par élément et additionnés par la suite, ainsi :

__global__   void dot(int *a, int *b, int *c){
  int temp = a[threadIdx.x]*b[threadIdx.x]; 
}

Après que chaque fil a exécuté sa portion, il faut tout additionner; chaque fils doit partager ses données. Toutefois, le problème est que chaque copie de la variable temporaire du fil est privée. La solution est d'utiliser la mémoire partagée avec les modifications suivantes au kernel :

#define N 512
__global__   void dot(int *a, int *b, int *c){
  __shared__ int temp[N];
  temp[threadIdx.x] = a[threadIdx.x]*b[threadIdx.x];
  __syncthreads();
  if(threadIdx.x==0){
    int sum; for(int i=0;i<N;i++) sum+= temp[i];
    *c=sum;
  }
}

Facteurs de performance de base

Transferts entre mémoires

  • PCI-e est extrêmement lent (4-6Go/s) en comparaison à la mémoire hôte et la mémoire de la carte graphique.
  • Minimisez les copies de mémoire dans les deux directions.
  • Gardez les données sur la carte graphique le plus longtemps possible.
  • Il n'est parfois pas efficace d'utiliser l'hôte (le CPU) pour des tâches non optimales; il pourrait être plus rapide de les exécuter avec le GPU que de copier vers le CPU, exécuter et retourner le résultat.
  • Utilisez les temps mémoire pour analyser les temps d'exécution.

Bande passante

  • Tenez toujours compte des limites de la bande passante quand vous modifiez votre code.
  • Connaissez la bande passante de pointe théorique des divers liens de données.
  • Faites le décompte des octets écrits/lus et comparez avec la pointe théorique.
  • Utilisez les divers types de mémoire selon le cas : globale, partagée, constante.

Stratégies usuelles de programmation

  • La mémoire constante réside aussi dans DRAM; l'accès est beaucoup plus lent que pour la mémoire partagée.
    • MAIS, elle est cachée !!!
    • accès hautement efficace en lecture seule, transmission.
  • Répartissez bien les données selon le mode d'accès :
    • lecture seule : mémoire constante (très rapide si dans la cache)
    • lecture/écriture dans le bloc : mémoire partagée (très rapide)
    • lecture/écriture dans le fil : registres (très rapide)
    • lecture/écriture en entrée/résultats : mémoire globale (très lent)