Courses

Sémantique des communications point à point

Dans cette série nous revenons sur les communications point-à-point. En effet, dans la série précédente nous avons uniquement présenté les commandes de base MPI_Send et MPI_Recv. Cette série complète les notions nécessaires pour bien comprendre le fonctionnement des communications point-à-point avec MPI.

Le but de cette série est que vous compreniez bien la signification de communication bloquante avec MPI ainsi que l’utilisation du tag et du status dans le cadre des opérations de communication point-à-point.

Routines bloquantes et non-bloquantes

Il peut arriver que les commandes MPI_Send et MPI_Recv ne soient pas suffisantes pour certains besoins.

On aimerait pouvoir spécifier plus en détail les modalités de transfert du message. MPI met à disposition toute une série de routines dont voici quelques-unes qui sont utiles.

Pour l'envoi d'un message:

  • MPI_Isend (Immediate Send): envoi non-bloquant
  • MPI_Ssend (Synchronous Send): envoi bloquant
  • MPI_Bsend (Buffered Send): envoi bloquant par une mémoire tampon

Pour la réception d'un message:

  • MPI_Irecv (Immediate Receive): réception non-bloquante

On s'intéressera aussi aux deux fonctions suivantes qui permettent de vérifier la complétion locale d'un appel non-bloquant:

  • MPI_Wait Attente de la complétion locale
  • MPI_Test Vérification de la complétion locale

Complétion globale et complétion locale

MPI associe à toutes les routines de communication une notion de complétion locale et de complétion globale. Rappelons à cet effet que les routines en question prennent toutes un paramètre qui est un pointeur sur un bloc de mémoire indiquant l'emplacement du message à envoyer ou à recevoir.

On dit que les routines ont achevé leur tâche localement lorsque ledit bloc de mémoire peut être modifié à nouveau sans altérer le transfert du message. On parle de complétion locale. La complétion globale est atteinte lorsque tous les processus impliqués dans la communication ont atteint la complétion locale.

Prenons comme exemple le cas où MPI transfère un message en le recopiant d'abord dans une mémoire tampon, par exemple à travers un appel à la routine MPI_Bsend, accompagné d'un MPI_Recv sur le processeur correspondant. Sur le processeur qui envoie le message, la complétion locale est atteinte très rapidement, dès que le message a été transféré dans la mémoire tampon. Dès ce moment le bloc de mémoire associé au message peut être modifié sans danger. Sur l'autre processeur la complétion locale n'est atteinte qu'après la réception intégrale du message. Cet événement coïncide donc en plus avec la complétion globale du transfert.

On est à présent en position de discuter la notion de routine bloquante et de routine non bloquante: une routine est bloquante si et seulement si elle attend la complétion locale avant de se terminer et de permettre au programme de poursuivre.

Les seules routines non bloquantes discutées ici sont MPI_Isend pour l'envoi et MPI_Irecv pour la réception. Si on veut s'en servir, on est obligé d'utiliser soit MPI_Wait soit MPI_Test pour s'assurer d'avoir atteint la complétion locale.

Après toute cette théorie, il est naturel de s'interroger à propos des fonctions MPI_Send et MPI_Recv

  1. Sont-elles bloquantes? Réponse: elles le sont.
  2. Utilisent-elles une mémoire tampon? Réponse: Ça dépend! Le standard MPI ne dit rien à ce sujet. Une implémentation de MPI peut définir la routine MPI_Send à choix par un MPI_Ssend ou un MPI_Bsend. Surtout, ne présupposez jamais ni l'un ni l'autre lorsque vous écrivez un programme!

Exemple d'utilisation d'un Isend et d'un Irecv :

#include <mpi.h>
#include <iostream>
#include <vector>
#include <cassert> 
 
int main(int argc, char **argv) {
  int myRank, nProc;
  MPI_Status status;
  MPI_Request request1;
  MPI_Request request2;
  MPI_Init(&argc, &argv);
  MPI_Comm_rank(MPI_COMM_WORLD, &myRank);
  MPI_Comm_size(MPI_COMM_WORLD, &nProc);
 
  assert(nProc==2);
 
  std::vector<int> send(1000000, myRank);
  std::vector<int> recv(1000000, -1);
 
  int dest=0;
  if(myRank==0) dest=1;
 
  MPI_Isend(send.data(), 1000000, MPI_INT, dest, 0, MPI_COMM_WORLD, &request1);
  MPI_Irecv(recv.data(), 1000000, MPI_INT, MPI_ANY_SOURCE, 0, MPI_COMM_WORLD, &request2);
 
  MPI_Wait(&request1, &status);
  MPI_Wait(&request2, &status);
 
  std::cout << "I'm process " << myRank << ", last element of my recv vector is " << recv.back() << std::endl;
 
  MPI_Finalize();
}

