• Aucun résultat trouvé

Architecture Nvidia et CUDA

5.6 Un Code, Plusieurs Architectures

5.6.2 Architecture Nvidia et CUDA

Selon le guide programmation de Nvidia32, CUDA33 est une extension du langage de program-

mation C permettant aux programmeurs de définir des fonctions en C et d’appeler des noyaux d’applications aussi appelés kernel qui sont exécutés N fois en parallèle à travers N proces- sus logiques CUDA sur une carte graphique. Un noyau d’application est défini par le mot clé __global__, ce mot doit être placé en amont de la déclaration d’une fonction. Les fonctions qui sont uniquement destinées à fonctionner sur la carte graphique ont le mot clé __device__. La configuration du parallélisme et la structuration de l’algorithme sur la grille de calcul sont données en entrée lors de l’appel de cette fonction par la syntaxe <<<. . .>>>. Chaque proces- sus logique qui exécute le noyau d’application est attribué par une unique identité d’indexation accessible par la variable syntaxique threadIdx.

5.6.2.1 Processus Logique

Par convention, threadIdx est un vecteur en 3 dimensions donc chaque processus logique peut être identifié selon une indexation en une, deux ou trois dimensions formant des blocs de processus

32GPU Programming Guide, 2006, disponible à l’adresse suivante: https://developer.download.nvidia.com/

GPU_Programming_Guide/GPU_Programming_Guide.pdf

33http://docs.nvidia.com/cuda/, on pourra retrouver des exemples de programmation dans Sanders et Kan-

en une, deux ou trois dimensions. Les blocs regroupent un paquet de processus logiques, sur les premières générations de GPU un bloc ne pouvait contenir que 512 processus alors que sur les dernières générations, un bloc contient jusqu’à 1024 processus. Il est possible de générer une grille de 65 536 blocs en une, deux ou trois dimensions, voir figure 5.12 de représentation d’une grille . L’identité d’un bloc est accessible via le mot clé blockId. L’indexation globale d’un processus logique est retrouvé à partir d’un peu d’arithmétique. Par exemple, sur un bloc en

trois dimensions de paramètres (nx, ny, nz) sur une grille à une dimension (nu), l’indexation est

définie par la relation suivante:

unxnynz+ x + ynx+ znxny. (5.5)

Pour une grille en 3 dimensions de paramètres (nu, nv, nw) avec des blocs en 3 dimensions:

nxnynz(u + vnu+ wnunv) + x + ynx+ znxny. (5.6)

Ce choix est laissé à l’utilisateur en fonction de son confort et de la structure de l’algorithme mais cela procure une façon naturelle de calquer les processus logiques aux éléments de calcul vectoriel, matriciel ou tensoriel. Le choix de la représentation de la grille ne joue pas en rôle en terme de performance. Les processus logiques au sein d’un même bloc sont exécutés simultanément

Figure 5.12: Réprésentation de la grille découpée en blocs sous-découpés en processus logiques.

(conceptuellement et dans la limite physique du nombre d’unité de calcul disponible) mais les processus de différents blocs sont complètement indépendants et reposent sur des mémoires indépendantes. Une programmation optimisée demandera de connaître le nombre de processeurs de la machine pour profiter au plus de ses spécificités.

5.6. Un Code, Plusieurs Architectures

5.6.2.2 Structure Mémorielle

Les processus logiques d’une carte graphique peut avoir accès à une mémoire multi-niveaux pendant leurs exécutions. Chaque processus a sa propre région privée de mémoire accessible uniquement par lui-même. Chaque bloc dispose d’une mémoire partagée auquel tous les processus de ce bloc ont accès mais pas ceux d’un autre bloc. Tous les processus ont accès à la mémoire globale. Il y a en addition trois zones de mémoire spécifiques; la mémoire constante, locale et de texture. Ces trois mémoires sont des espaces optimisés pour des usages différents. Par exemple, la mémoire de texture a un adressage spécial pour des formats de données spécifiques. Les mémoires globales, constante et de texture sont persistantes pendant l’exécution du noyau d’application alors que les mémoires partagées, locales et registres sont éphèmères. On peut retrouver sur la figure 5.13 un schéma représentatif de l’architecture mémorielle d’une carte graphique.

Figure 5.13: Réprésentation hiérarchique des différentes mémoires sur une carte graphique.

Plus précisément, ces mémoires ont d’autres caractéristiques: la mémoire globale et locale sont sur la carte graphique et accessibles par paquet de 32, 64 ou 128 bit (paramètre à prendre en compte pour optimiser les accès mémoire). Ces accès mémoires sont alignés et très lents, voir Barlas [12] et Kirk et Hwu [113]. La mémoire partagée et les registres sont placés directement sur la puce donc ils disposent d’accès très rapide. Les accès à la mémoire partagée sont très rapide s’il n’y a pas de conflits d’indexation entre les banques de mémoires et les processus logiques. La mémoire constante et de texture résident sur la carte graphique et sont accessibles par l’hôte ainsi que la mémoire globale. Les accès sur les deux premières sont plus rapides que ceux à la mémoire globale.

5.6.2.3 Caractéristiques

Une spécificité à prendre en compte des cartes graphiques est qu’elles sont hautement optimisées dans la gestion et le calcul des nombres à virgule flottante en simple précision. Le calcul double précision est encore à la traîne, par exemple la Nvidia Tesla K10 développe 4.58 teraFLOPs en simple précision mais seulement 0.19 teraFlops en double précision. Sur les architectures plus récentes, le retard commence à être comblé mais n’exhibe toujours pas un rapport de 1 à 2 avec le calcul simple précision. Un des gros inconvénients du calcul sur carte graphique est le temps de copie des données entre l’hôte et celle-ci. Ce point important doit être pris en compte lors de présentation de performance entre deux dispositifs différents.

