Survol de MPI

Il est hautement probable que vous soyez amené(e)s à réaliser un travail pratique avec une implémentation MPI de votre choix. En ce sens, la présente se veut une aide au démarrage, donc un premier programme donnant quelques pistes d'exploration et indiquant quelques pièges attendant les débutant(e)s avec un tel outil.

Ce qu'est MPI

Par l'acronyme MPI, on entend Message Passing Interface, mais cela ne dit pas grand chose. Les serveurs MPI prennent en charge des exécutables respectant certaines règles structurelles (en particulier, qui sont liés avec les bibliothèques statiques et dynamiques appropriétes) et leur constituent un « monde » d'une certaine taille. Plusieurs implémentations MPI existent dans le monde; j'ai personnellement utilisé Deino MPI et Open MPI, et les deux m'ont donné de bons résultats, mais vous pouvez en explorer d'autres si le coeur vous en dit. Présentement, j'utilise MS MPI qui s'intègre bien à Visual Studio mais est très mal documenté.

C'est d'ailleurs pour apprivoiser ces pratiques que nous avons examiné en classe un moteur d'interopérabilité commercial (COM, mais ça aurait pu être CORBA, ICE, etc.), les concepts impliqués étant les mêmes.

À l'intérieur de ce monde, plusieurs processus communiquent en se passant des messages, sérialisés (par marshalling) de manière à ce que chaque homologue comprennent et manipulent un même format.

Les fonctions qu'offre MPI pour sérialiser les données primitives sont d'assez bas niveau mais rien ne vous empêche de construire une strate de proxy par-dessus celles-ci. Si vous comptez utiliser MPI ou une infrastructure semblable dans le futur, travailler avec un peu plus de soin aujourd'hui peut s'avérer profitable demain.

Pour explorer les fonctions de communication de MPI, MPI_Send() et MPI_Recv() sont de bons points de départ, ces fonctions étant simples à utiliser et bloquantes.

Il est toutefois possible que vous souhaitiez aller plus loin; je vous propose donc de consulter l'aide en ligne de l'API d'Open MPI (une implémentation MPI parmi plusieurs, mais c'est un site simple et bien fait) dont la plus récente version stable au moment d'écrire ces lignes est http://www.open-mpi.org/doc/v1.6/. Sachez toutefois que si jamais cette aide ne vous convient pas, il existe d'autres sources telles que http://mpi.deino.net/mpi/mpi_functions/

Il se trouve que l'approche MPI est surtout présente dans le monde du calcul réparti à très haute performance, ce qu'on nomme du High Performance Computing (HPC). À titre d'exemple, près de nous à Sherbrooke, les gens qui utilisent Mammouth le font souvent à travers MPI.

Les implémentations MPI que je connais sont axées vers les langages C et Fortran, mais supportent C++ et – à travers l'API C, dans la plupart des cas – bien d'autres langages. Je ne sais pas à quel point Java et les langages .NET sont supportés, mais si vous y tenez, il est toujours possible d'interfacer ces langages avec C ou C++ (je peux vous donner des trucs, incluant ceci).

Premier programme avec MPI et Visual Studio

Ce qui suit est un petit programme utilisant MPI avec C++ sous Visual Studio (2013), donc avec le compilateur MSVC. Si vous utilisez g++, notez que la documentation sera en général meilleure (le monde du HPC avec MPI est un monde très orienté Unix/ Linux) et vous n'aurez probablement pas besoin d'autant de soutien. Il est possible que le code source proposé ici vous intéresse malgré tout, étant indépendant du système d'exploitation ou du compilateur.

Parce que j'ai utilisé MS MPI, j'ai téléchargé un installateur puis ajouté les dossiers et les .lib requis à la configuration du projet. La conplexité de la configuration que vous aurez à faire dépendra de votre choix d'outils; lisez la documentation de votre produit de prédilection avec soin.

Quelques a priori

Raccourcis vers les diverses étapes a priori :

Ce qui suit est un bref des étapes par lesquelles je suis arrivé à une implémentation fonctionnelle. Vous pouvez vous y référer ou vous en inspirer si vous avez pris d'autres chemins pour en arriver à une solution qui vous ressemble. Notez que je peux vous aider de plusieurs manières mais que je ne connais pas tous les détails de tous les outils possibles et impossibles, alors ne vous fâchez pas si je n'ai pas immédiatement ce qu'il faut pour vous dépanner dans tous les cas.

Installer une implémentation MPI

Le code client pour MPI est assez portable d'une implémentation à l'autre, mais il demeure nécessaire d'avoir installé une implémentation ou l'autre de MPI au préalable.

Utiliser Boost ou non

Je souhaitais utiliser Boost.MPI, qui est une couche rendant l'utilisation de MPI nettement plus conviviale, mais je n'ai pas eu le temps de bâtir cette bibliothèque à travers mes autres tâches d'enseignement et d'étudiant au doctorat. Désolé! Cependant, si vous en avez envie (et si vous êtes moins coincés par le temps que moi), je suis certain que l'investissement initial requis pour mettre les outils en place peut rapporter gros.