Exercice 1

Exécutez le code suivant, qu'observez-vous ? quelle conclusion pouvez-vous tirer sur l'implémentation de MPI_Send dans ce cas ?

#include <mpi.h>
#include <iostream>
#include <vector>
#include <cassert>
 
const int nbEnvois = 8;
 
int main(int argc, char **argv) {
  int myRank, nProc;
  MPI_Status status;
  MPI_Init(&argc, &argv);
  MPI_Comm_rank(MPI_COMM_WORLD, &myRank);
  MPI_Comm_size(MPI_COMM_WORLD, &nProc);
 
  assert(nProc==2);
 
  int size = 1;
  for (int iSize=1; iSize<nbEnvois; ++iSize) {
    std::vector<int> buffer(size);
 
    if (myRank == 0) {
      std::cout << "Envoi d\'un message de " << size << " entiers" << std::endl;
      MPI_Send(buffer.data(), size, MPI_INT, 1, 0, MPI_COMM_WORLD);
      MPI_Recv(buffer.data(), size, MPI_INT, 1, 0,MPI_COMM_WORLD, &status);
    }
    else {
      MPI_Send(buffer.data(), size, MPI_INT, 0, 0, MPI_COMM_WORLD);
      MPI_Recv(buffer.data(), size, MPI_INT, 0, 0, MPI_COMM_WORLD, &status);
    }
    size *= 10;
  }
 
  if (myRank == 0) {
    std::cout << "Fin des envois" << std::endl;
  }
 
  MPI_Finalize();
}

Exécutez le code suivant. Arrivez-vous à expliquer le deadlock dans ce cas ? Proposez une implémentation permettant d'éviter le deadlock. L'utilisation d'un Isend permet de résoudre le problème:

#include <mpi.h>
#include <iostream>
#include <vector>
 
const int size = 1000000;
 
int main(int argc, char **argv) {
  int myRank, nProc;
  MPI_Status status;
  MPI_Init(&argc, &argv);
  MPI_Comm_rank(MPI_COMM_WORLD, &myRank);
  MPI_Comm_size(MPI_COMM_WORLD, &nProc);
 
  const int voisin_gauche = (myRank+nProc-1) % nProc;
  const int voisin_droite = (myRank+1) % nProc;
 
  std::vector<int> bufSend(size);
  std::vector<int> bufRecv(size);
 
  bufSend[0] = myRank;
 
  for (int iEnvoi = 0; iEnvoi < nProc; ++iEnvoi) {
    // MPI_Send(bufSend.data(), size, MPI_INT, voisin_droite, 0, MPI_COMM_WORLD);
    MPI_Request request;
    MPI_Isend(bufSend.data(), size, MPI_INT, voisin_droite, 0, MPI_COMM_WORLD, &request);
    MPI_Recv(bufRecv.data(), size, MPI_INT, voisin_gauche, 0, MPI_COMM_WORLD, &status);
    MPI_Wait(&request, &status);
    bufSend[0] = bufRecv[0];
    if (myRank == 0) {
      std::cout << "Le message actuel est " << bufRecv[0] << std::endl;
    }
  }
 
  MPI_Finalize();
}

Informations sur les arguments des routines de communication

L'enveloppe d'un message

Lorsque un message est envoyé avec MPI celui-ci est reçu par un destinataire uniquement si l'enveloppe du message correspond aux paramètres spécifiés chez le destinataire. L'enveloppe contient donc les informations utilisées pour identifier l'opération de réception correspondant à une opération d'envoi. Les données faisant partie de l'enveloppe sont:

  • la source ou la destination;
  • le tag;
  • le communicateur.

On constate que ni la taille du message, ni le type de donnée du message sont utilisés. Jusqu'à présent, avec MPI, vous avez toujours spécifié la source d'un message et le tag, or il est possible de rendre l'opération de réception uniquement dépendante du communicateur.

Le joker MPI_ANY_SOURCE

