• Aucun résultat trouvé

3.5 Etude de la parallélisation sur GPU

3.5.1 Implémentations CUDA et OpenCL

Nous allons présenter les implémentations GPU. Le modèle de programmation de ces ar- chitectures est SIMT, ce qui implique l’exécution d’une même instruction par un groupe de threads de manière synchrone. Nous allons donc essayer d’adapter au mieux l’algorithme à ce modèle, en prenant en considération les dimensions de nos paramètres pour utiliser au mieux la mémoire partagée du GPU.

3.5.1.1 Base des implémentations GPU

Comme nous l’avons vu dans la section 3.3.7, nous allons paralléliser sur les pixels de l’image afin de bénéficier de la réutilisation des temps de vol. Sur GPU, deux niveaux de parallélisa- tion sont accessibles. Le premier se trouve au niveau des threads et le second sur les blocs de threads. Une première approche serait donc de calculer un pixel par thread.

Comme nous l’avons vu pour l’algorithme 4, il va être nécessaire de stocker l’ensemble des temps de vol en émission vers un point de la zone de calcul pour effectuer ensuite l’extraction et la sommation des amplitudes. De plus, comme nous l’avons vu précédemment, nous nous intéressons à des traducteurs dont le nombre d’éléments est compris entre 16 et 256. Nous al- lons donc avoir besoin de stocker jusqu’à 256× sizeof(double) = 2048 octets. Dans le cas de l’association "1 thread / 1 pixel", il serait donc nécessaire, pour un bloc de 32 threads, une dimension très restreinte, d’avoir 64 Ko de mémoire partagée. Ce n’est pas le cas, étant donné que l’architecture Fermi permet d’obtenir jusqu’à 48 Ko. Ce niveau de parallélisation n’est donc pas approprié.

Nous allons donc nous intéresser à l’association d’un bloc de threads par pixel. Par rap- port à la première association. Le nombre de threads n’a ici aucun impact sur la quantité de mémoire partagée ; nous allons voir par la suite leur manière de se partager les calculs. Pour un traducteur de 256 éléments, il va être nécessaire de stocker à nouveau 2 Ko de temps de vol (256× sizeof(double)= 2Ko). Cette fois-ci, notre bloc de threads va pouvoir profiter de la mémoire partagée. De plus, comme nous l’avons vu dans le chapitre 1, le GPU est capable d’ordonnancer des warps de threads provenant de plusieurs blocs, à condition d’avoir suffi- samment de ressources. Dans le cas présent, il serait possible de saturer le nombre maximum de blocs de threads du multiprocesseur (8, donc 8× 2Ko = 16Ko), sans pour autant saturer la mémoire partagée. Ce type de parallélisme semble donc permettre de charger de manière efficace le GPU.

Pour cette parallélisation, nous allons devoir partager les calculs entre les threads pour ob- tenir de bonnes performances. Ce n’est pas un problème dans la mesure où les calculs de temps

de vol sont du même ordre de grandeur que le dimensionnement du bloc de threads et que l’ex- traction/sommation est, elle, de dimension bien plus élevée (puisqu’en O(N2

t)). Le calcul des temps de vol, effectué dans la première boucle de l’algorithme 4, est partagée entre les threads tel que présenté dans le tableau 3.4. Le cas présenté ici est pour un bloc de threads de 6 threads et un traducteur de 10 éléments. Les 6 premiers threads exécutent les calculs de temps de vol des 6 premiers éléments en parallèle, puis dans un second temps les éléments 6 à 9 sont traités par les threads 0 à 3. Les threads 4 et 5 restent passifs. Dans le cas où le nombre d’éléments du traducteur n’est pas une puissance de deux, la charge entre les threads ne sera pas idéale entraînant une légère perte d’efficacité.

Tableau 3.4 – Ordonnancement des threads du GPU pour le calcul des temps de vol. A chaque élément de capteur, un temps de vol est calculé. Pour l’exemple ici, le travail est réparti entre 6 threads pour un traducteur de 10 éléments.