Créer un projet

Pour cette démonstration, j'ai fait un simple programme console natif; un projet vide de prime abord, le code des générateurs de code de Microsoft étant en général très mauvais pour le genre de programmation que je fais.

Configurer le répertoire des fichiers à inclure et configurer le répertoire des bibliothèques statiques

Pour assurer la bonne compilation du code avec Open MPI, j'ai ajouté aux répertoires standards de Visual Studio le chemin vers les fichiers d'en-tête qui accompagnent cette implémentation. Chez moi, à partir du menu contextuel Propriétés de la solution, Propriétés de configuration, Répertoires VC++, j'ai ajouté les répertoires pour les fichiers d'en-tête (Includes) et les bibliothèques (Libraries) appropriés. Il y aura des bibliothèques statiques à ajouter à la liste de celles utilisées par le projet, mais celles-ci varieront en fonction de ce que vous aurez utilisé.

Indiquer les directives du préprocesseur requises

Enfin, avec Open MPI, si vous compilez le code tel quel, vous aurez encore quelques erreurs. Il se trouve que Open MPI compile différemment si le symbole du préprocesseur OMPI_IMPORTS n'est pas défini, alors prenez soin de le définir (le plus simple est de le définir globalement en l'ajoutant la la liste que vous trouverez dans Propriétés de la solution, Propriétés de configuration, C/C++, Préprocesseur, Définitions du préprocesseur).

Adapter la variable d'environnement PATH

Enfin, selon les implémentations de MPI, il arrivera que le code client doive être lié dynamiquement à partir de quelques DLL. Étant donné que le système d'exploitation fouille entre autres dans les répertoires listés dans la variable d'environnement PATH lorsqu'il cherche des DLL, vous devrez peut-être ajouter à cette variable d'environnement le chemin requis.

Exécuter un processus MPI

Bien que vous puissiez développer un processus destiné à MPI avec g++ ou dans Visual Studio, on ne démarre généralement pas un tel processus par et pour lui-même. En effet, un processus MPI est typiquement lancé par MPI, qui contrôle son monde (la grappe toute entière) et assure la communication entre chacun des processus qui y sont impliqués.

Présumant que vous ayez compilé un programme reposant sur MPI sous Windows et que vous soyez à la ligne de commande dans le répertoire où se trouve l'exécutable généré, un lancement correct serait :

mpiexec -n 6 test_mpi.exe 3

Notez que mpiexec est le nom du serveur MPI de Microsoft, mais il se peut que vous ayez un autre nom de programme sous la main (p. ex. : mpirun). Peu importe. Ce qu'il faut lire ici est :

Ceci lancera donc six instances de test_ompi.exe en parallèle, et chaque main() recevra le paramètre " 3" . Notez que pour chacun, la taille de la grappe sera 6 mais le rang sera distinct (de 0 à 5 inclusivement).

Le code à proprement dit

Le code que j'ai écrit pour mon test est inspiré d'exemples pris dans Internet et légèrement adapté. Évidemment, la difficulté avec des outils comme une implémentation de MPI est rarement le code en soi; c'est plutôt la configuration de l'implémentation, règle générale, ce qui explique la section précédente.

Notez d'office que je présume ici que vous connaissez l'idiome de classe Incopiable.

Dans mon petit exemple, main() ne recevra pas de paramètres.

Remarquez tout d'abord que l'essentiel des fonctions clés de MPI se trouve déclaré dans l'en-tête <mpi.h>.

Pour de la documentation sur MPI_Init(), voir http://www.open-mpi.org/doc/v1.6/man3/MPI_Init.3.php.

Pour de la documentation sur MPI_Finalize(), voir http://www.open-mpi.org/doc/v1.6/man3/MPI_Finalize.3.php.

J'ai utilisé ici la fonction MPI_Init() avec paramètres pour le chargement de MPI dans mon programme, mais je n'en tire pas vraiment profit. Vous trouverez une version sans paramètres de la même fonction dans MPI; à vous de voir laquelle conviendra le mieux à vos besoins.

J'ai utilisé la notation entre crochets (réservée aux en-têtes jugés « standards ») du fait que j'ai pris soin de configurer mon outil de développement pour qu'il considère le répertoire des en-têtes de mon implémentation MPI comme en faisant partie.