Le joker MPI_ANY_SOURCE permet au programmeur de s'affranchir de spécifier la source du message lors d'une étape de réception. Ce joker prend la place de l'argument source dans les routines de réception de MPI. Par exemple avec la routine MPI_Recv:

MPI_Recv( buffer.data(), 10, MPI_DOUBLE, MPI_ANY_SOURCE, 0, MPI_COMM_WORLD, &status );

Le tag

Le tag est un entier non négatif choisi par le programmeur pour identifier un message de manière unique. Les tags des opérations d'envoi et de réception de message doivent toujours correspondre. Le standard MPI garantit que les entiers de 0 à 32767 peuvent être utilisés. Toutefois la majorité des implantations supportent un intervalle beaucoup plus grand.

Comme le tag fait partie de l'enveloppe d'un message, ce dernier est utilisé pour créer plusieurs canaux de communication lors d'un envoi et d'une réception de message. On l'utilise donc pour identifier l'opération de réception correspondante à une opération d'envoi.

Exemple

L'exemple suivant illustre un cas typique d'utilisation de différents tags et du joker MPI_ANY_SOURCE. Dans cet exemple on souhaite procéder au calcul de deux sommes dont les types de données sont différents: une somme d'entiers et une somme de réels. Chaque nœud de calcul envoie un entier et un réel qui vont être sommés par le nœud de rang 0. Comme on ne veut pas être dépendant du rang pour réceptionner le message (p.ex. pour des raisons d'équilibre de charge), ceci pose deux problèmes:

  • on ne peut pas prédire l'ordre d'arrivée des messages;
  • le type de donnée ne fait pas partie de l'enveloppe.

Une solution possible revient à ne pas vérifier la source d'un message et d'utiliser un tag par type de donnée:

int TAG_INT = 100;
int TAG_FLT = 200;
 
MPI_Send( &intVar, 1, MPI_INT, 0, TAG_INT, MPI_COMM_WORLD );
MPI_Send( &fltVar, 1, MPI_FLOAT, 0, TAG_FLT, MPI_COMM_WORLD );
 
if ( myrank == 0 ) {
  MPI_Status status;  
 
  int intRecv;
  int intSum = 0;  
 
  for ( i = 0; i < nProcs; i++ ) {    
    MPI_Recv( &intRecv, 1, MPI_INT, MPI_ANY_SOURCE, TAG_INT, MPI_COMM_WORLD, &status );
    intSum += intRecv;
  }
 
  float fltRecv;
  float fltSum = 0;
 
  for ( i = 0; i < nProcs; i++ ) {
    MPI_Recv( &fltRecv, 1, MPI_FLOAT, MPI_ANY_SOURCE, TAG_FLT, MPI_COMM_WORLD, &status );
    fltSum += fltRecv;
  }
}

Le joker MPI_ANY_TAG

On précise aussi qu'il existe un joker nommé MPI_ANY_TAG permettant de s'affranchir du tag lors des opérations de communication avec MPI. Ce dernier est passé en argument en place du tag:

MPI_Recv( buffer.data(), 10, MPI_DOUBLE, 0, MPI_ANY_TAG, MPI_COMM_WORLD, &status );

Le status MPI

Le type MPI_Status est une structure contenant les trois éléments suivants:

  • MPI_SOURCE: la source du message;
  • MPI_TAG: le tag attaché au message;
  • MPI_ERROR: l'erreur si un problème a été rencontré lors de la communication.

Le programmeur accède simplement à ces éléments de la manière suivante:

int recv_tag = status.MPI_TAG;

dans ce cas la variable recv_tag contiendra le tag attaché à la communication correspondante.

Exercice 2

Écrivez un programme où le nœud de calcul de rang 0 reçoit des autres nœuds de calcul un tableau dont la taille est uniquement connue au moment de l'exécution. Vous devez utiliser MPI_ANY_SOURCE pour la réception des tailles. Le programme doit fonctionner de la manière suivante:

  • les nœuds de rang 1 à N-1 envoient la taille du tableau (un entier);
  • les nœuds de rang 1 à N-1 envoient le tableau d'entiers (c.-à-d. la donnée);

et:

  • le nœud de rang 0 reçoit la taille de chaque tableau
  • le nœud de rang 0 reçoit chaque tableau affiche son contenu.

-> proposition de solution (Moodle: Sémantique des communications point-à-point Dossier)