Thread ID 0 1 2 3 4 5 Elément 0 x Elément 1 x Elément 2 x Elément 3 x Elément 4 x Elément 5 x Elément 6 x Elément 7 x Elément 8 x Elément 9 x

Dans le cas d’une association d’un bloc de threads par pixel, et d’un capteur de 128 élé- ments, on aura tendance à vouloir utiliser 128 threads par bloc pour avoir une association 1 pour 1. Ceci reste contraignant pour des configurations plus petites, lorsqu’on aura un traduc- teur 32 éléments par exemple, où 32 sera une taille de bloc relativement sous dimensionnée.

Une fois les temps de vol calculés, il est nécessaire d’effectuer une synchronisation des threads du bloc afin de vérifier que tous ont terminé leur calcul pour pouvoir passer à la boucle d’extraction et de sommation des amplitudes.

Pour cette seconde boucle, les extractions/sommations sont partagées comme pour la pre- mière boucle. La différence principale réside dans le fait que l’ensemble des calculs effectués est sommé dans la valeur du pixel. Etant donné que tous les threads du bloc accèdent à ce pixel, il est donc nécessaire de synchroniser l’accès à cet emplacement mémoire en utilisant un atomi- cAdd. Etant donné que les threads extraient plusieurs amplitudes (nombre de threads par bloc / Nt2), ces sommations sont effectuées dans un registre, et la réduction à l’aide de l’instruction atomique uniquement effectuée une fois le travail des threads terminé.

Les détails d’implémentations présentés dans cette section sont les grandes lignes des deux implémentations réalisées. Nous allons maintenant présenter leurs spécificités.

3.5.1.2 Exhaust : implémentation sans interpolation des temps de vol

Les détails d’implémentation présentés ci-dessus nous permettent d’apporter une optimi- sation supplémentaire pour obtenir l’implémentation que nous appellerons Exhaust. En effet, la mémoire partagée du GPU est utilisée, mais il reste une certaine partie de cette mémoire disponible, même pour les traducteurs de grandes dimensions. Nous proposons donc d’ajou- ter un niveau de parallélisation au sein du bloc de threads afin d’apporter les optimisations suivantes :

1. possibilité d’utiliser plus de mémoire partagée, 2. réduction de la concurrence des accès atomiques,

3. localisation des accès aux signaux permettant d’obtenir des gains grâce au cache.

Pour le point (1), si plusieurs pixels par bloc sont traités, il va falloir stocker les temps de vol de ces pixels. Le point (2) est aussi relativement immédiat puisque si on traite plusieurs pixels, la réduction finale portera sur ces différents pixels et donc la concurrence sera d’autant plus faible qu’on traite plusieurs pixels. Enfin, concernant le point (3), les threads qui accèdent à des signaux pour différents pixels vont être synchronisés, en utilisant la notion du warp, afin d’accéder au même signal en même temps, et en se basant sur que le fait qu’entre deux pixels différents, le temps de vol calculé sera proche, et donc que les amplitudes extraites pour les différents pixels seront proches en mémoire. Cette optimisation permettra d’obtenir une meilleure efficacité du cache L1 des GPU Fermi.

Bloc de threads - répartition pour 4 sous-groupes

Pixels de l'image

FIGURE3.15 – Schéma présentant l’ordonnancement des threads d’un bloc pour l’implémen- tation Exhaust avec plusieurs pixels par bloc de threads lors de la boucle d’extraction/somma- tion des amplitudes. Cet ordonnancement permet entre autres à plusieurs threads d’accéder au même signal en même temps permettant d’obtenir des accès cache au lieu d’accès en globale Exhaustif pour une implémentation avec un bloc de threads par pixel de l’image.

Pour illustrer nos propos, la figure 3.15 présente l’ordonnancement des accès pour un bloc de threads traitant différents pixels. L’ordre est important puisqu’il permet à des threads voi- sins de traiter différents pixels en parallèle et donc d’accéder aux même signaux en même temps comme nous venons de le dire. Ce type d’optimisation dépend bien évidemment de la résolution de l’image, qui sera d’autant plus efficace que celle-ci sera fine par rapport à l’échan- tillonnage des signaux. Par exemple, pour le jeu de données DATA 1, l’espacement entre deux pixels est de 0,2 mm et l’ordre de grandeur de la différence de temps de vol entre ces points est de 0,06 µs. Etant donné que la fréquence d’échantillonnage est de 0,02 µs, l’accès à deux échantillons se fera avec un pas de 3 échantillons.

