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