Étant donné qu'une implémentation MPI demande d'être chargée explicitement puis déchargée explicitement, j'ai appliqué l'idiome RAII pour automatiser cette symétrie (et alléger le code client) avec une classe MPI_Scope, et j'ai appliqué l'idiome de classe incopiable pour éviter toute tentative (volontaire ou accidentelle) de dupliquer une instance de cette classe (et de décharger deux fois l'implémentation par le fait-même).

//
// Une sorte de "Hello World" pour les programmes MPI
//
#include <mpi.h>
#include <string>
#include <vector>
#include <algorithm>
#include <sstream>
#include <iostream>
using namespace std;
class MPI_Scope {
   int nprocs;
   int rang_;
   void initialiser_mpi(int &argc, char **argv)
   {
      MPI_Init(&argc,&argv); // une fois par processus, dans le thread principal
   }
public:
   MPI_Scope(const MPI_Scope&) = delete;
   MPI_Scope& operator=(const MPI_Scope&) = delete;
   MPI_Scope(int argc, char *argv[]) {
      initialiser_mpi(argc, argv);
      MPI_Comm_size(MPI_COMM_WORLD,&nprocs); // Taille du monde == combien de processus participent
      MPI_Comm_rank(MPI_COMM_WORLD,&rang_);  // Dans le monde, qui suis-je?
   }
   int nb_processus() const noexcept {
      return nprocs;
   }
   int rang() const noexcept {
      return rang_;
   }
    // une fois par processus, dans le thread principal
   ~MPI_Scope() {
      MPI_Finalize();
   }
};

Cet exemple impliquera l'envoi et la réception de chaînes de caractères. J'ai encapsulé ces opérations dans des fonctions un peu simplistes, mais celles-ci montrent la syntaxe à l'utilisation de fonctions MPI.

void send_string(const string &msg, int destinataire, int msg_tag) {
   MPI_Send(&msg[0], static_cast<int>(msg.size()), MPI_CHAR, destinataire, msg_tag, MPI_COMM_WORLD);
}
template <int BUFSIZE>
   string recv_string(int emetteur, int msg_tag) {
      char buff[BUFSIZE];
      MPI_Status stat; 
      MPI_Recv(buff, BUFSIZE, MPI_CHAR, emetteur, msg_tag, MPI_COMM_WORLD, &stat);
      int nrecus;
      MPI_Get_count(&stat, MPI_CHAR, &nrecus);
      return string{buff, buff + nrecus};
   }

Examinons maintenant le (petit) programme principal proposé en exemple. Celui-ci :

  • charge l'infrastructure MPI (à l'aide du MPI_Scope présenté plus haut)
  • identifie le rang du processus. Par rang, on entend un entier identifiant le processus de manière unique au monde; par convention, on considère souvent le processus de rang 0 comme la « maman » des autres
  • selon le rang du processus (0 ou non), s'ensuit soit une séquence d'envois de messages, soit une réception pour fins d'écho. Dans les deux cas, j'ai utilisé des fonctions bloquantes pour que le code soit simple
  • le processus de rang 0 envoie un message aux autres processus, qui lui répondront. Un affichage simple du fruit des actions posées a ensuite lieu
  • éventuellement, décharge (grâce à notre approche RAII) l'infrastructure MPI.

Tel que mentionné plus haut, notre programme principal pourrait accepter des paramètres. Ceux-ci seraient captés par le gestionnaire MPI utilisé pour lancer le programme et relayés à tous les processus participants.

int main(int argc, char *argv[]) {
   const int MSG_TAG = 0;
   MPI_Scope mpi_info(argc, argv);
   //
   // Initialement, tous les programmes sont équivalents. Le rang permet de leur attribuer
   // un rôle (typiquement, le processus de rang 0 a droit à un traitement particulier)
   //
   if(mpi_info.rang() == 0) {
      cout << "Rang: " << mpi_info.rang()
           << ", sur un total de " << mpi_info.nb_processus() << " processus"
           << endl;
      for(int i = 1; i < mpi_info.nb_processus(); ++i) {
         stringstream sstr;
         sstr << "Coucou " << i;
         send_string(sstr.str(), i, MSG_TAG);
      }
      for(int i = 1; i < mpi_info.nb_processus(); ++i) {
         enum { BUFSIZE = 128 };
         cout << mpi_info.rang() << ": " << recv_string<BUFSIZE>(i, MSG_TAG)<< endl;
     }
   } else {
      enum { BUFSIZE = 128 };
      // Recevoir du processus de rang 0...
      recv_string<BUFSIZE>(0, MSG_TAG);
      stringstream sstr;
      sstr << "Processus " << mpi_info.rang() << " au boulot";
      // Envoyer au processus de rang 0...
      send_string(sstr.str(), 0, MSG_TAG);
   }
}

Une exécution possible de ce programme serait :

mpiexec -n 4 "C:\Mr_Roy\Code\Code C++\IFT630\test_mpi\Release\test_mpi.exe"
Rang: 0, sur un total de 4 processus
0: Processus 1 au boulot
0: Processus 2 au boulot
0: Processus 3 au boulot

Remarquez qu'au démarrage, MPI est une implémentation particulière du modèle fork/join.

Liens complémentaires

Si vous souhaitez d'autres références :


Valid XHTML 1.0 Transitional

CSS Valide !