Les spécifications des langages de programmation CUDA et OpenCL n’en disent pas beaucoup sur l’architecture sur lequel se repose ces interfaces. Or il est primordial pour le développeur de connaître les caractéristiques de la machine à un très bas niveau pour pouvoir produire un code très efficient. Les spécificités des cartes Nvidia dépendent énormément de l’attribut “capability”. Par exemple, les cartes graphiques qui ont une capability en dessous de 3.0 ne peuvent pas faire

de l’I/O34. Les cartes graphiques qui ont une capability 3.5 sont capables de faire du parallélisme

dynamique, ce qui permet à un kernel ou une application exécutée sur la carte de lancer un autre kernel avec une configuration de grille indépendante de la première.

Nvidia se réfère a ces unités de calcul en tant que “multithreaded streaming processeurs”, c’est-à-dire à des multiprocesseurs capable de gérer plusieurs processus logiques en même temps.

Ces unités de calculs comportent une architecture dite SIMT35où une instruction pour plusieurs

processus simultanément. Chaque multiprocesseurs est capable d’exécuter des fils d’instructions en même temps pour plusieurs processus logiques. Les processus d’un bloc s’exécute simultané- ment sur le même multiprocesseur et plusieurs blocs peuvent être gérés simultanément par un multiprocesseur. Chaque bloc est partitionné en warp ou grappe. Sur les machines contempo- raines, chaque grappe est composée de 32 processus logiques indexés de façon consécutives et par ordre croissant au sein d’un même bloc. C’est une composante importante à connaître lors de l’implémentation d’une solution, on prendra par exemple un multiple de 32 comme taille de dimension des processus logiques par bloc. On peut omettre la synchronisation des processus si on est sûr qu’il exécute tous la même instruction en même temps.

Chaque processus logique d’une grappe a son propre fil d’instruction et son registre de telle sorte qu’il peut faire des opérations indépendantes des autres processus appartenant à la même grappe. L’état de chaque grappe est stocké directement sur la puce pendant le temps de vie de celui-ci. Une grappe exécute les instructions d’un code une à la fois et durant chaque cycle, un gestionnaire de grappe en sélectionne une qui est prête à exécuter la prochaine instruction. Les processus évoluent de façon “symétrique” , c’est-à-dire qu’ils sont tous à peu près au même point chronologique de l’application à exécuter.

En cas de divergence de fil d’instruction au sein d’une grappe entre un ou plusieurs processus logiques, les différents fils sont exécutés séquentiellement. Les processus logi- ques qui n’exécutent pas l’instruction sont désactivés au moment du passage sur le multiprocesseur. Par exemple, dans le cas d’un processus de branchement par une condition if, en supposant que les 16 premiers processus effectuent l’instruction si elle est remplie et les autres le cas contraire. La chronologie sera condition remplie puis condition non remplie ce qui prendra plus de cycle que s’ils avaient eu tous la même instruction à exécuter. Pour une implémentation performante, il faudra éviter au plus les processus de branchement. Les ressources d’une unité de calcul sont partitionnées entre les différents blocs qui sont exécutés simultanément, ce qui limite donc le nombre de bloc

34Lecture et écriture en sortie de terminal 35Single instruction, multiple threads.

5.6. Un Code, Plusieurs Architectures

sur le multiprocesseur. En général, ce nombre se limite à 8 blocs.

Une des plus gros contraintes de la programmation sur carte graphique est que le processus de débogage, qui est pratiquement inexistant et il y a beaucoup moins de vérifications sur le calcul que sur un CPU. La latence qui est le nombre de cycle d’horloge que peut prendre une grappe avant d’être prête à exécuter la prochaine instruction a un gros impact en terme de performance. Une performance maximum est atteinte seulement si le gestionnaire de grappe émet une instruction pour une grappe par cycle. Les accès en mémoire provoquent de la latence, en particulier si la mémoire globale est constamment sollicitée. Ce qui peut être équivalent à 400-600 cycles d’horloge alors que l’accès à la mémoire partagée n’en prend que 40.

Grâce à une analyse poussée du code généré à l’aide de certaines options de compilation, en examinant le fichier .ptx et les différentes tailles de mémoire utilisées, on peut déduire une con- figuration optimale de la grille pour maximiser le pourcentage d’occupation des multiprocesseurs.

5.6.2.4 Exemple de Code _ _ g l o b a l _ _ v o i d v e c t o r M u l S c a l a r ( f l o a t * A , f l o a t c ) { int idx = b l o c k I d x . x * b l o c k D i m . x + t h r e a d I d x . x ; A [ idx ] = A [ idx ] * c ; } int m a i n ( vo i d ) { int nT = 4 , nB = 2 , n = nT * nB ; int t = n * s i z e o f( f l o a t ); f l o a t * hA = ( f l o a t* ) m a l l o c ( t ) , * dA , c = 0 . 5 ; int t = n * s i z e (f l o a t); for( int i =0; i < n ; ++ i ) hA [ i ] = (f l o a t) i ; c u d a M a l l o c ( (v o i d**) & dA , t ); c u d a M e m c p y ( dA , hA , t , c u d a M e m c p y H o s t T o D e v i c e ); v e c t o r M u l S c a l a r < < < nB , nT > > >( dA , c ); c u d a M e m c p y ( hA , dA , t , c u d a M e m c p y D e v i c e T o H o s t ); c u d a F r e e ( dA ); fr e e ( hA ); r e t u r n 0; }

Code 5.7: Code CUDA permettant la multiplication d’un vecteur par un scalaire.

Documents relatifs