Tutoriel CUDA

From Alliance Doc
Revision as of 14:51, 5 October 2017 by Diane27 (talk | contribs) (Created page with "== 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 graphiqu...")
Jump to navigation Jump to search
Other languages:

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.

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 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.

Architecture du GPU

Un GPU se compose  :

  • d'une mémoire globale
    • semblable à la mémoire du CPU
    • accessible par un CPU et un GPU
  • des multiprocesseurs en continu (SMs pour streaming multiprocessors)
    • chaque SM est formé de plusieurs processeurs en continu (SPs 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 CPU 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 CPU 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 (noyau). 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)

Blocs de fils

Les fils sont groupés en blocs qui forment une grille.

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.

A simple CUDA C program

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.

__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 <<< et >>>.

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

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:

__global__   void add (int *a, int *b, int *c){

	c[blockIdx.x] = a[blockIdx.x] + b[blockIdx.x];

where blockIdx.x is the unique number identifying a CUDA block. This way each CUDA block adds a value from a[ ] to b[ ].

CUDA blocks-based parallelism.

Can we again make some modifications in those triple brackets ?

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

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.

Advantages of shared memory

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:

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

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:

#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; } 
}

Basic performance considerations

Memory transfer

  • PCI-e is extremely slow (4-6 GB/s) compared to both host and device memories
  • Minimize host-to-device and device-to-host memory copies
  • Keep data on the device as long as 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
  • Use memcpy times to analyse the execution times

Bandwidth

  • Always keep CUDA bandwidth limitations in mind when changing your code
  • Know the theoretical peak bandwidth of the various data links
  • Count bytes read/written and compare to the theoretical peak
  • Utilize the various memory spaces depending on the situation: global, shared, constant

Common GPU programming strategies

  • Constant memory also resides in DRAM - much slower access than shared memory
    • BUT, it’s cached !!!
    • highly efficient access for read-only, broadcast
  • Carefully divide data acording to access patterns:
    • read-only: constant memory (very fast if in cache)
    • read/write within block: shared memory (very fast)
    • read/write within thread: registers (very fast)
    • read/write input/results: global memory (very slow)