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