Tableau 3.5 – Quantité de mémoire partagée utilisée pour 8 blocs de threads par multipro- cesseur (cas idéal pour l’ordonnancement sur le GPU) en fonction de la taille du traducteur ultrasons et du nombre de pixels traités par bloc de threads. Les valeurs grisées ne sont pas utilisables, et celles en italique sont des optimums.

# pixels / bloc de threads

nombre d’éléments shared/pixel (Ko) 1 2 4 8

32 256 2048 4096 8192 16384

64 512 4096 8192 16384 32768

128 1024 8192 16384 32768 65536

256 2048 16384 32768 65536 131072

Nous constatons que pour cette implémentation, selon la taille de bloc, et le nombre de pixels par blocs traités, la quantité de mémoire partagée utilisée va être variable. Le tableau 3.5 pré- sente différentes combinaisons de tailles de traducteurs et de nombre de pixels traités par un bloc de threads. On considère que l’on cherche à pouvoir exécuter le maximum de blocs de threads par multiprocesseur en espérant obtenir le meilleur ordonnancement possible. Etant donné que le nombre maximum de blocs exécutable en parallèle sur un multiprocesseur des GPU Nvidia est 8, on multiplie la taille de mémoire shared utilisée par 8. On peut constater que toutes ne sont pas possibles telles que celles grisées dans le tableau étant donné que le GPU ne dispose que de 48Ko de mémoire shared au maximum par multiprocesseur. Ce tableau nous permet aussi de déterminer des optimums locaux (valeurs en italiques). Il est à noter qu’obtenir 48Ko serait évidemment mieux, mais nous allons rester dans nos cas de tests sur des puissances de 2. 32 Ko est donc la valeur retenue. Nous validerons cette approche dans les benchmarks.

3.5.1.3 Interp : Implémentation avec interpolation des temps de vol

L’implémentation avec interpolation des temps de vol est basée sur l’algorithme 5. La dif- férence principale par rapport à l’implémentation Exhaust est que la parallélisation ne va plus se faire sur les pixels de l’image mais sur les tuiles du maillage d’interpolation. Il va donc être nécessaire de stocker 4 tableaux de temps de vol au lieu d’un. Au niveau du calcul des temps de vol, la répartition des calculs entre les threads est identique à celle utilisée pour l’implémen- tation Exhaust.

Concernant la boucle d’extraction et de sommation des amplitudes, le principe de localité est lui aussi repris sur l’implémentation Exhaust. Les threads calculant les amplitudes des pixels de la tuile vont être répartis de sorte qu’ils travaillent sur les mêmes couples émetteur-récepteur en parallèle. Dans ce cas, le nombre de sous-groupes ne va pas avoir d’impact sur la quantité de mémoire partagée utilisée. En effet, les temps de vol sont calculés aux coins de la tuile et sont donc indépendants des groupes de pixels. Il va donc être possible d’utiliser un parallélisme plus important que pour l’implémentation Exhaust. Cette optimisation est actuellement 1D, mais il serait possible de l’étendre en 2D.

A l’heure actuelle, nous n’utilisons pas de recouvrement entre plusieurs tuiles. Il serait in- téressant de l’utiliser et particulièrement pour des traducteurs de petite taille où la mémoire partagée est moins utilisée. Cela permettrait d’augmenter l’efficacité de l’algorithme dans ces cas. Ce recouvrement permettrait aussi d’éviter de recalculer des points. En effet, pour deux tuiles côte à côte, seuls 6 points seraient nécessaires. Néanmoins, cette optimisation est très li- mitée dans la mesure où l’interpolation en elle-même réduit déjà considérablement la quantité de calculs.

Passons maintenant à l’analyse des différents benchmarks effectués sur les deux implémen- tations présentées ci-dessus.