handout prog par

Transcription

handout prog par
1 Présentation d’OpenMP
Master
1ère
OpenMP utilise trois types de construction pour contrôler la parallélisation d’un programme :
⋆ des directives de compilation ;
⋆ des fonctions de bibliothèques utilisables lors de l’exécution ;
⋆ des variables d’environnement.
année
Pour compiler un programme OpenMP :
xterm
$ gcc -fopenmp -o mon_programme mon_source.c
Un exemple de code :
1 #include <omp.h>
Explications :
2 int main () {
▷ la ligne 7 définit le début du block correspon3
int var1, var2, var3;
dant à la partie parallèle :
4
/* Partie séquentielle exécutée par la master thread */
⋄ toutes les threads prévues sont lancées, for5
…
ked ;
6
#pragma omp parallel private(var1, var2) shared(var3)
⋄ le block de code est dupliqué entre toutes
7
{
les threads ;
8
Partie parallèle exécutée par toutes les threads
9
…
▷ la ligne 10 marque la fin du block :
10
}
⋄ toutes les threads se synchronisent avec la
11
/* Reprise de la partie séquentielle */
«master thread», join, et s’arrêtent ;
12 }
⋄ la «master thread» reprend l’exécution sé-
quentielle.
Parallélisme & Applications
—
P-F. Bonnefoi
Version du 26 février 2016
Le processus de parallélisation
Table des matières
Annotation d’un code
1
2
Présentation d’OpenMP . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Le processus de parallélisation
Les directives OpenMP
Le partage du travail entre les threads, «work sharing»
«WorkSharing» : la directive «for»
«WorkSharing» : la directive «sections»
Les fonctions de bibliothèques utilisables durant l’exécution, «runtime»
Les variables d’environnement
La synchronisation
Les «tasks»
CUDA, «Compute Unified Device Architecture» . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
La hiérarchie mémoire
Répartition du travail entre threads regroupées en bloc
Communication entre «l’hôte» et le «CUDA device»
Exécution d’une application parallèle sur le «device»
Synchronisation & Communication
Optimisation de la vitesse en localisant mieux les données
Stratégie de développement
Extraction du parallèlisme de données et la définition de «grille»
Optimisation
Cours «OpenMP & Cuda» – P-F. Bonnefoi – http://p-fb.net/
1
OpenMP utilise des directives de compilation pour créer, synchroniser des threads et distribuer le travail qu’elles doivent réaliser :
⋄ la version séquentielle est donnée en (a) : elle est composée
⋄ des directives de compilation sont insérées manuellement
de différents blocks numérotés ;
par le programmeur au début d’un ensemble de block.
Ces directives ordonnent au compilateur de créer des
threads aux endroits indiqués ;
⋄ le schéma (b) indique comment le compilateur crée autant
de threads que demandé pour exécuter en parallèle les sections de code ;
⋄ une synchronisation, join, est automatiquement ajoutée à
la fin de chaque section pour s’assurer que le programme
continue son exécution une fois que chaque thread a terminé son travail.
⋄ la «master thread» indiquée par une ligne plus sombre s’occupe de créer les différentes threads.
Chaque thread est identifiée par un «ID» entier et la «master thread» reçoit le numéro 0.
5
Le rôle des différentes annotations
⋆ les directives de compilation indiquent au compilateur comment paralléliser le code ;
⋆ les fonctions de bibliothèques permettent de modifier/connaître le nombre de threads et de connaître le nombre de proces⋆ les variables d’environnement qui permettent de modifier l’exécution du programme OpenMP.
seurs/cœurs du système ;
—
26 février 2016 — 1
Les clauses utilisables dans les directives de compilation
Les directives OpenMP
Les directives contrôlent :
▷ la création de threads ;
▷ la distribution de la charge de travail, «workload» ;
Le format d’une directive est le suivant :
▷ la gestion des données ;
▷ la synchronisation entre les threads.
1 #pragma omp parallel default(shared) private(a,b)
2 {/* tout le code du bloc est exécuté en parallèle */
3
…
Exemple ⇒ 4 }
#pragma omp nom_directive [clause, …]
Directive pragma OpenMP
#pragma omp parallel
#pragma omp atomic
#pragma omp barrier
#pragma omp critical
#pragma omp flush
#pragma omp for
#pragma omp sections
#pragma omp section
Description
définie une région parallèle qui doit être exécutée par plusieurs threads, le processus original
devenant la «master thread».
Toutes les threads exécutent la région parallèle.
impose la modification d’une variable de manière atomique
synchronise toutes les threads appartenant à la même région parallèle
impose que le block de code qui suit soit exécuté par une seule thread à la fois
opération de synchronisation qui garantit que toutes les threads de la région parallèle auront la
même valeur pour la variable spécifiée
indique que les différentes occurrences de la boucle doivent être exécutées en parallèle en utilisant les différentes threads
répartie différents travaux effectué chacun par une thread différente si possible
définie un travail pour la directive précédente (doit être contenue dans le corps de la directive
sections)
Les directives OpenMP combinées
Il est possible de combiner «région parallèle» et «répartition de travail» :
Certaines des directives de compilation utilisent une ou plusieurs clauses :
∘ l’ordre dans lequel est écrit les clauses n’est pas important ;
∘ la plupart des clauses acceptent une liste d’éléments séparés par des virgules ;
∘ les clauses gèrent les partages des données entre les threads ;
∘ certaines clauses gèrent la copie d’une variable privée d’une thread à une variable correspondante d’une autre thread.
Directive
Parallel
Sections
Section
Critical
Barrier
Atomic
Flush (liste)
Ordered
Threadaptive (liste)
Clause
default(mode)
shared(liste)
copyin(liste)
num_threads(𝑛 entier)
Clause
Copying, default, private, firstprivate, reduction, shared
Private, firstprivate, lastprivate, reduction, schedule, nowait
Private, firstprivate, lastprivate, reduction
Aucune
Aucune
Aucune
Aucune
Aucune
Aucune
Description
permet de contrôler le mode par défaut pour le partage de l’accès aux variables, le mode peut être
private, shared, none
donne la liste des variables à partager entre les différentes threads créées par la directive «parallel».
Exemple : #pragma omp parallel default(shared)
copie la valeur des variables de la liste depuis la «master thread» vers les autres threads
requiert la création de 𝑛 threads
Le partage du travail entre les threads, «work sharing»
⋆ Les directives de «partage de travail» contrôlent quelles threads exécutent quelles instructions.
⋆ Ces directives ne créent pas de threads.
⋆ Ces directives sont au nombre de deux : «#pragma omp for» et «#pragma omp sections».
La directive #pragma omp for
Il existe différentes clauses possibles pour cette directive, en particulier :
□ schedule(static, 3) indique que le nombre total d’itérations de la boucle doit être divisé par 3, et la valeur obtenue
représente le nombre d’itérations à répartir entre les différentes threads.
Le «static», indique que cette répartition doit être fait de manière régulière et cyclique entre les différentes threads classées par
leur ID ;
□ schedule(dynamic, 3) le découpage est similaire à l’exemple précédent, mais le «dynamic» indique que la répartition des
itérations doit être fait en fonction de la première thread disponible : quand une thread termine sa tâche, elle cherche à prendre le
prochain bloc de travail, «chunk», disponible.
Clauses pour le #pragma omp for
schedule(type [, chunk size])
private(liste)
firstprivate(liste)
variables initialisées avec la valeur connue avant le début de la région ou du block
lastprivate(liste)
variables mises à jour à la sorite de la région ou du block
shared(liste)
Cette version peut être plus efficace du point de vue du code généré par le compilateur qui connait
à la fois la région parallèle et le travail à répartir.
reduction(operator:list)
nowait
Cours «OpenMP & Cuda» – P-F. Bonnefoi – http://p-fb.net/
Description
l’ordonnancement peut être statique ou dynamique. Elle décrit comment le travail est réparti
entre les threads, le nombre d’itération de la boucle réalisé par chaque thread est égal à «chunk
size».
la liste des variables «privées» de chaque thread
—
variables partagées entre les threads
réalise une «réduction» sur les variables indiquées dans la liste en utilisant l’opérateur + * & | ^ && ||.
Cet opérateur travaille sur la sortie de chaque thread une fois qu’elles ont fini leur exécution.
Les threads ne se synchronisent pas à la fin de la région «omp for».
26 février 2016 — 2
«WorkSharing» : la directive «for»
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <omp.h>
Le schéma de parallélisation :
#include <stdio.h>
#include <stdlib.h>
int main (int argc, char *argv[])
{ int i, n;
float a[100], b[100], sum;
n = 100;
for (i=0; i < n; i++)
a[i] = b[i] = i*1.0;
sum = 0.0;
#pragma omp parallel for schedule(dynamic,16) reduction(+:sum)
for (i = 0; i < 64; i++)
sum = sum + (a[i]* b[i]);
printf( “sum = %f\n”, sum);
}
⋄ en ligne 11, la directive de compilation ordonne l’exécution en parallèle des différentes itérations (qui sont indépendantes
les unes des autres).
Attention
OpenMP ne garantie/vérifie pas la capacité d’un code à être exécuté en parallèle.
⋄ la répartition est «dynamic», chaque thread reçoit, comme travail, 16 itérations de boucle.
⋄ l’opération de réduction applique l’addition sur les différentes valeurs de la variable «sum», calculées par chacune des
threads.
Un autre exemple
1 #pragma omp parallel default (none) \
⋄ la région parallèle débute en ligne 3 et finit en ligne 14 ;
2
shared (x, m, n) private (i, j, h1, h2, y1, y2)
3 { /* début de la région parallèle */
⋄ la clause «default(none)» (l’autre option possible se4
#pragma omp for nowait
rait «shared») définit la portée par défaut des variables
5
for (i = 0; i < m; i++) {
dans chaque thread ; none indique que c’est uniquement
6
for (j = 0; j < n; j++)
7
y1(i) = y1(i) + h1(j) *x(i-j);
le programmeur qui décide du partage ou non, y compris
8
}
pour la variable utilisée pour la boucle ;
9
#pragma omp for nowait
10
for (i = 0; i < m; i++) {
⋄ en ligne 2, la clause «shared(x, m, n)» indique que
11
for (j = 0; j < n; j++)
ces trois variables données dans la liste doivent être parta12
y2(i) = y2(i) + h2(j) *x(i-j);
gées par toutes les threads ;
13
}
14
} /* fin de la région parallèle */
⋄ la clause «private (i, j, h1, h2, y1, y2)» indique que ces variables doivent être privées pour chaque thread.
⋄ la ligne 4 est une directive utilisée pour paralléliser la boucle en ligne 5.
Les itérations de la boucle vont être réparties entre les différentes threads, et chaque thread va exécuté la boucle imbriquée,
nested, (en ligne 6) de manière séquentielle.
⋄ la clause «nowait» indique que les threads ne se synchroniseront pas à la fin de la boucle extérieur, ce qui évite la mise en
œuvre implicite de la barrière de synchronisation à la fin de la parallélisation de la boucle.
⋄ la ligne 9 est similaire à la ligne 4.
Cours «OpenMP & Cuda» – P-F. Bonnefoi – http://p-fb.net/
Le «nested parallelism» ou parallélisme imbriqué
Le «parallélisme imbriqué», ou «nested parallelism» peut
être activé :
⋄ en l’activant avec l’appel à la fonction de la bibliothèque
omp_set_nested().
Dans le programme, son activation peut être testée par
la fonction omp_get_nested().
⋄ en positionnant la variable d’environnement OMP_NESTED
à TRUE ;
1 #pragma omp parallel
2 {
3
#pragma omp for private(i, j)
4
for (i=0; i <n; i++) {
5
#pragma omp parallel <= requis
6
{
7
#pragma omp for private(j)
8
for (j=0; i<n; j++) {
9
do_work(i,j)
10
}
11
} <= barrière implicite
12
}
13 } <= barrière implicite
Attention
⋆ le nombre de threads utilisées va croître de manière exponentielle ;
⋆ seules les régions parallèles peuvent être imbriquées et non les direc-
tives «worksharing» de répartition de travail (il faut obligatoirement
le mot clé parallel dans la directive imbriquée) ;
⋆ les barrières de synchronisation implicites de fin de région ne peuvent
être supprimées (pas de nowait possible) ;
⋆ les directives de barrière, #pragma omp barrier, et de section
critique, #pragma omp critical, ne peuvent être imbriquées
dans une directive «woksharing».
«WorkSharing» : la directive «sections»
1 #pragma omp parallel
2 {
3
#pragma omp sections [clause [ ···]]
4
{
5
#pragma omp section
6
{
7
structured block # 1 statements
8
}
9
#pragma omp section
10
{
11
structured block # 2 statements
12
}
13
#pragma omp section
14
{
15
structured block # 3 statements
16
}
17
}
18 }
⋄ la ligne 1 définit la région parallèle ;
⋄ la ligne 3 ordonne au compilateur d’exécuter chacune des
«section» dans une thread séparée :
⋄ la ligne 5 définit la première section exécutée par une
thread ;
⋄ la ligne 9 définit la première section exécutée par une
seconde thread ;
⋄ la ligne 13 définit la première section exécutée par une
troisième thread.
Utilisation du «nowait»
La clause «nowait» permet aux threads qui :
▷ exécutent une section et qui finissent plus rapidement que celles s’occupant d’une autre section ;
▷ exécutent aucune section ;
de passer au travail situé après la partie «sections» et contenu dans la région parallèle globale.
—
26 février 2016 — 3
Les fonctions de bibliothèques utilisables durant l’exécution, «runtime»
Le fichier d’entête «omp.h» contient les prototypes des différentes fonctions.
Ces fonctions :
▷ contrôlent l’environnement d’exécution ;
▷ contrôlent et surveillent les threads ;
▷ contrôlent et surveillent les processeurs.
Exemples de fonction
void omp_set_num_threads(int num_threads); contrôle le nombre de threads utilisées pour la région parallèle qui sera définie après et qui ne possède pas de clause num_threads.
Fonction
double omp_get_wtime(void);
Description
retourne le temps réel, elapsed wall clock, en secondes.
Ce temps prends en compte le temps utilisateur, le temps système ainsi que les ralentissements dus à la surcharge du système.
double omp_get_wtick(void);
retourne la précision du temps utilisé par la fonction omp_get_wtime
omp_set_num_threads
définit le nombre de threads pour la région parallèle
omp_get_num_threads
retourne le nombre de threads
omp_get_thread_num
omp_get_num_procs
omp_in_parallel
retourne le numéro de la thread courante
retourne le nombre de processeur/cœur
Des fonctions basées sur l’utilisation de «loquets» permettent de :
▷ synchroniser les threads entre elles ;
▷ garantir l’intégrité des données lors de leur écriture/lecture simultanée par les différentes threads.
La syntaxe est la suivante :
void omp_fonction_lock(omp_lock_t *lck) où fonction peut être : init, destroy, set, unset et test.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <omp.h>
Deux types de fonctionnement :
#include <stdio.h>
⋆ Non bloquant : l’opération omp_test_lock permet de
int main() {
tester et de positionner le loquet :
omp_lock_t loquet;
⋄ s’il est occupé la fonction retourne faux ;
omp_init_lock(&loquet);
⋄ s’il est libre, la fonction positionne le loquet et retourne
#pragma omp parallel shared(loquet)
vrai.
{
⋆ Bloquant : l’opération omp_set_lock bloque la thread
int id = omp_get_thread_num();
tant qu’il n’est pas possible de positionner le loquet.
omp_set_lock(&loquet);
printf("Mon numero de thread est %d\n", id);
omp_unset_lock(&loquet);
Sur l’exemple ci-contre, une thread réalise un autre_travail
while(!omp_test_lock(&loquet))
de manière répétitive en attendant d’obtenir le loquet.
autre_travail(id);
travail(id);
Il est possible d’utiliser une version autorisant l’usage imomp_unset_lock(&loquet);
briqué où la même thread peut positionner le loquet plu}
sieurs fois en suffixant par _nest, comme par exemple :
omp_destroy_lock(&loquet);
omp_set_nest_lock.
}
retourne vrai si la fonction est appelée depuis une région parallèle
Les variables d’environnement
Des variables d’environnement sont utilisées pour modifier l’exécution de programmes OpenMP.
Les usages de certaines variables d’environnement :
⋄ le nombre de threads ;
⋄ le type de politique d’ordonnancement, «scheduling policy» ;
⋄ le parallélisme imbriqué, «nested parallelism» ;
⋄ la limite du nombre de threads.
Les variables d’environnement possèdent :
⋆ des noms tout en majuscules ;
⋆ des valeurs insensibles à la casse (pas de différences minuscules/majuscules).
Clauses #pragma omp for
OMP_NUM_THREADS nombre
OMP_DYNAMIC true/false
OMP_THREAD_LIMIT limite
La synchronisation
Description
définit le nombre de threads de la région parallèle
ajuste dynamiquement le nombre de threads de la région parallèle
contrôle le nombre maximum de threads utilisées dans le programme OpenMP
Pour définir une variable d’environnement :
$ export OMP_NUM_THREADS=4
Cours «OpenMP & Cuda» – P-F. Bonnefoi – http://p-fb.net/
Les directives de synchronisation
OpenMP offre des directives de compilation pour synchroniser l’exécution des threads.
Attention : Le programmeur doit éviter inter-blocages qui peuvent découler de ces synchronisations.
Il existe 5 directives de synchronisation :
⋄ critical ;
⋄ atomic ;
⋄ barrier.
⋄ ordered ;
⋄ flush ;
la directive barrier
Cette directive, #pragma omp barrier, synchronise toutes les threads :
⋄ lorsqu’une thread atteint la barrière, elle attend que toutes les autres threads atteignent aussi la barrière.
⋄ lorsque toutes les threads ont atteint la barrière, elles reprennent l’exécution du code situé après la barrière.
La directive critical
Cette directive définit une section critique, c-à-d que les threads exécutant du code code parallèle vont suspendre leur exécution à
leur arrivée à cette directive : une thread va exécuter la section de code suivant la directive seulement lorsqu’aucune autre thread ne
l’exécute.
1
2
3
4
5
6
7
8
9
10
11
12
#include <omp.h>
int main ()
{
int x = 0;
#pragma omp parallel
{
/* instructions */
…
#pragma omp critical
x = x+1;
/* instructions */
}
}
—
⋄ la ligne 4 définit une section de code qui doit être exécutée par toutes les
⋄ la ligne 8 indique que l’instruction de la ligne 9 ne peut exécutée que par une
threads ;
seule thread à la fois et que toutes les threads qui ont atteint cette ligne doivent
attendre.
Ainsi, la directive critical garantit qu’une section de code ne peut être
exécutée que par une thread à la fois, et ce, sans interruption des autres threads.
26 février 2016 — 4
Les «tasks»
CUDA
Les directives «orphelines» ou «orphan directives»
Des définitions
L’utilisation de fonctions peut compliquer l’utilisation des instructions parallèles, et il est nécessaires d’apporter des
modifications pour les y adapter.
Pour réaliser ces transformations on ajoute des directives dans ces fonctions.
Ce sont des directives qui sont définies en dehors d’une région parallèle, mais qui seront activées lorsqu’une région
parallèle fera appel à la fonction qui les contient.
Les «tasks»
□ la directive «task» crée une tâche de manière explicite ;
□ toutes les threads se partagent le travail disponible parmi l’ensemble des tâches créées ;
□ la directive «taskwait» garantie que toutes les tâches enfants crées dans la même tâche courante ont fini.
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <omp.h>
#include <stdio.h>
void main()
{
int i;
#pragma omp parallel private(i)
{ for(i=0; i<10; i++)
#pragma omp task
{
printf("Task %d: %i\n",
omp_get_thread_num(),i);
}
}
}
Grâce à la directive «#pragma omp task», une tâche, c-à-d une
thread matérielle est lancée à chaque occurence de la boucle for.
Si le nombre de tâches lancées dépasse le nombre de cœurs disponibles, alors elles seront simulées.
2 CUDA, «Compute Unified Device Architecture»
Terme
Host ou CPU
GPU
Device
kernel
Définition
c’est l’ordinateur qui sert d’interface avec l’utilisateur et qui contrôle le «device» utilisé pour
exécuter les parties de calcul intensif basé sur un parallélisme de données.
L’hôte est responsable de l’exécution des parties séquentielles de l’application.
est le processeur graphique, «General-Purpose Graphics Processor Unit», pouvant réaliser du
travail générique qui peut être utilisé pour implémenter des algorithmes parallèles.
est le GPU connecté à «l’hôte» et qui va exécuter les parties de calcul intensif basé sur un parallélisme de données.
Le device, ou périphérique, est responsable de l’exécution de la partie parallèle de l’application.
est une fonction qui peut être appelée depuis «l’hôte» et qui est exécutée en parallèle sur le
«device» CUDA par de nombreuses threads.
⋆ Le «kernel» est exécuté simultanément par des milliers de threads.
⋆ Une application ou une fonction de bibliothèque consiste en un ou plusieurs kernels.
Fermi peut exécuter différents kernels à la fois, s’ils appartiennent tous à la même application.
⋆ Un kernel peut être écrit en C avec des annotations pour exprimer le parallélisme :
⋄ localisation des variables ;
⋄ utilisation d’opération de synchronisation fournie par l’environnement CUDA.
La hiérarchie mémoire
La hiérarchie de mémoire et de threads est la suivante :
1. la thread au niveau le plus bas de la hiérarchie ;
C’est un environnement logiciel qui permet d’utiliser le GPU, «Graphics Processing Unit» au travers de programme de
haut niveau comme le C ou le C++ :
⋄ le programmeur écrit un programme C avec des extensions CUDA, de la même manière qu’un programme OpenMP ;
⋄ CUDA nécessite une carte graphique équipée d’un processeur NVIDIA de type Fermi, GeForce 8XXX/Tesla/Quadro,
2. le bloc composé de plusieurs threads exécutées de manière concurrente ;
etc.
3. la grille composée de plusieurs blocs de threads exécutés de manière concurrente ;
⋄ les fichiers source doivent être compilés avec le compilateur C CUDA, NVCC.
4. de la mémoire locale dédiée à chaque thread, per-thread local memory, visible
uniquement depuis la thread (cela concerne également des registres) ;
Les registres sont sur le processeur et disposent de temps d’accès très rapide.
La mémoire locale, indiquée en gris sur le schéma, dispose d’un temps d’accès
plus lent que celui des registres.
Un programme CUDA utilise des «kernels» pour traiter des «data streams», ou «flux de données».
Ces flux de données peuvent être par exemple, des vecteurs de nombres flottants, ou des ensembles de frames pour du
traitement vidéo.
5. de la mémoire partagée associée à chaque bloc visible, per-block shared memory,
uniquement par toutes les threads du bloc ;
Le bloc dispose de sa propre mémoire partagée et privée pour permettre des communications inter-thread rapides et de taille réglable.
Un «kernel» est exécuté dans le GPU en utilisant des threads exécutées en parallèles.
CUDA fournit 3 mécanismes pour paralléliser un programme :
⋄ un regroupement hiérarchique des threads ;
⋄ des mémoires partagées ;
6. de la mémoire globale, «per-device global memory», utilisable par le «device».
Une grille, «grid», utilise la mémoire globale. Cette mémoire globale permet de
communiquer avec la mémoire de l’hôte et sert de lien de communication entre
l’hôte et le GPGPU.
⋄ des barrières de synchronisation.
Ces mécanismes fournissent du parallélisme à grain fin imbriqué dans du parallélisme à gros grain.
Cours «OpenMP & Cuda» – P-F. Bonnefoi – http://p-fb.net/
—
26 février 2016 — 5
Répartition du travail entre threads regroupées en bloc
Grille & Bloc : organisation et localisation d’une thread
Le programmeur doit spécifier le nombre de threads dans un bloc et le nombre de blocs dans une grille.
Le nombre de blocs dans la grille est spécifié par la variable gridDim.
Assigner une fonction pour être exécutée par un «kernel» dans CUDA
Pour définir une fonction qui va être exécutée en tant que «kernel», le programmeur modifie le code C du prototype de la
fonction en plaçant le mot clé «__global__» devant ce prototype :
1
Exemple : un tableau à une seule dimension
⋄ on peut organiser les blocs en un tableau à une seule dimension, et le nombre de blocs sera :
gridDim.x = k,
ainsi si 𝑘 = 10, alors on aura 10 blocs dans la grille.
⋄ on peut organiser les threads en un tableau à une seule dimension de 𝑚 threads par bloc :
blockDim.x = m
⋄ chaque bloc dispose d’un identifiant unique, «ID», appelé
blockIdx qui est compris dans l’intervalle :
0≤ blockId ≤ gridDim
__global__ void kernel_function_name(function_argument_list);
La fonction doit renvoyer void.
Le programmeur doit ensuite indiquer au compilateur C NVIDIA, nvcc, de lancer le «kernel» pour être exécuté sur le
«device» :
Pour associer une thread à la ième case d’un vecteur, on doit trouver à quel bloc appartient la thread et ensuite la localisation
de la thread dans le bloc : i = blockIdx.x × blockDim.x + threadIdx.x
Généralisation du concept de Grille et de blocs
⋄ Les variables gridDim et blockIdx sont définies automatiquement et sont de type dim3.
⋄ Les blocs dans la grille peuvent être organisés suivant une, deux ou trois dimensions.
⋄ Chaque dimension est accédée par la notation blockIdx.x, blockIdx.y et blockIdx.z.
1 int main()
2
{
3
…
4
/* Une partie séquentielle du code */
5
…
6
/* Le début de la partie parallèle du code */
7
kernel_function_name<<< gridDim, blockDim >>> (function_argument_list);
8
/* La fin de la partie parallèle */
9
…
10
/* Une partie séquentielle du code */
11
}
Le programmeur modifie le code C en spécifiant la structure des blocs dans la grille et la structure des threads dans un
bloc en ajoutant la déclaration <<<gridDim, blockDim>>> entre le nom de la fonction et la liste des arguments
de la fonction.
La commande CUDA suivante définit le nombre de blocs dans les dimensions 𝑥 , 𝑦 et 𝑧 :
dim3 dimGrid(4, 8, 1) ;
Cette commande définit 32 blocs organisés en un tableau à deux dimensions avec 4 lignes de 8 colonnes.
Le nombre de threads dans un bloc est défini par la variable blockDim.
Répartition du travail entre threads regroupées en bloc
Chaque thread dispose d’un identifiant unique, «ID», appelé threadIdx qui est compris dans l’intervalle :
0≤ threadId ≤ blocDim
⋄ Les variables blockDim et threadIdx sont définies automatiquement et sont de type dim3.
⋄ Les threads dont un bloc peuvent être organisées suivant une, deux ou trois dimensions.
⋄ Chaque dimension est accédée par la notation threadIdx.x, threadIdx.y et threadIdx.z.
La commande CUDA suivante définit le nombre de threads dans les dimensions 𝑥 , 𝑦 et 𝑧 :
dim3 dimBlock(100, 1, 1);
Cette commande définit 100 threads organisées en un tableau de 100 cases.
Rapport entre «kernel» et grille
⋄ Chaque «kernel» est associé avec une grille dans le «de⋄ le choix du nombre de threads et de blocs est conditionvice».
né par la nature de l’application et la nature des données
à traiter.
Le but est d’offrir au programmeur des moyens d’organiser les threads de manière adaptée à l’organisation
des données, afin de simplifier l’accès à ces données :
«les données sont organisées en grille ? alors les
threads aussi».
Cours «OpenMP & Cuda» – P-F. Bonnefoi – http://p-fb.net/
Communication entre «l’hôte» et le «CUDA device»
▷ L’ordinateur hôte dispose de sa propre hiérarchie de mémoire de même que le «device».
▷ L’échange de données entre l’hôte et le «device» est réalisé en copiant des données entre la DRAM, «dynamic ram»,
▷ De la même façon qu’en C, le programmeur doit allouer de la mémoire dans la mémoire globale du «device» pour
et la mémoire DRAM globale du «device».
les données et libérer cette mémoire une fois l’application terminée.
Les appels systèmes CUDA suivants permettent de réaliser ces opérations :
Fonction
cudaThreadSynchronize()
cudaChooseDevice()
cudaGetDevice()
cudaGetDeviceCount()
cudaGetDeviceProperties()
cudaMalloc()
cudaFree()
cudaMemcpy()
—
Description
bloque jusqu’à ce que le «device» ait terminé les tâches demandées
précédemment
retourne le «device» qui correspond aux propriétés spécifiées
retourne le «device» utilisé actuellement
retourne le nombre de «device» capable de faire du GPGPU
retourne les informations concernant le «device»
alloue un objet dans la mémoire globale du «device».
Nécessite deux arguments : l’adresse d’un pointeur qui recevra l’adresse de
l’objet, et la taille de l’objet
libère l’objet de la mémoire globale du «device»
copie des données de l’hôte vers le «device».
Nécessite quatre arguments : le pointeur destination, le pointeur source, le
nombre d’octets et le mode de transfert.
26 février 2016 — 6
Exécution d’une application parallèle sur le «device»
La communication hôte - «device»
L’interface mémoire entre l’hôte et le «device» :
La mémoire globale en bas du schéma est le moyen de
communiquer des données entre l’hôte et le «device».
La notion de «stream» :
Le contenu de la mémoire globale est visible depuis
toutes les threads et est accessible en lecture/écriture,
celle indiquée «constant memory», n’est accessible
qu’en lecture seulement.
La mémoire partagée par bloc est visible depuis toutes
les threads de ce bloc.
La mémoire locale, comme les registres, n’est visible
que de la thread.
Exécution d’une application parallèle sur le «device»
L’hôte déclenche une fonction «kernel» :
⋄ Le «kernel» est exécuté sur une grille de blocs
⋄
⋄
⋄
⋄
⋄
de threads.
Différents «kernels» peuvent être exécutés par
le «device» à différents moments de la vie du
programme.
Chaque bloc de threads est exécuté sur un
multi-processeur à flux, «streaming multiprocessor», «SM».
Le SM exécute plusieurs blocs de threads à la
fois.
Des copies du «kernel» sont exécutées sur le
«streaming processor», «SP», ou «thread processors», ou «Cuda core», qui exécute une
thread qui évalue la fonction.
Chaque thread est allouée à un SP.
Actuellement :
▷ au plus 512 threads par block qui communiquent par mémoire partagée ;
▷ chaque dimension d’une grille doit être inférieure à 65536 ;
▷ la mémoire partagée dans un block ≃ 16𝑘𝑜 ;
▷ la mémoire constante ≃ 64𝐾𝑜 ;
▷ nombre de registres disponibles par block 8192 à 16384.
Cours «OpenMP & Cuda» – P-F. Bonnefoi – http://p-fb.net/
temps
Ici, la carte GPGPU est capable de supporter plusieurs streams, mais offre un accès séquentielle pour les transferts
des données de l’hôte vers la carte, «Copy To device».
En général, un seul stream est possible sur des cartes grand public.
La notion de «warp»
Soit le programme suivant et sa parallélisation :
1
2
3
4
5
6
void some_func(void)
{
int i;
for (i=0;i<128;i++)
{ a[i] = b[i] * c[i]; }
}
On parallélise la boucle en créant une thread par occurence de la
boucle :
1
2
3
__global__ void some_kernel_func(int *a, int *b, int *c)
{ unsigned int thread_idx = threadIdx.x;
a[thread_idx] = b[thread_idx] * c[thread_idx]; }
Un «warp»
▷ correspond à 32 threads ;
▷ exécute du code SPMD, ou SPMT, «Simple Program Multiple Thread», ⇒
▷ est ordonnancé, scheduled, dans le SP, suivant son état :
Le «Warp 0» progresse de «Ready Queue» à «Executing».
—
Le «Warp 0» réalise des demandes d’accès mémoire et se suspend : c’est le «Warp 1» qui s’exécute.
26 février 2016 — 7
La notion de «warp»
Synchronisation & Communication
Le «scheduler» fait progresser le warp de l’état prêt, «Ready Queue», à l’état exécuté, «Executing».
Dans le cas où le warp réclame du contenu dans la mémoire : il passe en «Suspended» et des accès mémoires attendent
d’être résolus : «Memory Request Pending».
Lorsqu’un programme parallèle est exécuté sur le «device», la synchronisation et les communications parmi les threads
doivent être réalisées à différents niveaux :
1. «Kernels» et «grids» ;
2. Blocs ;
3. Threads.
Grille & Kernels
Différents «kernels» peuvent être exécutés sur le «device».
1
2
3
4
5
Toutes les threads sont bloquées en attente du retour des
données depuis la mémoire...
Les données 0 à 63 sont obtenues : les threads 0 à 31
sont exécutées et les threads 32 à 63 sont prêtes.
void main() {
…
kernel_1<<<nblocks_1, blocksize_1>>>(fonction_argument_liste_1)
kernel_2<<<nblocks_2, blocksize_2>>>(fonction_argument_liste_2);
…
⋆ kernel_1 va être exécuté en premier sur le «device» :
⋄ il va définir une grille qui contiendra dimGrid blocs, chacun de ces blocs contiendra dimBlock threads.
⋄ toutes les threads vont exécuter le même code spécifié par le «kernel».
⋆ lorsque kernel_1 aura terminé, alors kernel_2 sera transmis vers le «device» pour son exécution.
Attention
Le communication entre les différentes grilles est indirecte : elle consiste à laisse en place les données dans l’hôte
ou la mémoire globale du «device» pour être utilisées par le prochain «kernel».
Exécution d’une application parallèle sur le «device»
Synchronisation & Communication
La notion de divergence
Les blocs
1 __global__ some_func(void)
2 {
3
if (some_condition)
4
{
5
action_a(); // +
6
}
7
else {
8
action_b(); // 9
}
10 }
Imaginons que les threads paires réalisent le travail positif et les threads impaires le travail négatif par rapport à la condtion :
Le Warp exécute du code SPMT : les threads «+» s’exécutent pendant que les
autres sont bloquées.
Une solution : l’association par demi warp, soient 16 threads :
1
2
3
4
5
6
7
if ((thread_idx % 32) < 16)
{
action_a();
}
else {
action_b();
}
On bénéficie
⋆ d’une exécution paralléle de la partie «+» et «−» sur chacun des demi-warps en SPMT ;
⋆ éventuellement d’accès mémoire sur 4 ∗ 16 = 64 octets pour les données sur 32bits utilisées par le demi-warp ;
⋆ Tous les blocs d’une grille s’exécutent indépendamment les uns des autres : il n’y a pas de mécanisme de syn⋆ lorsqu’une grille est lancée, les blocs sont assignés à un SM, dans un ordre arbitraire qui n’est pas prédictible.
chronisation entre les blocs.
Les communications entre les threads à l’intérieur d’un bloc sont réalisées au travers de la mémoire partagée du
bloc :
⋄ une variable est déclarée comme étant partagée par les threads du même bloc en préfixant sa déclaration à l’aide
du mot-clé «__shared__».
Cette variable est alors stockée dans la mémoire partagée du bloc.
⋄ lors de l’exécution d’un «kernel», une version privée de cette variable est créée dans la mémoire locale de la
thread.
Des temps d’accès mémoire différents
⋄ La mémoire partagée associée au bloc est sur la même puce que les cœurs, «cores», exécutant les threads ;
La communication est relativement rapide, car la SRAM, «Static RAM», est plus rapide que la mémoire située
en dehors de la puce, «off-chip» de type DRAM ;
⋄ chaque thread dispose d’un accès direct à ses registres inclus dans la puce et d’un accès direct à sa mémoire
locale qui est en «off-chip». Les registres sont beaucoup plus rapides que la mémoire locale ;
⋄ chaque thread peut également avoir accès à la mémoire globale du «device» ;
⋄ l’accès d’une thread à la mémoire locale et globale souffre des problèmes inhérents aux communications entre
puces, «interchip» : retard, consommation de puissance, débit.
Cours «OpenMP & Cuda» – P-F. Bonnefoi – http://p-fb.net/
—
26 février 2016 — 8
Synchronisation & Communication
Stratégie de développement
Les threads et le SIMD : la notion de «warp»
Un grand nombre de threads sont exécutées sur le «device».
Un bloc qui est assigné à un SM est divisé en groupe de 32 threads warps. Chaque warp représente le «SIMD» du GPU.
Chaque SM peut gérer plusieurs «warps» simultanément, et lorsque certains «warps» sont bloqués à cause d’accès à la mémoire,
le SM peut ordonnancer l’exécution d’un autre «warp» (suivant un scheduler).
⋄ Les threads d’un même bloc peuvent se synchroniser à l’aide de la fonction __syncthreads(), réalisant une barrière
⋆ la mémoire globale réside dans la mémoire globale du «device» en DRAM (plus lente) ;
⋆ il faut partitionner les données pour tirer parti de la mémoire partagée plus rapide :
⋄ utiliser la technique vu sur le transparent précédent ;
⋄ utiliser une méthode «divide and conquer»
Partitionner les données en différents sous-ensembles tenant
dans la mémoire partagée
⋄ une thread peut utiliser une opération atomique pour obtenir l’accès exclusif à une variable pour un réaliser une opération
de synchronisation entre les différents warps exécutés.
⋄ Chaque thread utilise ses registres et sa mémoire locale, qui utilise tous les deux de la SRAM, ce qui induit une petite
donnée : atomicAdd(result, input[threadIdx.x]) coûteux en synchronisation.
⋄ Chaque thread peut également utiliser la mémoire globale qui est plus lente car elle utilise de la DRAM.
quantité de mémoire disponible, mais des communications rapides et peut coûteuse en énergie.
La répartition des variables entre les différentes zones mémoire
Gérer chaque partition à l’aide d’un bloc de threads
Charger chaque partition de la mémoire globale vers la mémoire partagée et exploiter plusieurs threads pour exploiter
du parallélisme de données
Calculer sur la partition depuis la mémoire partagée.
Copier le résultat de la mémoire partagée vers la mémoire
globale.
Pour définir la localisation, on utilise des préfixe pour la déclaration ou des règles automatiques.
Déclaration
int var;
int tableau[10];
__shared__ int var;
__device__ int var;
__constant__ int const;
Lieu de stockage de la variable
un registre de la thread
la mémoire locale de la thread
la mémoire partagée du bloc
la mémoire globale du «device»
la mémoire constante du bloc.
Pénalité
plus lent
plus lent
Portée
thread
thread
bloc
grille
grille
Lifetime
thread
thread
bloc
application
application
Les variables __constant__ et __device__ sont à déclarer en dehors de toute fonction.
Optimisation de la vitesse en localisant mieux les données
Exemple de code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Calcul de différence de données adjacentes
// calculer result[i] = input[i] – input[i-1]
__device__ int result[N];
__global__ void adj_diff_naive(int *result, int *input)
{
// calculer l'identifiant de la thread en fonction
// de sa position dans la grille
unsigned int i = blockDim.x * blockIdx.x + threadIdx.x;
if(i > 0)
{
// Utiliser des variables locales à la thread
int x_i = input[i];
int x_i_minus_one = input[i-1];
// Deux accès sont nécessaires pour i et i-1
result[i] = x_i – x_i_minus_one;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// version optimisée
__device__ int result[N];
__global__ void adj_diff(int *result, int *input)
{
// raccourci pour threadIdx.x
int tx = threadIdx.x;
// allouer un __shared__ tableau,
// un élément par thread
__shared__ int s_data[BLOCK_SIZE];
// chaque thread lit un élément dans s_data
unsigned int i = blockDim.x * blockIdx.x
+ tx;
s_data[tx] = input[i];
// éviter une race condition: s'assurer
// des chargements avant de continuer
__syncthreads();
if(tx > 0)
result[i] = s_data[tx] – s_data[tx–1];
else if(i > 0)
{ // traiter les accès aux bords du bloc
result[i] = s_data[tx] – input[i-1];
}
Cette optimisation se traduit par une nette amélioration des performances : 36, 8𝐺𝐵/𝑠 vs
57.5𝐺𝐵/𝑠 .
}
Cours «OpenMP & Cuda» – P-F. Bonnefoi – http://p-fb.net/
Une exemple complet : calcul d’une fractale, «l’ensemble de Julia»
Dessiner un ensemble de Julia
On parcours une surface 2D :
⋆ pour chaque point de coordonnées (𝑥, 𝑦), on traduit ses coordonnées dans l’espace complexe :
⋄ Soit 𝐷𝐼𝑀 × 𝐷𝐼𝑀 la dimension de la surface en pixels ;
⋄ on centre cet espace complexe au centre de l’image, en décalant de 𝐷𝐼𝑀/2 ;
⋄ ce qui donne pour un point (𝑥, 𝑦), les coordonnées
((𝐷𝐼𝑀/2 − 𝑥)/(𝐷𝐼𝑀/2), (𝐷𝐼𝑀/2 − 𝑦)/(𝐷𝐼𝑀/2))
⋆ on utilise également un «zoom» avec la variable 𝑠𝑐𝑎𝑙𝑒 pour zoomer ou dézoomer sur l’ensemble de Julia ;
⋆ la constante 𝐶 = −0.8 + 1.5𝑖 donne une image intéressante.
⋆ on considère le complexe 𝑍0 = ((𝐷𝐼𝑀/2 − 𝑥)/(𝐷𝐼𝑀/2)) + ((𝐷𝐼𝑀/2 − 𝑦)/(𝐷𝐼𝑀/2))𝑖
⋆ on calcule alors pour ce complexe 𝑍0 , la limite de la suite 𝑍u�+1 = 𝑍u�2 + 𝐶 :
⋄ si la limite de la suite diverge ou tend vers l’infini, alors le point n’appartient pas à l’ensemble et il sera coloré
⋄ si la limite de la suite ne diverge pas, alors le point fait partie de l’ensemble et sera coloré en rouge.
en noir ;
⋆ pour la limite, on utilise une simplification : si après 200 itérations de calcul de la suite, la valeur au carré de la
suite est toujours inférieure à 1000 alors on considère qu’elle ne diverge pas.
—
26 février 2016 — 9
Un exemple complet
Un exemple complet
Version CPU
1 int main( void ) {
2
CPUBitmap bitmap( DIM, DIM ); // DIM = 1000
3
unsigned char *ptr = bitmap.get_ptr();
4
kernel( ptr );
5
bitmap.display_and_exit();
6 }
1 int julia( int x, int y ) {
2
const float scale = 1.5;
3
float jx = scale * (float)(DIM/2 - x)/(DIM/2);
4
float jy = scale * (float)(DIM/2 - y)/(DIM/2);
5
6
cuComplex c(-0.8, 0.156);
7
cuComplex a(jx, jy);
8
int i = 0;
9
for (i=0; i<200; i++) { // Calcul de la limite
10
a = a * a + c;
11
if (a.magnitude2() > 1000) // Infini ?
12
return 0; // Pas dans l'ensemble
13
}
14
return 1; // Dans l'ensemble
15 }
1 void kernel( unsigned char *ptr ){
2 for (int y=0; y<DIM; y++) {
3
for (int x=0; x<DIM; x++) {
4
int offset = x + y * DIM; // 2D -> 1D
5
6
int juliaValue = julia( x, y );
7
ptr[offset*4 + 0] = 255 * juliaValue; // Red
8
ptr[offset*4 + 1] = 0; // Green
9
ptr[offset*4 + 2] = 0; // Blue
10
ptr[offset*4 + 3] = 255; } // Alpha
11
}
12 }
1 struct cuComplex { // Opérations sur les complexes
2
float r, i;
3
cuComplex( float a, float b ) : r(a), i(b) {}
4
float magnitude2( void ) { return r * r + i *
5 i;}
6
cuComplex operator*(const cuComplex& a) {
7
return cuComplex(r*a.r - i*a.i, i*a.r + r*a.i);
8
}
9
cuComplex operator+(const cuComplex& a) {
10
return cuComplex(r+a.r, i+a.i);
11
}
12 };
Un exemple complet
Version GPU
1
2
3
4
5
6
7
8
9
10
11
1
2
3
4
5
6
7
8
9
10
11
12
int main( void ) {
CPUBitmap bitmap( DIM, DIM );
unsigned char *dev_bitmap;
HANDLE_ERROR( cudaMalloc( (void**)&dev_bitmap, bitmap.image_size() ) ); // On alloue la zone de l'image
dim3 grid(DIM,DIM); // On utilise dim3 même si la dernière coordonnée vaudra seulement 1
kernel<<<grid,1>>>( dev_bitmap ); // Un bloc par pixel et une thread par bloc
HANDLE_ERROR( cudaMemcpy( bitmap.get_ptr(), dev_bitmap, bitmap.image_size(),
cudaMemcpyDeviceToHost ) );
bitmap.display_and_exit();
cudaFree( dev_bitmap );
}
__global__ void kernel( unsigned char *ptr ) {
1 __device__ int julia( int x, int y ) {
// map from threadIdx/BlockIdx to pixel position 2
const float scale = 1.5;
int x = blockIdx.x; // Calculé automatiquement
3
float jx = scale * (float)(DIM/2 - x)/(DIM/2);
int y = blockIdx.y; // Calculé automatiquement
4
float jy = scale * (float)(DIM/2 - y)/(DIM/2);
int offset = x + y * gridDim.x;
5
cuComplex c(-0.8, 0.156); cuComplex a(jx, jy);
// now calculate the value at that position
6
int i = 0;
int juliaValue = julia( x, y );
7
for (i=0; i<200; i++) {
ptr[offset*4 + 0] = 255 * juliaValue;
8
a = a * a + c;
ptr[offset*4 + 1] = 0;
9
if (a.magnitude2() > 1000)
ptr[offset*4 + 2] = 0;
10
return 0;
ptr[offset*4 + 3] = 255;
11
}
}
12
return 1;
13 }
Cours «OpenMP & Cuda» – P-F. Bonnefoi – http://p-fb.net/
1 struct cuComplex {
2
float r, i;
3
cuComplex( float a, float b ) : r(a), i(b) {}
4
__device__ float magnitude2( void )
5
{
6
return r * r + i * i;
7
}
8
__device__ cuComplex operator*(const cuComplex& a)
9
{
10
return cuComplex(r*a.r - i*a.i, i*a.r + r*a.i);
11
}
12
__device__ cuComplex operator+(const cuComplex& a)
13
{
14
return cuComplex(r+a.r, i+a.i);
15
}
16 };
Les fonctions préfixées par __device__ :
⋄ tournent sur le GPU ;
⋄ ne peuvent être appelées que depuis une fonction préfixée par __device__ ou __global__.
Le code complet est accessible sur http://developer.nvidia.com/cuda-cc-sdk-code-samples
Extraction du parallèlisme de données et la définition de «grille»
Parcours imbriqué d’un tableau en version CPU
1
2
3
4
5
6
7
8
9
10
11
12
void add_matrix ( float* a, float* b, float* c, int N )
{
int index;
for ( int i = 0; i < N; ++i )
for ( int j = 0; j < N; ++j ) {
index = i + j*N;
c[index] = a[index] + b[index];
}
}
int main() {
add_matrix( a, b, c, N );
}
Ici, la matrice est définie suivant un tableau à
une seule dimension.
Parcours imbriqué d’un tableau en version GPU
1
2
3
4
5
6
7
8
9
10
11
12
13
14
__global__ add_matrix ( float* a, float* b, float* c,
int N )
{
int i = blockIdx.x * blockDim.x + threadIdx.x;
int j = blockIdx.y * blockDim.y + threadIdx.y;
int index = i + j*N;
if ( i < N && j < N )
c[index] = a[index] + b[index];
}
int main() {
dim3 dimBlock( blocksize, blocksize );
dim3 dimGrid( N/dimBlock.x, N/dimBlock.y );
add_matrix<<<dimGrid, dimBlock>>>( a, b, c, N );
}
—
On «déplie» les deux boucles imbriquées en une
grille, où chaque élément de la grille définie une
combinaison de valeurs pour i et j.
26 février 2016 — 10
Exemple de programme type
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const int N = 1024;
const int blocksize = 16;
__global__ void add_matrix( float* a, float *b, float *c, int N )
{ int i = blockIdx.x * blockDim.x + threadIdx.x;
int j = blockIdx.y * blockDim.y + threadIdx.y;
int index = i + j*N;
if ( (i < N) && (j < N) ) c[index] = a[index] + b[index];
}
int main() {
float *a = new float[N*N]; float *b = new float[N*N]; float *c = new float[N*N];
for ( int i = 0; i < N*N; ++i ) { a[i] = 1.0f; b[i] = 3.5f; }
float *ad, *bd, *cd;
const int size = N*N*sizeof(float);
cudaMalloc( (void**)&ad, size );
cudaMalloc( (void**)&bd, size );
cudaMalloc( (void**)&cd, size );
cudaMemcpy( ad, a, size, cudaMemcpyHostToDevice );
cudaMemcpy( bd, b, size, cudaMemcpyHostToDevice );
dim3 dimBlock( blocksize, blocksize );
dim3 dimGrid( N/dimBlock.x, N/dimBlock.y );
add_matrix<<<dimGrid, dimBlock>>>( ad, bd, cd, N );
cudaMemcpy( c, cd, size, cudaMemcpyDeviceToHost );
cudaFree( ad ); cudaFree( bd ); cudaFree( cd );
delete[] a; delete[] b; delete[] c;
return EXIT_SUCCESS;
UniqueBlockIndex = blockIdx.x;
UniqueThreadIndex = blockIdx.x * blockDim.x * blockDim.y + threadIdx.y * blockDim.x + threadIdx.x;
Une grille 1D avec des blocs en 3D
UniqueBlockIndex = blockIdx.x;
UniqueThreadIndex = blockIdx.x * blockDim.x * blockDim.y * blockDim.z
+ threadIdx.z * blockDim.y * blockDim.x + threadIdx.y * blockDim.x + threadIdx.x;
Une grille 2D avec des blocs en 3D
UniqueBlockIndex = blockIdx.x;
UniqueThreadIndex = blockIdx.x * blockDim.x * blockDim.y * blockDim.z
+ threadIdx.z * blockDim.y * blockDim.x + threadIdx.y * blockDim.x + threadIdx.x;
Une grille 2D avec des blocs 1D
UniqueBlockIndex = blockIdx.y * gridDim.x + blockIdx.x;
UniqueThreadIndex = UniqueBlockIndex * blockDim.x + threadIdx.x;
Un grille 2D avec des blocs 2D
UniqueBlockIndex = blockIdx.y * gridDim.x + blockIdx.x;
UniqueThreadIndex =UniqueBlockIndex * blockDim.y * blockDim.x + threadIdx.y * blockDim.x + threadIdx.x;
Une grille 2D avec des blocs 3D
UniqueBlockIndex = blockIdx.y * gridDim.x + blockIdx.x;
UniqueThreadIndex = UniqueBlockIndex * blockDim.z * blockDim.y * blockDim.x
+ threadIdx.z * blockDim.y * blockDim.x + threadIdx.y * blockDim.x + threadIdx.x;
Architecture Fermi
Optimisation
Les optimisations possibles
u�u�u�u�u�u� u�u� u�u�u�u�u�u�
u�u�u�u�u�u� u�u� u�u�
u�u�u�u�u�u� u�u� u�u�u�u�u�u�
u�u�u�u�u�u� u�u� u�u�
> 1 ⟹ tous les MPs ont toujours quelque chose à faire ;
> 2 ⟹ plusieurs blocks peuvent être exécutés en paralléle sur un MP ;
▷ les ressources par block ≤ au total des ressources disponibles :
⋄ mémoire partagée et registres ;
⋄ plusieurs blocks peuvent être exécutés en paralléle sur un MP ;
⋄ éviter les branchements divergents dans les conditions à l’intérieur d’un block :
⋆ les différents chemins d’exécution doivent être sérialisés !
Cours «OpenMP & Cuda» – P-F. Bonnefoi – http://p-fb.net/
512 «cuda cores» orgnisés en 16 SM de 32
cores chacun.
??
32 «cuda cores» par SM, «Streaming Multiprocessor».
Un «cuda core» correspond à un circuit capable de réaliser un calcul en simple précision, FPU, ou sur un entier, ALU (opérations
binaire comprises), en 1 cycle d’horloge.
Une unité «Special Function Unit» exécute
les opérations transcendentales comme le 𝑠𝑖𝑛 ,
𝑐𝑜𝑠 , 𝑠𝑞𝑟𝑡 , 𝑎𝑟𝑐𝑐𝑜𝑠 , etc.
Il en existe 4 : seuls 4 opérations de ce type
peuvent avoir lieu simultanément.
Le GPU dispose de 6 partition mémoire de 64
bits, soient 16 unités sur 32bits.
16 unités «Load/Store» permettent de calculer et d’accéder en écriture et en lecture des
données pour 16 threads par cycle d’horloge.
—
COMPLÉMENT
COMPLÉMENT
▷ un GPU possède 𝑁 multiprocesseur, MP, en général 2 ou 4 ;
▷ chaque MP possède 𝑀 processeur scalaire, SP ;
▷ chaque MP traite des blocks par lot :
⋄ un block est traité par un MP uniquement ;
▷ chaque block est découpé en groupe de threads SIMD appelé warp :
⋄ un warp est exécuté physiquement en parallèle ;
▷ le scheduler « switche» entre les warps ;
▷ un warp contient des threads d’identifiant consécutif, «thread ID» ;
▷ la taille d’un warp est actuellement de 32 ;
▷
Une grille 1D avec des blocs en 2D
}
Les contraintes d’architecture
▷
Les différents accès à la grille
26 février 2016 — 11
Architecture Fermi
Deux «Warp Schedulers» permettent
de programmer l’exécution de deux
Warps indépendants simultanément :
− seuls les warps capables de
s’exécuter peuvent être exécutés
simultanément ;
cution d’une instruction d’un
Warp vers un groupe de 16
cores/16 LD/ST or 4 SFUs.
− la plupart des instructions peuvent
être émises par deux : deux instructions entières, deux instructions flottantes, un mix d’entier
de flottant, lecture, écriture et
opération transcendentale.
Mais les instructions en double
précision ne peuvent pas être
émises par deux.
Architecture Fermi
MAD, «multiply-add» : une opération de
multiplication et d’addition combinée,
mais avec perte des bits dépassant la capacité.
L’utilisation de calcul en double précision permet 16 calcul de type MAD par
SM et par cycle d’horloge.
Cours «OpenMP & Cuda» – P-F. Bonnefoi – http://p-fb.net/
??
COMPLÉMENT
COMPLÉMENT
FMA, «fused multiply-add» : réalise les
mêmes opérations mais sans perte, en
simple ou double précision.
COMPLÉMENT
COMPLÉMENT
− un scheduler programme l’exé-
??
—
26 février 2016 — 12