Babel : optimisation des communications MPI et de l'extensibilité

Sommaire

Introduction

La plupart des applications parallèles actuelles pouvant tourner sur Blue Gene/P utilisent la bibliothèque de communication MPI. Les communications n'étant malheureusement pas instantanées, elles impliquent des surcoûts en temps d'exécution et peuvent aussi limiter l'extensibilité (nombre de coeurs pouvant être utilisé efficacement) d'un code de calcul.

Cette page a pour but de vous donner des éléments de reflexion et des pistes pour optimiser les communications MPI et améliorer l'extensibilité d'une application.

Méthodologie

Comme déjà expliqué dans la page Optimisation : introduction, il faut commencer par profiler votre application pour déterminer quelles sont les communications les plus critiques et identifier des éventuels problèmes d'équilibrage de la charge de travail entre les différents processus. Cette étape est absolument nécessaire car il est souvent très difficile de deviner où les ressources sont les plus utilisées (et les erreurs d'appréciation sont généralement la règle).

De plus, un profilage initial sera très utile pour comparer les gains en performance obtenus par la suite.

Il faut bien entendu commencer par s'attaquer aux parties les plus consommatrices de temps et aller progressivement vers des sections moins gourmandes. Il ne sert à rien d'aller trop loin car les gains potentiels diminuent très rapidement par rapport au temps et au travail nécessaires pour y parvenir.

La procédure à suivre sera découpée en deux phases.

La première forme d'optimisation et souvent la plus importante se fait lors de la conception. Il s'agit de choisir les bons algorithmes.

Dans une deuxième phase, lorsque le code de calcul fonctionne correctement, l'optimisation peut se dérouler en plusieurs étapes :

  1. Effectuer un profilage ;
  2. Choisir une section de code à améliorer ;
  3. Optimiser la zone choisie ;
  4. Vérifier que les résultats sont corrects ;
  5. Vérifier si il y a gain de performance ;
  6. Une fois satisfait pour cette section, retourner au point 2 ;
  7. Effectuer un profilage et éventuellement reboucler.

En cours d'optimisation, il peut être utile de refaire des profilages pour s'assurer que votre application se comporte bien comme attendu.

Algorithmes et conception

Avant même d'écrire votre application, lors de la phase de conception, il est essentiel de choisir les bons algorithmes. Il faut décider lesquels sont les plus adaptés à votre code de calcul en terme de type de problème traité, de taille, d'extensibilité (nombre de processus),… Il ne faut pas non plus sous-estimer les calculs qui seront réalisés et essayer de concevoir un code qui pourra encore servir dans plusieurs années (voire bien plus).

Pour réduire l'impact des communications MPI, il est souvent possible de dupliquer certains calculs entre différents processus pour réduire la fréquence des communications et/ou la quantité de données à échanger. Cela peut améliorer fortement l'extensibilité de votre application. Par contre, il s'agit de trouver le bon compromis entre volume de communication et quantité d'opérations dupliquées. Trop de réplication a un impact sur la quantité de mémoire utilisée et le temps d'exécution sur chaque processus.

Durant l'existence d'un code, il est toujours possible de revenir sur ces choix mais il est plus simple de partir dans la bonne direction dès le début. Un code ayant une longue durée de vie changera probablement d'algorithmes au cours du temps. Certaines bibliothèques permettent également de changer facilement d'algorithmes sans avoir à modifier fondamentalement votre code (par exemple en utilisant PETSc).

Equilibrage de charge

L'équilibrage de charge entre les différents processus est un des éléments essentiels pour obtenir une bonne extensibilité. Il faut absolument s'assurer que la quantité de travail que doit réaliser chaque processus est équivalente à celle de tous les autres. Il suffit qu'un seul d'entre-eux ait plus de travail et l'ensemble des autres processus sera obligé d'attendre.

