OpenACC 2.0 : l’optimisation des zones de calcul
By   |  February 12, 2014

La nouvelle spécification d’OpenACC apporte de nombreuses améliorations permettant de programmer les accélérateurs de façon plus efficace et moins intrusive. Dans notre précédent article, nous nous sommes penchés sur les nouveautés relatives à la gestion des données. Ce mois-ci, regardons plus en détail les nouvelles possibilités d’optimisation des zones de calcul…

Commençons par les atomiques, auxquels la spécification 2.0 d’OpenACC consacre toute une section. Ceux d’entre nos lecteurs qui sont déjà familiers d’OpenMP vont très certainement avoir un sentiment de déjà-vu. Qu’ils se rassurent, rien d’étonnant à cela : les atomiques dans OpenACC ne sont ni plus ni moins que l’exacte copie des atomiques dans OpenMP, à quelques exceptions près dont, bien entendu, la garde OMP remplacée par la garde ACC dans les directives.

Cette similarité est tout à fait intentionnelle. Réinventer une syntaxe pour une fonctionnalité qui possède quasiment les même prérequis n’aurait aucun bénéfice pratique. Au contraire, la correspondance permet le portage plus ou moins direct, d’un standard à l’autre, des codes qui utilisent ce type d’opérations.

Le support des opérations atomiques

Si les atomiques sont assez simple à comprendre, ils peuvent en revanche engendrer des bugs assez subtils. Imaginez par exemple une application OpenMP ou OpenACC contenant deux threads (ou gangs ou workers) essayant chacun d’incrémenter un compteur global X. En pratique, cette tâche assez banale se décompose en trois étapes : charger X en registre depuis la mémoire, incrémenter le registre et, enfin, le sauvegarder en mémoire. L’ordre de ces trois opérations est parfaitement suivi au sein d’un même thread mais pas dans un contexte multithread. Le pseudo-code du listing 1 en est une illustration avec des accès concurrents (race condition) entre deux threads incrémentant chacun la variable X qui, au final, n’est incrémentée qu’une seule fois.

En réalité, la situation est encore plus chaotique. Pourquoi ? Parce que les caches présents dans les CPU et GPU récents ajoutent un degré supplémentaire de complexité. Dans le même temps, ces accélérateurs offrent des mécanismes internes permettant de réaliser des opérations de manière atomique au regard des accès mémoire. Grâce à ces mécanismes, pour incrémenter une variable de façon atomique avec OpenACC 2.0, il suffit tout simplement d’insérer une directive – atomic update – avant l’opération d’incrémentation, comme illustré au listing 2. Officiellement, la plupart des opérations arithmétiques de base sur les scalaires natifs de chacun des langages est supportée. Mais quelques-unes peuvent ne pas être disponibles sur toutes les cibles matérielles car leur implémentation peut nécessiter un support hardware spécifique.

atomic update convient parfaitement pour compter, par exemple, le nombre d’erreurs ou de correspondances dans l’exécution d’une région entière de calcul OpenACC. Mais elle ne peut être utilisée lorsqu’un thread qui met à jour une variable doit avoir connaissance de la valeur mise à jour. Pour mieux comprendre, prenons l’exemple d’un large vecteur creux et d’une fonction qui compacte ses valeurs non nulles dans un autre vecteur de taille maximum 1000. L’approche immédiate, illustrée au listing 3, est incorrecte car toutes les occurrences de la variable n qui sont en dehors de l’opération atomique sont sujettes à des accès concurrents entre les autres threads. De ce fait, n’importe quelle occurrence de n (au sens thread) peut avoir des valeurs différentes à la même itération de la boucle.

Ce problème se résout en utilisant la directive atomic capture, qui est une variante de la directive atomic update. Elle réalise la même opération atomique sur les variables mais conserve aussi une copie de la valeur finale dans une autre variable. L’implémentation correcte de la fonction de compactage d’un vecteur creux est donnée au listing 4.

Spécialiser les directives et les clauses par rapport aux cibles matérielles

Il y a quelques temps encore, la devise du groupe de travail OpenACC était en quelque sorte “écrivez une seule version, exécutez efficacement sur toutes cibles”. Cet objectif de portabilité est aujourd’hui plus ou moins atteint mais force est de constater que l’exécution efficace d’un même code sur différentes plateformes n’est la plupart du temps pas possible. La raison en est que les différences entre les accélérateurs du marché se sont creusées. Ainsi, en pratique, pour obtenir de bonnes performances sur toutes les cibles matérielles visées, le nombre de gangs ou de workers ainsi que la taille du vecteur dans une directive parallel nécessitent un réglage manuel pour chacune des cibles. Des valeurs inadéquates peuvent impacter la performance de manière très importante, notamment lorsque l’on travaille dans des environnements qui utilisent des coprocesseurs Xeon Phi et des accélérateurs GPU NVIDIA ou AMD. Bien sûr, les compilateurs vont utiliser des heuristiques pour essayer de déterminer automatiquement les meilleures valeurs en fonction de la cible visée mais le résultat sera la plupart du temps loin, voire très loin, d’être optimal. Moralité : un réglage à la main reste incontournable !

Une solution partielle à ce problème consiste à utiliser la nouvelle clause device type (ou dtype) qui permet de restreindre certaines clauses à une cible donnée. Elle se paramètre à l’aide d’un mot-clé définissant une cible matérielle et a pour effet de rendre les clauses qui la suivent spécifiques à cette cible. La portée de la clause device type s’arrête lorsqu’une autre clause device type est rencontrée dans la directive ou à la fin de la directive elle-même. Lorsqu’aucun des types de cibles de concordent, le type générique * s’applique par défaut. Autrement dit, device type fonctionne un peu comme un switch en C appliqué aux clauses d’une même directive. Le listing 5 en montre une utilisation possible pour définir le nombre de gangs et de workers d’une boucle en fonction des différentes cibles.

La norme OpenACC ne nomme pas spécifiquement les différentes cibles matérielles. Les noms NVIDIA, RADEON et XEONPHI utilisés au listing 5 sont recommandés pour les trois principaux types d’accélérateurs mais les compilateurs sont supposés fournir des mécanismes pour en créer d’autres (typiquement sous forme d’options en ligne de commande). Par exemple, NVIDIA2, utilisé au listing 5, est un mot clé défini par l’utilisateur pour un certain type de GPU NVIDIA. Les cibles non connues par le compilateur OpenACC seront tout simplement ignorées.

Notez que la clause device type n’est permise qu’avec certaines directives, à savoir routine, kernels, parallel, loop et update, ainsi que toutes leurs combinaisons. Par ailleurs, certaines clauses ne peuvent pas être utilisées après device type. Pour en savoir plus, référez-vous à la documentation officielle, qui détaille chacune de ces directives.

Navigation

<12>

© HPC Today 2021 - All rights reserved.

Thank you for reading HPC Today.

Express poll

Do you use multi-screen
visualization technologies?

Industry news

Brands / Products index