3.Programmation des processeurs graphiques - Moodle

publicité
1
Travaux pratiques d'Informatique Parallèle et distribuée, UMONS/Polytech, TP 3
3.Programmation des processeurs graphiques avec l'architecture de
programmation parallèle CUDA
3.1.Objectifs
Les objectifs de la séance sont de:
●
se familiariser avec la syntaxe de CUDA
●
se familiariser avec le compilateur nvcc
●
réaliser des programmes tournant sur les processeurs graphiques
3.2.Généralités
CUDA (Compute Unified Device Architecture) est une architecture de programmation parallèle développée par NVIDIA. Elle étend le langage C avec des fonctionnalités permettant la programmation sur processeur graphique. La compilation d'un code CUDA nécessite l'emploi du compilateur nvcc développé par NVIDIA. Ce compilateur s'utilise de la même manière que gcc :
nvcc code.cu ­o executable
3.3.Déroulement de l'exécution
Un programme tournant sur GPU s'exécute en 5 étapes :
1. La mémoire dédiée au CPU (mémoire hôte) et la mémoire dédiée au GPU (mémoire device) étant distinctes, les données utilisées sur le processeur graphique doivent être transférées dans la mémoire dédiée à ce dernier.
2. Le CPU demande au GPU d'exécuter le programme (le kernel) et spécifie le nombre de threads à lancer.
3. Au cours du traitement, les données sont chargées depuis la mémoire device dans les registres des ALUs ou dans les autres mémoires (partagée, constante, …) des multiprocesseurs.
4. Les résultats du calcul sont placés dans la mémoire device.
5. Les résultats sont transférés dans la mémoire hôte.
1
UMONS/Polytech
Pierre Manneback, Sébastien Frémal, Sidi Ahmed Mahmoudi
2014­15
2
Travaux pratiques d'Informatique Parallèle et distribuée, UMONS/Polytech, TP 3
3.4.Programmation
a) Sélection du processeur graphique :
La sélection du GPU utilisé par le programme s'effectuer à l'aide de la fonction cudaSetDevice :
cudaError_t cudaSetDevice (int device)
Cette fonction indique au programme qu'il doit utiliser le GPU d'identifiant device. S'il y a n GPU sur une machine, device peut valoir une valeur être comprise entre 0 et n­1. b)Allocation en mémoire device et transfert des données CPU => GPU
L'allocation de la mémoire s'exécute à l'aide de la fonction cudaMalloc :
cudaError_t cudaMalloc (void ** devPtr, size_t size)
Cette fonction réserve, dans la mémoire device, un espace mémoire de taille size dont l'adresse est contenue dans le pointeur devPtr.
Le transfert des données s'exécute via la fonction cudaMemcpy :
cudaError_t cudaMemcpy (void * dst, const void * src, size_t count, enum cudaMemcpyKind kind)
Cette fonction transfert count octets de l'espace mémoire src vers l'espace mémoire dst. La variable kind doit être mise à :
•
cudaMemcpyHostToDevice dans le cas d'un transfert CPU ==> GPU
•
cudaMemcpyDeviceToHost dans le cas d'un transfert GPU ==> CPU
c) Appel du kernel
Le kernel est une fonction destinée à tourner sur GPU. Elle se distingue des fonctions habituelles par son en­tête précédé par l'un de ces mots­clés :
•
__global__ : indique que la fonction est appelée depuis le CPU
•
__device__ : indique que la fonction est appelée depuis le GPU
Lorsque le GPU exécute un kernel, les instructions sont chargées dans les multiprocesseurs et les ALUs exécutent simultanément la même instruction (mode de fonctionnement SIMD – Single Instruction Multiple Data). Le nombre de thread s'exécutant ainsi en parallèle est déterminé par l'utilisateur lors de l'appel au kernel :
kernel<<<Nombre_de_blocs,Nombre_de_threads_par_bloc>>>(arguments);
Les threads se situent dans des blocs et le nombre total de thread lancé est égal à Nombre_de_blocs* Nombre_de_threads_par_bloc. Ces dernières variables peuvent soit être des scalaires, soit des variables de types dim3 :
dim3 var(x,y,z)
Dans ce dernier cas, les threads et les blocs occupent plusieurs dimensions, pouvant ainsi mieux représenter la répartition des données. Par exemple, l'appel kernel<<<(2,2,1),
(2,2,1)>>>(arguments); crée la grille de threads présentée à la page suivante.
UMONS/Polytech
Pierre Manneback, Sébastien Frémal, Sidi Ahmed Mahmoudi
2014­15
3
Travaux pratiques d'Informatique Parallèle et distribuée, UMONS/Polytech, TP 3
d)Identification des threads
Le même programme s'exécutant sur tous les coeurs, l'identifiant du thread est le moyen unique de différencier les exécutions. Celui­ci peut­être récupéré grâce aux variables blockDim, blockSize et threadId. Dans le cas d'une grille de threads en deux dimensions, voici comment situer le thread sur les deux axes :
int x = blockId.x * blockDim.x + threadId.x;
int y = blockId.y * blockDim.y + threadId.y;
e) Les types de mémoires dédiées au processeur graphique
La gestion de la mémoire doit être assurée par l'utilisateur et il est donc important d'en connaître les caractéristiques. Cette séance de travaux pratiques exploite trois types de mémoires :
1. La mémoire globale : cette mémoire correspond à la mémoire RAM du processeur graphique et possède la plus grande latence d'accès aux données (de 400 à 600 cycles d'horloge). C'est là que les données sont placées par la fonction cudaMemcpy.
2. Les registres : cette mémoire correspond aux registres de travail des ALUs, ce qui en fait la mémoire qui possède la latence la plus basse (1 cycle d'horloge). Une donnée est placée dans un registre lorsqu'elle est placée dans une variable déclarée au sein d'un kernel.
3. La mémoire partagée : cette mémoire se trouve dans le SM. Une donnée placée en mémoire partagée est accessible par tous les threads d'un même bloc. Pour déclarer une variable dans la mémoire partagée, il suffit de faire précéder sa déclaration au sein du kernel par le mot­clé __shared__ (Exemple : __shared__ int variable;). Elle possède une latence de 4 cycles d'horloge.
Cette mémoire étant partagée entre plusieurs threads, il se peut qu'une synchronisation soit nécessaire afin de s'assurer que les données soient bien écrites en mémoire. Pour ce faire, il suffit d'appeler la fonction __syncthreads() qui synchronise les threads d'un même bloc.
Type de mémoire
Utilité
Taille
Latence (cycles)
Globale
Mémoire principale
Jusqu'à 4 Go
400 à 600
Registres
Propre à chaque thread
8192 * 4 octets
1
Partagée
Communications entre threads
16 ko
4
UMONS/Polytech
Pierre Manneback, Sébastien Frémal, Sidi Ahmed Mahmoudi
2014­15
4
Travaux pratiques d'Informatique Parallèle et distribuée, UMONS/Polytech, TP 3
f) Récupération des résultats et libération de la mémoire
La récupération des résultats sur le CPU s'effectue grâce à la fonction cudaMemcpy, avec la variable kind mise à cudaMemcpyDeviceToHost.
Lorsque l'espace mémoire allouée sur le GPU peut être libéré, un appel à cudaFree doit être exécuté :
cudaError_t cudaFree(void * devPtr)
devPtr contient l'adresse mémoire à libérer.
3.5.Optimisation
a) Nombre de threads par multiprocesseur
L'utilisateur crée les threads par bloc et le GPU les exécute sur ses coeurs par lot de 32. Le nombre de threads par bloc est un moyen d'influencer l'exécution et donc les performances. Voici les contraintes qui déterminent l'assignement des blocs de threads aux multiprocesseurs :
•
p*b <= 768 : maximum 768 threads peuvent être attribué à un multiprocesseur
•
r*p*b <= R : il ne faut pas dépasser la quantité de registres disponibles •
s*b <= S : il ne faut pas dépasser la quantité de mémoire partagée disponible •
b <= 8 : limite matérielle •
p <= 512 : limite matérielle
Avec :
•
p = nombre de threads par bloc
•
b = nombre de blocs assignés au multiprocesseur
•
r = nombre de registres utilisés par threads
•
R = nombre de registres disponibles pour un multiprocesseur
•
s = quantité de mémoire partagée utilisée par bloc
•
S = quantité de mémoire partagée disponible par multiprocesseur
Il est possible de voir la quantité de registres et la taille de la mémoire partagée utilisés en utilisant l'option de compilation ­­ptxas­option=­v. b) Fusion des accès mémoire
Lorsque des threads adjacents accèdent en mémoire globale à des espaces adjacents, les accès mémoires sont fusionnés. Ce mécanisme permet de nettement diminuer la latence d'accès aux données.
UMONS/Polytech
Pierre Manneback, Sébastien Frémal, Sidi Ahmed Mahmoudi
2014­15
Travaux pratiques d'Informatique Parallèle et distribuée, UMONS/Polytech, TP 3
5
3.6.Exercice
1. Écrire un programme séquentiel qui effectue la multiplication d'un vecteur par une matrice à nombre de lignes variable. 2. Porter sur processeur graphique le programme conçu au point 1. 3. Observer l'impact du nombre de threads par bloc sur les temps d'exécution.
4. Dérouler la boucle à l'intérieur du kernel.
5. Fusionner les accès à la mémoire globale.
6. Fixer la taille du vecteur à 16 et utiliser la mémoire partagée et les registres.
UMONS/Polytech
Pierre Manneback, Sébastien Frémal, Sidi Ahmed Mahmoudi
2014­15
Téléchargement