Pour les codes faisant de la décomposition de domaine, il est courant d'utiliser du raffinement adaptatif de maillage afin d'augmenter la résolution des calculs dans les zones où il se passe le plus d'événements intéressants. L'ajout ou la destruction de mailles au cours des itérations introduit un déséquilibre. Il existe des moyens de restaurer l'équilibre en échangeant des mailles entre processus. Ces méthodes sont malheureusement assez complexes et coûteuses. Il faut donc faire attention à ne le faire que ponctuellement (toutes les n itérations par exemple) pour trouver le meilleur compromis entre équilibrage et coût de l'opération.

L'équilibrage de charge se fait souvent en distribuant à chaque processus le même nombre de mailles. Cependant, ce n'est pas toujours parfait. En effet, selon les modèles et algorithmes utilisés, les calculs sur les différentes mailles peuvent être plus ou moins longs. Dans ce cas, il faut tenter d'adapter le partage pour que les temps de calcul soient les plus proches possibles entre les différents processus.

Si il n'est pas possible d'obtenir un équilibrage parfait, il vaut mieux que quelques processus aient un peu moins de travail que les autres que l'inverse. Le gaspillage de temps de calcul sera moindre dans le premier cas.

Pour améliorer l'équilibrage sur des applications massivement parallèles, il faut également faire attention aux phases d'initialisation et de finalisation qui peuvent prendre beaucoup de temps lorsque le nombre de processus augmente. Il est conseillé d'éviter des phases où un seul (ou quelques) processus effectue des opérations et pour lesquelles il distribue (ou collecte) des données de tous les autres processus. Cela fonctionne généralement parfaitement avec quelques dizaines de processus mais devient un point d'étranglement avec plusieurs milliers. Ce genre d'opération avec un processus maître collectant ou distribuant des données en cours d'exécution est également à éviter. Si il s'avère nécessaire de procéder comme cela, il est souvent possible d'améliorer la situation en ajoutant un niveau de processus qui servira d'intermédiaire entre le processus maître et les processus esclaves. Ces processus intermédiaires s'occuperont de gérer ces communications avec pour chacun un nombre restreint de processus esclaves. Il est moins dommageable de gaspiller quelques processus pour faire ces opérations que d'avoir un seul processus saturé qui ralentisse l'ensemble de l'application.

Les problèmes d'équilibrage de charge peuvent être mis en évidence à l'aide des outils de profilage MPI ou avec la bibliothèque libhpcidris (via les mesure de temps avec synchronisation).

Recouvrement calculs/communications

La Blue Gene/P possède un moteur DMA (Direct Memory Access) sur son réseau tore 3D. Il permet la transmission de messages entre les différents noeuds de la machine sans interrompre les coeurs. Un message peut donc être transféré pendant que les coeurs travaillent. Un recouvrement des communications par des calculs est alors tout à fait possible. Pour les applications qui s'y prêtent bien, il est ainsi possible de masquer une bonne partie du temps pris par les communications.

La bibliothèque MPI fournit des procédures non-bloquantes pour les communications point-à-point (MPI_Isend et MPI_Irecv par exemple). Sur une machine comme Babel, il est fortement conseillé de les utiliser si il est possible de réaliser des opérations entre le moment où une communication est initiée et le moment où les données transmises sont utilisées. Il ne faut cependant pas oublier qu'il y a plus de surcoûts que dans les communications bloquantes car les communications non-bloquantes nécessitent plus d'appels aux procédures MPI et qu'il est nécessaire de gérer les requêtes. Il est également plus facile de faire des erreurs. Dans la phase de développement, il vaut donc mieux en rester aux communications bloquantes et ne s'intéresser aux non-bloquantes que lors de l'optimisation.

Les communications non-bloquantes ne doivent pas être utilisées en trop grande quantité car elles consomment des ressources en mémoire. Si trop de requêtes sont en cours simultanément sur un processus, la mémoire risque de s'épuiser (surtout sur une machine Blue Gene/P).

Attention : pour activer complétement le recouvrement calculs/communications sur Babel, il faut positionner la variable d'environnement DCMF_INTERRUPT à 1 (en passant l'option -env DCMF_INTERRUPT=1 à mpirun).

Placement des processus

