Dans ce chapître, on appellera accélérateur un co-processeur dedié différent du processeur principal utilisé pour exécuter le code de calcul. Dans la version actuelle de Arcane, il s'agit d'accélérateurs de type GPGPU.
L'API Arcane pour gérer les accélérateurs s'inspire des bibliothèques telles que RAJA ou Kokkos mais se restreint aux besoins spécifiques de Arcane.
L'implémentation actuelle supporte uniquement comme accélérateur les cartes graphiques NVidia (via CUDA) ou AMD (via ROCm).
L'API accélérateur de Arcane répond aux objectifs suivants:
#pragma
comme dans les normes OpenMP ou OpenACC.Le principe de fonctionnement est l'exécution de noyaux de calcul déportés. Le code est exécuté par défaut sur le CPU (l'hôte) et certaines parties du calcul sont déportés sur les accélérateurs. Ce déport se fait via des appels spécifiques.
Pour utiliser les accélerateurs, il est nécessaire d'avoir compiler Arcane avec CUDA ou ROCm. Plus d'informations dans le chapitre Compilation.
L'ensemble des types utilisés pour la gestion des accélérateurs est dans l'espace de nom Arcane::Accelerator. Il y a deux composantes pour gérer les accélérateurs :
arcane_accelerator_core
dont les fichiers d'en-tête sont inclus via #include <arcane/accelerator/core>
. Cette composante comporte les classes indépendantes du type de l'accélérateur.arcane_accelerator
dont les fichiers d'en-tête sont inclus via #include <arcane/accelerator>
. Cette composante comporte les classes permettant de déporter des noyaux de calcul sur un l'accélérateur spécifique.Les classes principales pour gérer les accélérateurs sont:
Il existe deux possibilités pour utiliser les accélérateurs dans Arcane :
Pour lancer un calcul sur accélérateur, il faut instancier une file d'exécution. La classe RunQueue gère une telle file. La fonction makeQueue() permet de créer une telle file. Les files d'exécution peuvent être temporaires ou persistantes mais ne peuvent pas être copiées. La méthode makeQueueRef() permet de créer une référence à une file qui peut être copiée.
Il est possible pour tout module de récupérer une implémentation de l'interface IAcceleratorMng via la méthode AbstractModule::acceleratorMng(). Le code suivant permet par exemple d'utiliser les accélérateurs depuis un point d'entrée :
Il est possible de créer plusieurs instances de l'objet Runner.
Une instance de cette classe est associée à une politique d'exécution dont les valeurs possibles sont données par l'énumération eExecutionPolicy. Par défaut, la politique d'exécution est eExecutionPolicy::Sequential, ce qui signifie que les noyaux de calcul seront exécutés en séquentiel.
Il est aussi possible d'initialiser automatiquement une instance de cette classe en fonction des arguments de la ligne de commande :
Arcane propose une intégration pour compiler avec le support des accélérateurs via CMake. Ceux qui utilisent un autre système de compilation doivent gérer aux même ce support.
Pour pouvoir utiliser des noyaux de calcul sur accélérateur, il faut en général utiliser un compilateur spécifique. Par exemple, l'implémentation actuelle de Arcane via CUDA utilise le compilateur nvcc
de NVIDIA pour cela. Ce compilateur se charge de compiler la partie associée à l'accélérateur. La partie associée au CPU est compilée avec le même compilateur que le reste du code.
Il est nécessaire de spécifier dans le CMakeLists.txt
qu'on souhaite utiliser les accélérateurs ainsi que les fichiers qui seront compilés pour les accélérateurs. Seuls les fichiers utilisant des commandes (RUNCOMMAND_LOOP ou RUNCOMMAND_ENUMERATE) ont besoin d'être compilés pour les accélérateurs. Pour cela, Arcane définit les fonctions CMake suivantes :
mytarget
a besoin de l'environnement accélérateur.Si Arcane est compilé en environnement CUDA, la variable CMake ARCANE_HAS_CUDA
est définie. Si Arcane est compilé en environnement HIP/ROCm, alors ARCANE_HAS_HIP
est défini.
Le choix de l'environnement d'exécution par défaut (IAcceleratorMng::defaultRunner()) est déterminé par la ligne de commande :
AcceleratorRuntime
est spécifiée, on utilise ce runtime. Actuellement les seules valeurs possibles sont cuda
ou hip
. Par exemple : -T
(voir Lancement d'un calcul), alors les noyaux de calcul sont répartis sur plusieurs threads,Une fois qu'on dispose d'une instance de RunQueue, il est possible de créér une commande qui pourra être déportée sur accélérateur. Les commandes sont toujours des boucles qui peuvent être de la forme suivante:
Le chapître Utilisation des lambda décrit la syntaxe de ces boucles.
Le code suivant permet par exemple d'utiliser les accélérateurs depuis un point d'entrée :
Les accélérateurs ont en général leur propre mémoire qui est différente de celle de l'hôte. Il est donc nécessaire de spécifier comment seront utilisées les données pour gérer les éventuels transferts entre les mémoires. Pour cela Arcane fournit un mécanisme appelé une vue qui permet de spécifier pour une variable ou un tableau s'il va être utilisé en entrée, en sortie ou les deux.
Arcane propose des vues sur les variables (VariableRef) ou sur la classe NumArray (La page Utilisation de la classe NumArray décrit plus précisément l'utilisation de cette classe).
Quel que soit le conteneur associé, la déclaration des vues est la même et utilise les méthodes viewIn(), viewOut() ou viewInOut().
Par défaut, Arcane utilise l'allocateur retourné par MeshUtils::getDefaultDataAllocator() pour le type NumArray ainsi que toutes les variables (VariableRef), les groupes d'entités (ItemGroup) et les connectivités.
Lorsqu'on utilise les accélérateurs, Arcane requiert que cet allocateur alloue de la mémoire qui soit accessible à la fois sur l'hôte et l'accélérateur. Cela signifie que les données correspondantes à ces objets sont accessibles à la fois sur l'hôte (CPU) et sur les accélérateurs. Pour cela, Arcane utilise par défaut la mémoire unifiée (eMemoryResource::UnifiedMemory).
Avec la mémoire unifiée, c'est l'accélérateur qui gère automatiquement les éventules transferts mémoire entre l'accélérateur et l'hôte. Ces transferts peuvent être coûteux en temps s'ils sont fréquents mais si une donnée n'est utilisée que sur CPU ou que sur accélérateur, il n'y aura pas de transferts mémoire et donc les performances ne seront pas impactées.
A partir de la version 3.14.12 de Arcane, il est possible de changer la ressoure mémoire utilisée par défaut via la variable d'environnement ARCANE_DEFAULT_DATA_MEMORY_RESOURCE
. Sur les accélérateurs où la mémoire eMemoryResource::Device est accessible directement depuis l'hôte (par exemple MI250X, MI300A, GH200), cela permet d'éviter les transferts que peut provoquer la mémoire unifiée.
Dans tous les cas, il est possible de spécifier un allocateur spécifique pour UniqueArray et NumArray via les méthodes MemoryUtils::getAllocator() ou MemoryUtils::getAllocationOptions().
Arcane fournit des mécanismes permettant de donner des informations permettant d'optimiser la gestion de cette mémoire. Ces mécanismes sont dépendants du type de l'accélérateur et peuvent ne pas être disponible partout. Ils sont accessibles via la méthode Runner::setMemoryAdvice().
A partir de la version 3.10 de Arcane et avec les accélérateurs NVIDIA, Arcane propose des fonctionnalités pour détecter les transferts mémoire entre le CPU et l'accélérateur. La page Intégration avec CUPTI (Cuda Profiling Tools Interface) décrit ce fonctionnement.
L'exemple suivant montre comment modifier l'intervalle d'itération pour ne pas partir de zéro :
Quelle que soit la macro (RUNCOMMAND_ENUMERATE(), RUNCOMMAND_LOOP(), ...) utilisée pour la boucle, le code qui suit doit être une une fonction lambda du C++11. C'est cette fonction lambda qui sera éventuellement déportée sur accélérateur.
Arcane utilise l'opérateur operator<<
pour "envoyer" la boucle sur une commande (RunCommand) ce qui permet d'écrire le code de manière similaire à celui d'une boucle C++ classique (ou une boucle ENUMERATE_() dans le cas des entités du maillage) avec les quelques modifications suivantes :
{
et }
) sont obligatoires;
après la dernière accolade.continue
ou break
. Le mot clé return
est disponible et donc aura le même effet que continue
dans une boucle.Par exemple :
Lorsque'un noyau de calcul est déporté sur accélérateur, il ne faut pas accéder à la mémoire associée aux vues depuis une autre partie du code pendant l'exécution sous peine de plantage. En général cela ne peut se produire que lorsque les RunQueue sont asynchrones. Par exemple :
Les mécanismes de compilation et la gestion mémoire sur accélérateurs font qu'il y a des restrictions sur l'utilisation des lambda classiques du C++
Dans une lambda prévue pour être déportée sur accélérateur, on ne peut appeler que :
inline
constexpr
Il n'est pas possible d'appeler des fonctions externes qui sont définies dans d'autres unités de compilation (par exemple d'autres bibliothèques)
Il ne faut pas utiliser dans les lambdas une référence à un champ d'une classe car ce dernier est capturé par référence. Cela provoquera un plantage par accès mémoire invalide sur accélérateur. Pour éviter ce problème, il suffit de déclarer localement à la fonction une copie de la valeur de l'instance de classe qu'on souhaite utiliser. Dans l'exemple suivant la fonction f1()
provoquera un plantage alors que f2()
fonctionnera bien.
A partir de la version 3.10, Arcane supporte les bibliothèques MPI "Accelerator Aware". Dans ce cas, le buffer utilisé pour les synchronisations des variables est alloué directement sur l'accélérateur. Si une variable est utilisée sur accélérateur cela permet donc d'éviter des recopies inutiles entre l'hôte et l'accélérateur. Le mode échange de message en mémoire partagée supporte aussi ce mécanisme.
En cas de problèmes, il est possible de désactiver ce support en positionnant la variable d'environnement ARCANE_DISABLE_ACCELERATOR_AWARE_MESSAGE_PASSING
à une valeur non nulle.
Arcane associe lors de la création d'un sous-domaine une instance de Runner (accessible via ISubDomain::acceleratorMng()). Lorsqu'une machine dispose de plusieurs accélérateurs, Arcane choisi par défaut le premier qui est retourné dans les accélérateurs disponibles. Il est possible de changer ce comportement en positionnant la variable d'environnement ARCANE_ACCELERATOR_PARALLELMNG_RANK_FOR_DEVICE
à une valeur strictement positive indiquant le modulo entre le rang de sous-domaine (retourné par IParallelMng::commRank() de ISubDomain::parallelMng()) et l'index de l'accélérateur dans la liste des accélérateurs. Par exemple si cette variable d'environnement vaut 8, alors le sous-domaine de rang N sera associé à l'accélérateur d'index (N % 8). Pour que ce mécanisme fonctionne, la valeur de cette variable d'environnemetn doit donc être inférieure au nombre d'accélérateurs disponibles sur la machine.
Lorsque plusieurs accélérateurs sont disponibles sur une même machine, il existe en général un accélérateur "courant" pour chaque thread (par exemple avec CUDA il est possible de le récupérer par la méthode cudaGetDevice()
et on peut le changer par la méthode cudaSetDevice()
). Lorsqu'on alloue de la mémoire sur accélérateur, c'est sur cet accélérateur "courant" et cette mémoire ne sera pas disponible sur d'autres accélérateurs. Une instance de RunQueue est associée à un accélérateur donné et il faut donc s'assurer que les zones mémoires utilisées par une commande sont bien accessibles. Si ce n'est pas le cas cela produira une erreur lors de l'exécution (Par exemple, avec CUDA, il s'agit de l'erreur 400 dont le message est "invalid resource handle").
Si l'accélérateur "courant" a été modifié par exemple lors de l'appel à une bibliothèque externe il est possible de le changer en appelant la méthode Runner::setAsCurrentDevice().
L'accès aux connectivités du maillage se fait différemment sur accélérateur que sur le CPU pour des raisons de performance. Il n'est notamment pas possible d'utiliser les entités classiques (Cell,Node, ...). A la place il faut utiliser les indentifiants locaux tels que CellLocalId ou NodeLocalId.
La classe UnstructuredMeshConnectivityView permet d'accéder aux informations de connectivité. Il est possible de définir une instance de cette classe et de la conserver au cours du calcul. Pour initialiser l'instance, il faut appeler la méthode UnstructuredMeshConnectivityView::setMesh().
Pour accéder aux informations génériques des entités, comme le type ou le propriétaire, il faut utiliser la vue ItemGenericInfoListView.
L'exemple suivant montre comment accéder aux noeuds des mailles et aux informations des mailles. Il parcourt l'ensemble des mailles et calcule le barycentre pour celles qui sont dans notre sous-domaine et qui sont des hexaèdres.
La méthode doAtomic permet d'effectuer des opérations atomiques. Les types d'opérations supportées sont définies par l'énumération eAtomicOperation. Par exemple:
Arcane propose plusieurs classes permettant d'effectuer des algorithmes plus avancés. Sur accélérateur, ces algorithmes utilisent en général les bibliothèques proposées par le constructeur (CUB pour NVIDIA et rocprim pour AMD). Les algorithmes proposés par Arcane possèdent donc les mêmes limitations que l'implémentation constructeur sous-jacente.
Les classes disponibles sont:
Il est possible d'utiliser le mode accélérateur de Arcane sans le support des objets de haut niveau tel que les maillages ou les sous-domaines.
Dans ce mode, il est possible d'utiliser l'API accélérateur de Arcane directement depuis la fonction main()
par exemple. Pour utiliser ce mode, il suffit d'utiliser la méthode de classe ArcaneLauncher::createStandaloneAcceleratorMng() après avoir initialiser Arcane :
L'instance launcher
doit rester valide tant qu'on souhaite utiliser l'API accélérateur. Il est donc préférable de la définir dans le main()
du code. La classe StandaloneAcceleratorMng utilise une sématique par référence. Il est donc possible de conserver une référence vers l'instance n'importe où dans le code si nécessaire.
L'exemple 'standalone_accelerator' montre une telle utilisation. Par exemple, le code suivant permet de déporter sur accélérateur la somme de deux tableaux a
et b
dans un tabeau c
.