Atomicité
Transcription
Atomicité
9 Atomicité Dans la première partie de ce livre, nous avons rencontré de nombreuses occasions où un traitement difficile à effectuer avec une application monothread devenait assez simple en CUDA C. Grâce au travail que réalise CUDA en coulisse, nous n’avons plus besoin de boucles for pour modifier chaque pixel d’une animation ou d’une simulation thermique, par exemple. De même, les milliers de blocs et de threads parallèles créés peuvent être automatiquement énumérés avec des indices en appelant simplement une fonction __global__ à partir du code hôte. En revanche, certaines situations extrêmement simples à traiter avec des applications monothreads posent un sérieux problème lorsque l’on essaie d’implémenter le même algorithme sur une architecture massivement parallèle. Nous étudierons donc dans ce chapitre quelques cas où l’on doit utiliser des primitives spéciales pour accomplir des opérations pourtant triviales dans une application traditionnelle avec un seul thread. Objectifs du chapitre Au cours de ce chapitre : ✓✓ Vous découvrirez les possibilités de calcul des différents GPU NVIDIA. ✓✓ Vous apprendrez ce que sont les opérations atomiques et pourquoi elles peuvent être nécessaires. ✓✓ Vous apprendrez à réaliser des traitements arithmétiques avec des opérations atomiques dans vos noyaux CUDA C. © 2011 Pearson Education France – CUDA par l'exemple – Jason Sanders, Edward Kandrot Cuda-Livre.indb 143 26/04/11 12:44 144 CUDA par l’exemple Possibilités de calcul Tous les sujets que nous avons déjà abordés impliquaient certaines possibilités de calcul des GPU CUDA. Chaque GPU reposant sur l’architecture CUDA, par exemple, peut lancer des noyaux, accéder à la mémoire globale et lire dans les mémoires de données constantes et de texture. Mais, tout comme les différents modèles de CPU ont des possibilités et des jeux d’instructions différents (MMX, SSE ou SSE2, par exemple), les processeurs graphiques ont, eux aussi, leurs spécificités. NVIDIA appelle possibilités de calcul les fonctionnalités supportées par un GPU. Possibilités de calcul des GPU NVIDIA À l’heure où nous mettons sous presse, les versions des possibilités de calcul des GPU NVIDIA sont 1.0, 1.1, 1.2, 1.3 ou 2.0. Les versions supérieures sont des surensembles des versions inférieures, ce qui forme une hiérarchie en "poupées russes" : un GPU avec une possibilité de calcul de 1.2, par exemple, dispose de toutes les fonctionnalités des possibilités de calcul 1.0 et 1.1. Le NVIDIA CUDA Programming Guide contient la liste à jour de tous les GPU CUDA avec leurs possibilités de calcul correspondantes – le Tableau 9.1 énumère les GPU disponibles au moment où ce livre est écrit. Tableau 9.1 : GPU CUDA avec leurs possibilités de calcul correspondantes GPU Possibilités de calcul GeForce GTX 480, GTX 470 2.0 GeForce GTX 295 1.3 GeForce GTX 285, GTX 280 1.3 GeForce GTX 260 1.3 GeForce 9800 GX2 1.1 GeForce GTS 250, GTS 150, 9800 GTX, 9800 GTX+, 8800 GTS 512 1.1 GeForce 8800 Ultra, 8800 GTX 1.0 GeForce 9800 GT, 8800 GT, GTX 280M, 9800M GTX 1.1 GeForce GT 130, 9600 GSO, 8800 GS, 8800M GTX, GTX 260M, 9800M GT 1.1 GeForce 8800 GTS 1.0 GeForce 9600 GT, 8800M GTS, 9800M GTS 1.1 GeForce 9700M GT 1.1 © 2011 Pearson Education France – CUDA par l'exemple – Jason Sanders, Edward Kandrot Cuda-Livre.indb 144 26/04/11 12:44 Chapitre 9 Atomicité 145 GeForce GT 120, 9500 GT, 8600 GTS, 8600 GT, 9700M GT, 9650M GS, 9600M GT, 9600M GS, 9500M GS, 8700M GT, 8600M GT, 8600M GS 1.1 GeForce G100, 8500 GT, 8400 GS, 8400M GT, 9500M G, 9300M G, 8400M GS, 9400 mGPU, 9300 mGPU, 8300 mGPU, 8200 mGPU, 8100 mGPU 1.1 GeForce 9300M GS, 9200M GS, 9100M G, 8400M G 1.1 Tesla S2070, S2050, C2070, C2050 2.0 Tesla S1070, C1060 1.3 Tesla S870, D870, C870 1.0 Quadro Plex 2200 D2 1.3 Quadro Plex 2100 D4 1.1 Quadro Plex 2100 Model S4 1.0 Quadro Plex 1000 Model IV 1.0 Quadro FX 5800 1.3 Quadro FX 4800 1.3 Quadro FX 4700 X2 1.1 Quadro FX 3700M 1.1 Quadro FX 5600 1.0 Quadro FX 3700 1.1 Quadro FX 3600M 1.1 Quadro FX 4600 1.0 Quadro FX 2700M 1.1 Quadro FX 1700, FX 570, NVS 320M, FX 1700M, FX 1600M, FX 770M, FX 570M 1.1 Quadro FX 370, NVS 290, NVS 140M, NVS 135M, FX 360M 1.1 Quadro FX 370M, NVS 130M 1.1 Évidemment, NVIDIA produisant régulièrement de nouveaux processeurs graphiques, cette liste sera sans nul doute incomplète lorsque ce livre sera publié. Heureusement, le site web de NVIDIA contient une zone CUDA dans laquelle vous trouverez, entre autres, cette liste régulièrement mise à jour. Nous vous conseillons de la consulter si votre nouveau GPU n‘apparaît pas dans le Tableau 9.1 ; vous pouvez également exécuter le programme du Chapitre 3, qui affiche les possibilités de calcul de chaque accélérateur CUDA de votre système. © 2011 Pearson Education France – CUDA par l'exemple – Jason Sanders, Edward Kandrot Cuda-Livre.indb 145 26/04/11 12:44 146 CUDA par l’exemple Ce chapitre étant consacré à l’atomicité, nous nous intéresserons surtout aux possibilités d’effectuer des opérations atomiques en mémoire. Avant de présenter la notion d’atomicité et son importance, vous devez savoir que les opérations atomiques en mémoire globale ne sont disponibles qu’à partir de la version 1.1 des possibilités de calcul. En outre, les opérations atomiques sur la mémoire partagée exigent un GPU avec une version 1.2 ou supérieure. Du fait de l’emboîtement des versions, les GPU ayant des possibilités de calcul en version 1.2 disposent donc des opérations atomiques en mémoire globale et en mémoire partagée. Selon le même principe, les GPU en version 1.3 en disposent également. Si la version des possibilités de calcul de votre GPU est 1.0 et qu’il ne supporte donc pas les opérations atomiques en mémoire globale, nous venons de vous donner une très bonne excuse pour passer à un modèle supérieur ! Sinon vous pouvez continuer à lire tout ce qui concerne les opérations atomiques et découvrir les situations où elles sont nécessaires – mais vous ne pourrez pas les utiliser. Si cela vous brise le cœur, vous pouvez passer directement au chapitre suivant. Compilation pour une possibilité de calcul minimale Supposons que vous ayez écrit du code qui exige une possibilité de calcul minimale. Imaginons, par exemple, que vous avez terminé ce chapitre et que vous souhaitez écrire une application qui utilise intensément l’atomicité en mémoire globale : vous savez donc qu’il faut au minimum une version 1.1. Pour compiler votre code, vous devez informer le compilateur que le noyau ne pourra pas s’exécuter sur du matériel ayant une version inférieure. Ce faisant, vous lui donnez également la possibilité de faire d’autres optimisations qui peuvent n’être disponibles qu’à partir des GPU en version 1.1. Pour informer le compilateur du numéro de version minimal requis, il suffit d’ajouter une option à la ligne de commande de nvcc : nvcc -arch=sm_11 De la même façon, pour compiler un noyau qui exige l’atomicité en mémoire partagée, vous devez informer le compilateur que votre code a besoin d’une possibilité de calcul au moins égale à 1.2 : nvcc -arch=sm_12 Présentation des opérations atomiques Pour écrire des applications monothreads, on n’a généralement jamais besoin d’opérations atomiques. Si vous êtes dans ce cas, ne vous inquiétez pas : nous vous expliquerons leur signification et pourquoi elles peuvent être nécessaires dans les programmes © 2011 Pearson Education France – CUDA par l'exemple – Jason Sanders, Edward Kandrot Cuda-Livre.indb 146 26/04/11 12:44 Chapitre 9 Atomicité 147 multithreads. Pour poser le problème, examinons l’une des premières choses que l’on apprend lorsqu’on étudie C ou C++, l’opérateur d’incrémentation : x++; Il s’agit d’une expression simple en C – après son exécution, la valeur de x devrait avoir été incrémentée de un. Mais quelle est la séquence d’opérations qu’elle implique ? Pour ajouter un à la valeur de x, il faut d’abord connaître la valeur qui est dans x ; ce n’est qu’après l’avoir lue que l’on peut la modifier et, ensuite, la remettre dans x. Cette opération se déroule donc en trois étapes : 1.Lire la valeur de x. 2.Ajouter 1 à la valeur obtenue à l’étape 1. 3.Écrire le résultat dans x. Ce processus est parfois désigné par le terme général d’opération lecture-modification-écriture car l’étape 2 peut être n’importe quelle opération modifiant la valeur lue dans x. Considérons maintenant la situation où deux threads A et B ont besoin d’incrémenter la valeur stockée dans x : ils doivent donc, tous les deux, effectuer les trois opérations que nous venons de décrire. Supposons que la valeur initiale de x soit 7. Dans l’idéal, nous voudrions que A et B effectuent les étapes représentées dans le Tableau 9.2. Tableau 9.2 : Deux threads incrémentant la valeur de x Étape Exemple Le thread A lit la valeur de x A lit 7 dans x Le thread A ajoute 1 à la valeur lue A calcule 8 Le thread A écrit le résultat dans x x <- 8 Le thread B lit la valeur de x B lit 8 dans x Le thread B ajoute 1 à la valeur lue B calcule 9 Le thread B écrit le résultat dans x x <- 9 x démarrant avec la valeur 7 et étant incrémenté par deux threads, nous nous attendrions à ce qu’il contienne la valeur 9 après leurs calculs. Dans la séquence d’opérations que nous venons de décrire, c’est d’ailleurs bien ce que nous obtenons. Malheureusement, d’autres combinaisons de ces étapes peuvent produire la mauvaise valeur. Étudiez, par exemple, l’ordre du Tableau 9.3, où les opérations des threads A et B sont entrelacées. © 2011 Pearson Education France – CUDA par l'exemple – Jason Sanders, Edward Kandrot Cuda-Livre.indb 147 26/04/11 12:44 148 CUDA par l’exemple Tableau 9.3 : Deux threads incrémentant la valeur de x avec des opérations entrelacées Étape Exemple Le thread A lit la valeur de x A lit 7 dans x Le thread B lit la valeur de x B lit 7 dans x Le thread A ajoute 1 à la valeur lue A calcule 8 Le thread B ajoute 1 à la valeur lue B calcule 8 Le thread A écrit le résultat dans x x <- 8 Le thread B écrit le résultat dans x x <- 8 Par conséquent, nous obtiendrons le mauvais résultat si la planification des threads nous est défavorable. Ces six opérations pourraient se combiner de bien d’autres façons – certaines produisant un résultat correct, d’autres non. Lorsque nous passons d’une version monothread à une version multithreads de cette application, nous risquons donc subitement d’obtenir des résultats imprévisibles lorsque plusieurs threads doivent lire ou écrire des valeurs partagées. Dans l’exemple ci-dessus, nous avons donc besoin d’un moyen d’effectuer la lecturemodification-écriture sans être interrompus par un autre thread – plus précisément, aucun autre thread ne doit pouvoir lire ou écrire la valeur de x tant que nous n’avons pas terminé notre opération. Les opérations dont l’exécution ne peut pas être décomposée en sous-parties sont dites atomiques. CUDA C dispose de plusieurs opérations atomiques permettant d’utiliser la mémoire en toute sécurité, même lorsque des milliers de threads sont en compétition pour y accéder. Étudions maintenant un exemple qui exige l’emploi d’opérations atomiques pour produire les résultats attendus. Calcul d’histogrammes Souvent, les algorithmes nécessitent le calcul de l’histogramme d’un ensemble de données. Si vous n’avez jamais utilisé d’histogrammes par le passé, ce n’est pas grave : essentiellement, cela représente la fréquence de chaque élément au sein d’un ensemble d’éléments. Si nous créons un histogramme des lettres de la chaîne "Programmer avec CUDA C", par exemple, nous obtiendrons le résultat de la Figure 9.1. Bien qu’il soit simple à décrire et à comprendre, le calcul des histogrammes est étonnamment très courant en informatique. On s’en sert pour le traitement d’images, la compression des données, la vision par ordinateur, l’apprentissage par ordinateur, l’encodage audio et dans bien d’autres domaines encore. © 2011 Pearson Education France – CUDA par l'exemple – Jason Sanders, Edward Kandrot Cuda-Livre.indb 148 26/04/11 12:44 Chapitre 9 Figure 9.1 Histogramme des fréquences des lettres de la chaîne "Programmer avec CUDA C". Atomicité 149 3 3 1 2 1 2 1 1 3 1 1 A C D E G M O P R U V Calcul d’un histogramme sur le CPU Tous les lecteurs ne sachant pas nécessairement calculer un histogramme, nous commencerons par montrer comment effectuer ce traitement sur le CPU. Cet exemple servira également à illustrer la simplicité de ce calcul dans une application monothread. L’application recevra un flux important de données – avec un vrai programme, ces données pourraient représenter n’importe quelle information, des couleurs de pixels à des échantillons audio mais, ici, ce seront simplement des octets produits aléatoirement. Pour produire ce flux, nous avons écrit une fonction utilitaire, big_random_ block(), qui nous permettra de créer 100 Mo de données. #include "../common/book.h" #define SIZE (100*1024*1024) int main( void ) { unsigned char *buffer = (unsigned char*)big_random_block( SIZE ); Chaque octet pouvant prendre 256 valeurs différentes (de 0x00 à 0xFF), notre histogramme doit contenir 256 emplacements pour mémoriser le nombre d’occurrences de chacune de ces valeurs dans les données. Nous créons donc un tableau de 256 éléments tous initialisés à zéro : unsigned int histo[256]; for (int i=0; i<256; i++) histo[i] = 0; Nous devons maintenant placer les fréquences de chaque valeur de buffer[]. Le principe consiste à incrémenter la valeur de l’élément z de l’histogramme à chaque fois que l’on rencontre la valeur z dans le tableau buffer[]. Si buffer[i] est la valeur que l’on est en train d’examiner, nous devons donc incrémenter le compteur de l’élément buffer[i] de l’histogramme, c’est-à-dire histo[buffer[i]]. Cette opération peut s’effectuer en une seule ligne de code : histo[buffer[i]]++; © 2011 Pearson Education France – CUDA par l'exemple – Jason Sanders, Edward Kandrot Cuda-Livre.indb 149 26/04/11 12:44