Lorsque deux processus communiquent, leurs messages transitent par le réseau de la machine (sauf si ils sont dans le même noeud de calcul). La Blue Gene/P possède 3 réseaux distincts pour les communications MPI :

  • un réseau en tore 3D qui connecte chaque noeud de calcul à ses 2 voisins dans chaque direction (soit un total de 6 voisins). Ce réseau est utilisé par l'ensemble des communications point-à-point et par certaines communications collectives ;
  • un réseau en arbre binaire qui relie chaque noeud à 3 autres noeuds et qui est réservé à certaines communications collectives ;
  • un réseau de synchronisation pour les barrières de synchronisation globales.

reseaux

Sur les machines massivement parallèles, le placement des processus sur la machine peut avoir un impact considérable sur les performances. Prenons le cas du tore 3D sur lequel transitent la plupart des messages. La plupart du temps, une application alterne des phases de calcul et des phases de communication (avec éventuellement du recouvrement). Cela signifie que par moment, l'ensemble des processus communique. Si chaque processus doit échanger des données avec un ou plusieurs autres processus éloignés dans le réseau, les risques de mauvaises performances sont élevés. En effet, les liens réseaux sont partagés et si les processus ne sont pas voisins, les messages vont devoir en traverser plusieurs. Ces liens devront gérer une multitude de messages simultanément et forcément le débit de chaque communication sera réduit. Un autre effet de la non-proximité est l'augmentation des latences qui affecte plus particulièrement les messages de petites tailles.

Pour optimiser le placement, il faut essayer que toutes les communications point-à-point (ou utilisant le tore 3D) se fassent entre voisins directs ou encore mieux entre processus situés sur le même noeud de calcul.

Sur Babel, le placement des processus est statique. Cela signifie que lors du démarrage de l'application chaque processus est placé sur un coeur de calcul donné et ne bougera pas au cours de l'exécution. Il est possible d'imposer la position de chaque processus MPI en fonction de son rang dans MPI_COMM_WORLD sur le tore 3D. La procédure à suivre est détaillée dans la page web suivante ou dans le document Blue Gene/P Application Development.

Attention : par défaut, les travaux s'exécutent en mode mesh et non pas torus. Cela a pour conséquence que le réseau 3D obtenu n'est pas un tore car les connexions reliant les extrémités sont désactivées (par exemple, un noeud sur le côté gauche n'est pas directement connecté à un noeud du côté droit mais doit traverser tous les noeuds situés entre-eux). Pour activer le tore 3D (qui n'est disponible que pour les travaux utilisant un multiple de 512 noeuds de calcul), il faut utiliser la directive LoadLeveler # @ bg_connection = TORUS (pour plus de détails voir ici).

Conseils divers

