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

Introduction

La plupart des applications parallèles actuelles pouvant tourner sur Blue Gene/Q 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 cœurs pouvant être utilisé efficacement) d'un code de calcul.

Cette page a pour but de vous donner des éléments de réflexion 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. Une fois satisfait pour cette section, retourner au point 1.

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).

Équilibrage 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.

Les problèmes d'équilibrage de charge peuvent être mis en évidence à l'aide des outils de profilage MPI.

Recouvrement calculs/communications

La Blue Gene/Q possède un moteur DMA (Direct Memory Access) sur son réseau tore 5D. Il permet la transmission de messages entre les différents nœuds de la machine sans interrompre les cœurs. Un message peut donc être transféré pendant que les cœurs 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). 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/Q).

Attention : pour activer complétement le recouvrement calculs/communications sur Turing, il faut positionner la variable d'environnement PAMID_THREAD_MULTIPLE à 1 (en passant l'option --envs "PAMID_THREAD_MULTIPLE=1" à runjob). Cette option ne fonctionne pas si vous lancez 64 processus par nœud.

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 nœud de calcul). La Blue Gene/Q possède pour les communications MPI un réseau en tore 5D qui connecte chaque nœud de calcul à ses 2 voisins dans chaque direction (soit un total de 10 voisins). Ce réseau est utilisé par l'ensemble des communications.

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 5D 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 se fassent entre voisins directs ou encore mieux entre processus situés sur le même nœud de calcul.

Sur Turing, le placement des processus est statique. Cela signifie que lors du démarrage de l'application chaque processus est placé sur un cœur 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 5D. La procédure à suivre est détaillée dans la page web suivante ou dans le document Blue Gene/Q Application Development.

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/Q.

  • 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).
  • Éviter 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.
  • Éviter 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.
  • Éviter 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 dans l'espace mémoire envoyé avant de vérifier que l'envoi s'est bien terminé.
  • 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).
  • Éviter 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.

Programmation hybride MPI/OpenMP

La programmation hybride MPI/OpenMP peut apporter des gains d'extensibilité. OpenMP ou l'approche multithreads ne peut fonctionner qu'à l'intérieur d'un nœud à mémoire partagée : sur Turing, cela permet d'aller jusqu'à 64 threads par processus car il y a 16 cœurs par nœud de calcul, chaque cœur pouvant exécuter jusqu'à 4 threads (ou processus).

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

  • pour un nombre donné de cœurs, le nombre de processus MPI est diminué donc le nombre de messages diminue aussi. De plus, les messages sont plus gros et donc les latences sont moins critiques : l'application passera 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/Q traitent également de l'optimisation et de MPI ou peuvent vous aider à mieux comprendre le fonctionnement de Turing.