Cette rubrique répertorie quelques bonnes pratiques à respecter pour obtenir de bonnes performances MPI et une bonne extensibilité. Il s'agit juste de pistes qui peuvent vous aider à améliorer votre code de calcul. Certains de ces conseils sont génériques et devraient donner de bons résultats sur tout type d'architecture, d'autres sont plus spécifiques à l'architecture Blue Gene/P.

  • Utiliser les communications point-à-point non-bloquantes (voir section Recouvrement calculs/communications).
  • Communiquer avec les voisins proches dans le réseau (voir section Placement des processus).
  • Eviter d'utiliser les envois bufferisés (MPI_Bsend par exemple) car cela nécessite des copies mémoires supplémentaires qui sont coûteuses en temps et en occupation mémoire. Les envois standards (MPI_Send) sont généralement plus performants.
  • Eviter d'utiliser les envois synchrones (MPI_Ssend par exemple) car ce sont des opérations non-locales et qui entraîne donc des latences supplémentaires. Les envois standards (MPI_Send) sont généralement plus performants.
  • Eviter les types dérivés non-contigus en mémoire car la bibliothèque MPI doit réaliser des opérations de packing et donc des copies mémoires et une utilisation plus importante de la mémoire pour le stockage. Ils peuvent également poser des problèmes d'alignements mémoires non-optimaux.
  • Faire attention à ne pas avoir trop de messages en cours de transfert simultanément à un endroit donné (via des MPI_Isend par exemple). Ce problème est accentué pour protocole eager (petits messages).
  • Ne pas saturer de messages certains processus (envoi d'un message par tous les processus à un seul autre). Cela risque de saturer la mémoire du processus récepteur et également d'introduire un point d'étranglement dans l'application.
  • En envoi non-bloquant (MPI_Isend), ne jamais écrire ni lire (interdit par la norme MPI) dans l'espace mémoire envoyé avant de vérifier que l'envoi s'est bien terminé. La lecture fonctionne actuellement dans l'implémentation Blue Gene/P de MPI mais cela n'est pas garanti dans le futur car il y a des risques d'interaction avec le moteur DMA.
  • Limiter le nombre de messages : il vaut mieux envoyer un gros message plutôt que de multiples petits.
  • Utiliser le plus possible les communications collectives par rapport aux point-à-point (sauf si toutes les communications ont lieu avec des voisins directs).
  • Eviter les barrières de synchronisation (MPI_Barrier). La plupart d'entre-elles ne sont pas nécessaire.
  • Ne communiquer que lorsque c'est nécessaire. Il faut éviter d'envoyer des messages contenant des informations inutiles ou qui n'ont pas changé entre deux itérations par exemple.

Variables d'environnement

Certaines variables d'environnement peuvent avoir un impact important sur les performances des communication MPI. Un avantage de celles-ci est qu'il n'est pas nécessaire de modifier votre code source, ni même de recompiler votre application.

Les variables d'environnement les plus importantes sont décrites ci-dessous. Il faut les passer dans les arguments de la commande mpirun en utilisant l'option -env variable1=valeur1 variable2=valeur2 …

  • DCMF_EAGER (1200 par défaut) : taille en octets à partir de laquelle les envois standards (MPI_Send) utilisent le protocole de rendez-vous. Tous les messages de taille inférieure seront bufferisés et les autres envoyés de façon synchrone.
  • DCMF_INTERRUPT (0 par défaut) : active (si mis à 1) la gestion des messages par interruption. Il est nécessaire de mettre cette variable à 1 pour utiliser pleinement le moteur DMA et les possibilités de recouvrement des communications par des calculs.
  • DCMF_TOPOLOGY (1 par défaut) : si mis à 1, l'implémentation MPI tentera d'optimiser la forme des topologies cartésiennes lors des appels à MPI_Dims_create et à MPI_Cart_create pour coller au mieux à la topologie physique de la machine. Cela fonctionne principalement pour les topologies cartésiennes 3D et 4D. Par contre, la norme MPI n'est pas respectée. Mettre cette variable à 0 désactive ces optimisations mais garantit que la norme MPI est respectée.
  • DCMF_COLLECTIVE (1 par défaut) : utilise les procédures de communications collectives optimisées pour Blue Gene/P. Si désactivées (valeur à 0), utilise les versions non optimisées de MPICH2 et permet d'économiser environ 10 Mo de mémoire.

Programmation mixte MPI/OpenMP

La programmation mixte MPI/OpenMP peut apporter des gains d'extensibilité. OpenMP ou l'approche multithreads ne peut fonctionner qu'à l'intérieur d'un noeud à mémoire partagée. Sur Babel, cela limite donc à 4 threads par processus car il y a 4 coeurs par noeud de calcul (il faut alors fonctionner en mode d'exécution VN).

Par contre, il est tout à fait possible d'avoir sur chaque noeud de calcul un processus MPI ayant plusieurs threads OpenMP. Cette technologie à plusieurs atouts :

  • pour un nombre donné de coeurs, le nombre de processus MPI est diminué et donc le nombre de messages. De plus, les messages sont plus gros et les latences sont donc moins critiques. L'application passera donc normalement moins de temps dans les communications ;
  • les besoins en mémoire sont réduits grâce au partage de mémoire entre les threads d'un processus.

Pour que cette approche fonctionne bien, il est évidemment nécessaire d'avoir de bonnes performances OpenMP à l'intérieur de chaque processus MPI.

Pour en savoir plus

Les principaux documents concernant la Blue Gene/P traitent également de l'optimisation et de MPI ou peuvent vous aider à mieux comprendre le fonctionnement de Babel.