MPI
Introduction à la programmation parallèle
To pull a bigger wagon it is easier to add more oxen than to find (or build) a bigger ox. [traduction libre, Pour tirer une plus grosse charrette, il est plus facile d'ajouter des bœufs que de trouver un plus gros bœuf.]
—Gropp, Lusk & Skjellum, Using MPI
Pour construire une maison le plus rapidement possible, on n'engage pas la personne qui peut faire tout le travail plus rapidement que les autres. On distribue plutôt le travail parmi autant de personnes qu'il faut pour que les tâches se fassent en même temps, d'une manière parallèle. Cette solution est valide aussi pour les problèmes numériques. Comme il y a une limite à vitesse d'exécution d'un processeur, la fragmentation du problème permet d'assigner des tâches à exécuter en parallèle par plusieurs processeurs. Cette approche sert autant la vitesse du calcul que les exigences élevées en mémoire.
L'aspect le plus important dans la conception et le développement de programmes parallèles est la communication. Ce sont les exigences de la communication qui créent la complexité. Pour que plusieurs travailleurs accomplissent une tâche en parallèle, ils doivent pouvoir communiquer. De la même manière, plusieurs processus logiciels qui travaillent chacun sur une partie d'un problème ont besoin de valeurs qui sont ou seront calculées par d'autres processus.
Il y a deux modèles principaux en programmation parallèle : les programmes à mémoire partagée et les programmes à mémoire distribuée.
Dans le cas d'une parallélisation avec mémoire partagée (SMP pour shared memory parallelism), les processeurs voient tous la même image mémoire, c'est-à-dire que la mémoire peut être adressée globalement et tous les processeurs y ont accès. Sur une machine SMP, les processeurs communiquent de façon implicite; chacun des processeurs peut lire et écrire en mémoire et les autres processeurs peuvent y accéder et les utiliser. Le défi ici est la cohérence des données puisqu'il faut veiller à ce que les données ne soient modifiées que par un seul processus à la fois.
Pour sa part, la parallélisation avec mémoire distribuée s'apparente à une grappe, un ensemble d'ordinateurs reliés par un réseau de communication dédié. Dans ce modèle, les processus possèdent chacun leur propre mémoire et ils peuvent être exécutés sur plusieurs ordinateurs distincts. Les processus communiquent par messages : un processus utilise une fonction pour envoyer un message et l'autre processus utilise une autre fonction pour recevoir le message. Le principal défi ici est d'avoir le moins de communications possible. Même les réseaux avec les connexions physiques les plus rapides transmettent les données beaucoup plus lentement qu'un simple ordinateur : l'accès mémoire se mesure habituellement en centièmes de nanosecondes alors que les réseaux y accèdent généralement en microsecondes.
Nous discuterons ici uniquement de la programmation avec mémoire distribuée sur grappe, avec MPI.
Qu'est-ce que MPI?
MPI (message passing interface) est en réalité une norme avec des sous-routines, fonctions, objets et autres éléments pour développer des programmes parallèles dans un environnement à mémoire distribuée. MPI est implémentée dans plusieurs bibliothèques, notamment Open MPI, Intel MPI, MPICH et MVAPICH. La norme décrit comment MPI est appelé par Fortran, C et C++, mais il existe aussi des interfaces pour plusieurs autres langages (Boost.MPI, mpi4py, Rmpi, etc.). La version MPI 3.0 ne prend plus en charge les interfaces C++, mais vous pouvez utiliser les interfaces C de C++ ou Boost MPI. Les exemples avec Python utilisent le MPI du paquet Python mpi4py.
Puisque MPI est une norme ouverte sans droits exclusifs, un programme MPI peut facilement être porté sur plusieurs ordinateurs différents. Les programmes MPI peuvent être exécutés concurremment sur plusieurs cœurs à la fois et offrent une parallélisation efficace, permettant une bonne scalabilité. Puisque chaque processus possède sa propre plage mémoire, certaines opérations de débogage s'en trouvent simplifié es; en ayant des plages mémoire distinctes, les processus n’auront aucun conflit d’accès à la mémoire comme c'est le cas en mémoire partagée. Aussi, en présence d'une erreur de segmentation, le fichier core résultant peut être traité par des outils standards de débogage série. Le besoin de gérer la communication et la synchronisation de façon explicite donne par contre l'impression qu'un programme MPI est plus complexe qu'un autre programme où la gestion de la communication serait implicite. Il est cependant recommandé de restreindre les communications entre processus pour favoriser la vitesse de calcul d'un programme MPI.
Nous verrons plus loin quelques-uns de ces points et proposerons des stratégies de solution; les références mentionnées au bas de cette page sont aussi à consulter.
Principes de base
Dans ce tutoriel, nous présenterons le développement d'un code MPI en C, C++, Fortran et Python, mais les différents principes de communication s'appliquent à tout langage qui possède une interface avec MPI. Notre but ici est de paralléliser le programme simple "Hello World" utilisé dans les exemples.
#include <stdio.h>
int main()
{
printf("Hello, world!\n");
return(0);
}
#include <iostream>
using namespace std;
int main()
{
cout << "Hello, world!" << endl;
return 0;
}
program hello
print *, 'Hello, world!'
end program hello
print('Hello, world!')
Pour compiler et exécuter le programme :
[~]$ vi hello.c [~]$ cc -Wall hello.c -o hello [~]$ ./hello Hello, world!
Modèle SPMD
La parallélisation MPI utilise le modèle d'exécution SPMD (single program multiple data), où plusieurs instances s'exécutent en même temps. Chacune des instances est un processus auquel est assigné un numéro unique qui représente son rang; l'instance peut obtenir son rang lorsqu'elle est lancée. Afin d'attribuer un comportement différent à chaque instance, on utilisera habituellement un énoncé conditionnel if.
Cadre d'exécution
Un programme MPI doit comprendre le fichier entête approprié ou utiliser le modèle approprié (mpi.h pour C/C++, mpif.h, use mpi, ou use mpi_f08 pour Fortran, sachant que mpif.h est fortement déconseillé et mpi_f08 recommandé pour Fortran 2008). Il peut donc être compilé puis relié à l'implémentation MPI de votre choix. Dans la plupart des cas, l'implémentation possède un script pratique qui enveloppe l'appel au compilateur (compiler wrapper) et qui configure adéquatement include
et lib
, entre autres pour relier les indicateurs. Nos exemples utilisent les scripts de compilation suivants :
- pour le C, mpicc
- pour le Fortran, mpifort (recommandé) ou mpif90
- pour le C++, mpiCC ou mpicxx
Une fois les instances lancées, elles doivent se coordonner, ce qui se fait en tout premier lieu par l'appel d'une fonction d'initialisation :
int MPI_Init(int *argc, char **argv[]);
boost::mpi::environment(int &, char **&, bool = true);
MPI_INIT(IERR)
INTEGER :: IERR
MPI_Init(ierr)
INTEGER, OPTIONAL, INTENT(OUT) :: ierr
# importing automatically initializes MPI with mpi4py
MPI.Init()
En C, les arguments de MPI_Init
pointent vers les variables argc
et argv
qui sont les arguments en ligne de commande. Comme pour toutes les fonctions MPI en C, la valeur retournée représente l'erreur de la fonction. En Fortran, les routines MPI retournent l'erreur dans l'argument IERR
, ce qui est optionnel avec use mpi_f08.
On doit aussi appeler la fonction MPI_Finalize
pour faire un nettoyage avant la fin du programme, le cas échéant :
int MPI_Finalize(void);
Nothing needed
MPI_FINALIZE(IERR)
INTEGER :: IERR
MPI_Finalize(ierr)
INTEGER, OPTIONAL, INTENT(OUT) :: ierr
# mpi4py installs a termination hook so there is no need to explicitly call MPI.Finalize.
MPI.Finalize()
Règle générale, il est recommandé d'appeler MPI_Init
au tout début du programme et MPI_Finalize
à la toute fin.
#include <stdio.h>
#include <mpi.h>
int main(int argc, char *argv[])
{
MPI_Init(&argc, &argv);
printf("Hello, world!\n");
MPI_Finalize();
return(0);
}
#include <iostream>
#include <boost/mpi.hpp>
using namespace std;
using namespace boost;
int main(int argc, char *argv[])
{
mpi::environment env(argc, argv);
cout << "Hello, world!" << endl;
return 0;
}
program phello0
use mpi
implicit none
integer :: ierror
call MPI_INIT(ierror)
print *, 'Hello, world!'
call MPI_FINALIZE(ierror)
end program phello0
program phello0
use mpi_f08
implicit none
call MPI_Init()
print *, 'Hello, world!'
call MPI_Finalize()
end program phello0
from mpi4py import MPI
print('Hello, world!')
Fonctions rank et size
Le programme pourrait être exécuté tel quel, mais le résultat ne serait pas très convaincant puisque chacun des processus produirait le même message. Nous allons plutôt faire en sorte que chaque processus fasse afficher la valeur de son rang et le nombre total de processus en opération.
int MPI_Comm_size(MPI_Comm comm, int *nproc);
int MPI_Comm_rank(MPI_Comm comm, int *myrank);
int mpi::communicator::size();
int mpi::communicator::rank();
MPI_COMM_SIZE(COMM, NPROC, IERR)
INTEGER :: COMM, NPROC, IERR
MPI_COMM_RANK(COMM, RANK, IERR)
INTEGER :: COMM, RANK, IERR
MPI_Comm_size(comm, size, ierr)
TYPE(MPI_Comm), INTENT(IN) :: comm
INTEGER, INTENT(OUT) :: size
INTEGER, OPTIONAL, INTENT(OUT) :: ierr
MPI_Comm_rank(comm, rank, ierr)
TYPE(MPI_Comm), INTENT(IN) :: comm
INTEGER, INTENT(OUT) :: rank
INTEGER, OPTIONAL, INTENT(OUT) :: ierr
MPI.Intracomm.Get_rank(self)
MPI.Intracomm.Get_size(self)
Le paramètre de sortie nproc est donné à la fonction MPI_Comm_size afin d'obtenir le nombre de processus en opération. De même, le paramètre de sortie myrank est donné à la fonction MPI_Comm_rank afin d'obtenir la valeur du rang du processus actuel. Le rang du premier processus a la valeur de 0 au lieu de 1; pour N processus, les valeurs de rang vont donc de 0 à (N-1) inclusivement. L'argument comm est un communicateur, soit un ensemble de processus pouvant s'envoyer entre eux des messages. Dans nos exemples, nous utilisons la valeur de MPI_COMM_WORLD, soit un communicateur prédéfini par MPI et qui représente l'ensemble des processus lancés par la tâche. Nous n'abordons pas ici le sujet des communicateurs créés par programmation; voyez plutôt la liste des autres sujets en bas de page.
Utilisons maintenant ces fonctions pour que chaque processus produise le résultat voulu. Notez que, puisque les processus effectuent tous le même appel de fonction, il n'est pas nécessaire d'introduire des énoncés conditionnels.
#include <stdio.h>
#include <mpi.h>
int main(int argc, char *argv[])
{
int rank, size;
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);
printf("Hello, world! "
"from process %d of %d\n", rank, size);
MPI_Finalize();
return(0);
}
#include <iostream>
#include <boost/mpi.hpp>
using namespace std;
using namespace boost;
int main(int argc, char *argv[])
{
mpi::environment env(argc, argv);
mpi::communicator world;
cout << "Hello, world! from process " << world.rank() << " of " << world.size() << endl;
return 0;
}
program phello1
use mpi
implicit none
integer :: rank, size, ierror
call MPI_INIT(ierror)
call MPI_COMM_SIZE(MPI_COMM_WORLD, size, ierror)
call MPI_COMM_RANK(MPI_COMM_WORLD, rank, ierror)
print *, 'Hello from process ', rank, ' of ', size
call MPI_FINALIZE(ierror)
end program phello1
program phello1
use mpi_f08
implicit none
integer :: rank, size
call MPI_Init()
call MPI_Comm_size(MPI_COMM_WORLD, size)
call MPI_Comm_rank(MPI_COMM_WORLD, rank)
print *, 'Hello from process ', rank, ' of ', size
call MPI_Finalize(ierror)
end program phello1
from mpi4py import MPI
comm = MPI.COMM_WORLD
size = comm.Get_size()
rank = comm.Get_rank()
print('Hello from process %d of %d'%(rank, size))
Compilez maintenant ce programme et faites-le exécuter avec 2, 4 et 8 processus. Vous remarquerez que le résultat produit par chacun des processus dépend de la valeur de ses variables locales et que le résultat final est la concaténation de la sortie standard (stdout) de tous les processus. Vous constaterez sans doute que les sorties produites par les processus ne sont pas nécessairement ordonnées selon leur rang : il n'est pas possible de prévoir l'ordre en sortie. [~]$ vi phello1.c [~]$ mpicc -Wall phello1.c -o phello1 [~]$ mpirun -np 4 ./phello1 Hello, world! from process 0 of 4 Hello, world! from process 2 of 4 Hello, world! from process 1 of 4 Hello, world! from process 3 of 4
Pour compiler avec Boost :
[~]$ mpic++ --std=c++11 phello1.cpp -lboost_mpi-mt -lboost_serialization-mt -o phello1
Avec la version Python, vous n'avez pas besoin de compiler, mais vous pouvez exécuter avec
[~]$ mpirun -np 4 python phello1.py
Communication
Nous avons maintenant une version parallèle de Hello World, mais sans communication entre les processus. Voyons ensuite cet aspect.
Nous demandons à chaque processus de transmettre au processus suivant le mot hello. Le processus de rang i envoie son message au processus de rang i+1 et le dernier processus de rang N-1 envoie son message au processus de rang 0 pour boucler la boucle. Exprimé concisément, le processus i envoie au processus (i+1)%N où il y a N processus et où % est l'opérateur modulo.
MPI offre plusieurs fonctions pour échanger des données dans un grand nombre de relations entre processus (1,1; 1,n; n,1; n,n). Les fonctions les plus simples sont cependant celles qui échangent une ou plusieurs instances de données du même type de base, soit les fonctions MPI_Send et MPI_Recv.
Un processus envoie des données par la fonction MPI_Send. Examinons notre exemple :
- message est un pointeur vers un vecteur de données à envoyer;
- count représente le nombre d'instances contiguës de type datatype est le type des données;
- dest est le rang du processus cible;
- tag est un identifiant entier défini par le programmeur et associé au type de message à envoyer, ce qui est utile pour distinguer les différentes communications entre les processus. Cependant, puisque cet identifiant n'est toujours pas utile à notre exemple, nous choisissons la valeur arbitraire 0;
- MPI_COMM_WORLD est le communicateur représentant tous les processus lancés par mpirun.
int MPI_Send
(
void *message, /* reference to data to be sent */
int count, /* number of items in message */
MPI_Datatype datatype, /* type of item in message */
int dest, /* rank of process to receive message */
int tag, /* programmer specified identifier */
MPI_Comm comm /* communicator */
);
template<typename T> void mpi::communicator::send(
int dest, /* rank of process to receive message */
int tag, /* programmer specified identified */
const T & value /* message */
) const;
MPI_SEND(MESSAGE, COUNT, DATATYPE, DEST, TAG, COMM, IERR)
<type> MESSAGE(*)
INTEGER :: COUNT, DATATYPE, DEST, TAG, COMM, IERR
MPI_Send(message, count, datatype, dest, tag, comm, ierr)
TYPE(*), DIMENSION(..), INTENT(IN) :: message
INTEGER, INTENT(IN) :: count, dest, tag
TYPE(MPI_Datatype), INTENT(IN) :: datatype
TYPE(MPI_Comm), INTENT(IN) :: comm
INTEGER, OPTIONAL, INTENT(OUT) :: ierr
# For general Python objects (pickled)
MPI.Intracomm.send(self, obj, int dest, int tag=0)
# For numpy arrays (fast)
MPI.Intracomm.Send(self, buf, int dest, int tag=0)
Remarquez que l'argument datatype qui identifie le type des données contenues dans le buffer message est une variable définie par la norme MPI. Ceci assure une couche de compatibilité entre les processus opérant sur des architectures où le format natif des données serait différent. Il est possible d'utiliser de nouveaux types de données, mais nous nous limitons ici aux types définis nativement par MPI. En langage C : MPI_CHAR, MPI_FLOAT, MPI_SHORT, MPI_INT, etc. En Fortran :MPI_CHARACTER, MPI_INTEGER, MPI_REAL, etc. Pour la liste complète des types de données, consultez la section Références en bas de page.
À la fonction de réception MPI_Recv, on ajoute l'argument status : en C, l'argument réfère à une structure allouée MPI_Status et en Fortran, l'argument contient une matrice MPI_STATUS_SIZE de nombres entiers ou, pour mpi_f08, une variable dérivée TYPE(MPI_Status). À son retour, MPI_Recv contiendra de l'information sur le message reçu. Nos exemples ne montrent pas cet argument, mais il doit faire partie des instructions.
int MPI_Recv
(
void *message, /* reference to buffer for received data */
int count, /* number of items to be received */
MPI_Datatype datatype, /* type of item to be received */
int source, /* rank of process from which to receive */
int tag, /* programmer specified identifier */
MPI_Comm comm /* communicator */
MPI_Status *status /* stores info. about received message */
);
template<typename T> status mpi::communicator::recv(
int source, /* rank of process from which to receive */
int tag, /* programmer specified identified */
const T & value /* message */
) const;
MPI_RECV(MESSAGE, COUNT, DATATYPE, SOURCE, TAG, COMM, STATUS, IERR)
<type> :: MESSAGE(*)
INTEGER :: COUNT, DATATYPE, SOURCE, TAG, COMM, STATUS(MPI_STATUS_SIZE), IERR
MPI_Recv(message, count, datatype, source, tag, comm, status, ierr)
TYPE(*), DIMENSION(..) :: message
INTEGER, INTENT(IN) :: count, source, tag
TYPE(MPI_Datatype), INTENT(IN) :: datatype
TYPE(MPI_Comm), INTENT(IN) :: comm
TYPE(MPI_Status) :: status
INTEGER, OPTIONAL, INTENT(OUT) :: ierr
# For general Python objects (pickled)
MPI.Intracomm.recv(self, buf=None, int source=ANY_SOURCE, int tag=ANY_TAG, Status status=None)
# For numpy arrays (fast)
MPI.Intracomm.Recv(self, buf, int source=ANY_SOURCE, int tag=ANY_TAG, Status status=None)
Dans notre cas simple avec MPI_Send et MPI_Recv, le processus qui envoie doit connaitre le rang du processus qui reçoit et vice versa. Rappelons-nous des règles mathématiques suivantes :
- (rank + 1) % size est le processus auquel on envoie
- (rank + 1) % size est le processus duquel on reçoit
Modifions maintenant notre programme parallèle.
#include <stdio.h>
#include <mpi.h>
#define BUFMAX 81
int main(int argc, char *argv[])
{
char outbuf[BUFMAX], inbuf[BUFMAX];
int rank, size;
int sendto, recvfrom;
MPI_Status status;
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);
sprintf(outbuf, "Hello, world! from process %d of %d", rank, size);
sendto = (rank + 1) % size;
recvfrom = (rank + size - 1) % size;
MPI_Send(outbuf, BUFMAX, MPI_CHAR, sendto, 0, MPI_COMM_WORLD);
MPI_Recv(inbuf, BUFMAX, MPI_CHAR, recvfrom, 0, MPI_COMM_WORLD, &status);
printf("[P_%d] process %d said: \"%s\"]\n", rank, recvfrom, inbuf);
MPI_Finalize();
return(0);
}
#include <iostream>
#include <string>
#include <boost/mpi.hpp>
using namespace std;
using namespace boost;
int main(int argc, char *argv[])
{
mpi::environment env(argc, argv);
mpi::communicator world;
int rank = world.rank();
int size = world.size();
string outmessage = "Hello, world! from process " + to_string(rank) + " of " + to_string(size);
string inmessage;
int sendto = (rank + 1) % size;
int recvfrom = (rank + size - 1) % size;
cout << outmessage << endl;
world.send(sendto,0,outmessage);
world.recv(recvfrom,0,inmessage);
cout << "[P_" << rank << "] process " << recvfrom << " said: \"" << inmessage << "\"" << endl;
return 0;
}
program phello2
implicit none
use mpi
integer, parameter :: BUFMAX=81
character(len=BUFMAX) :: outbuf, inbuf, tmp
integer :: rank, num_procs, ierr
integer :: sendto, recvfrom
integer :: status(MPI_STATUS_SIZE)
call MPI_INIT(ierr)
call MPI_COMM_RANK(MPI_COMM_WORLD, rank, ierr)
call MPI_COMM_SIZE(MPI_COMM_WORLD, num_procs, ierr)
outbuf = 'Hello, world! from process '
write(tmp,'(i2)') rank
outbuf = outbuf(1:len_trim(outbuf)) // tmp(1:len_trim(tmp))
write(tmp,'(i2)') num_procs
outbuf = outbuf(1:len_trim(outbuf)) // ' of ' // tmp(1:len_trim(tmp))
sendto = mod((rank + 1), num_procs)
recvfrom = mod((rank + num_procs - 1), num_procs)
call MPI_SEND(outbuf, BUFMAX, MPI_CHARACTER, sendto, 0, MPI_COMM_WORLD, ierr)
call MPI_RECV(inbuf, BUFMAX, MPI_CHARACTER, recvfrom, 0, MPI_COMM_WORLD, status, ierr)
print *, 'Process', rank, ': Process', recvfrom, ' said:', inbuf
call MPI_FINALIZE(ierr)
end program phello2
program phello2
implicit none
use mpi_f08
integer, parameter :: BUFMAX=81
character(len=BUFMAX) :: outbuf, inbuf, tmp
integer :: rank, num_procs
integer :: sendto, recvfrom
type(MPI_Status) :: status
call MPI_Init()
call MPI_Comm_rank(MPI_COMM_WORLD, rank)
call MPI_Comm_size(MPI_COMM_WORLD, num_procs)
outbuf = 'Hello, world! from process '
write(tmp,'(i2)') rank
outbuf = outbuf(1:len_trim(outbuf)) // tmp(1:len_trim(tmp))
write(tmp,'(i2)') num_procs
outbuf = outbuf(1:len_trim(outbuf)) // ' of ' // tmp(1:len_trim(tmp))
sendto = mod((rank + 1), num_procs)
recvfrom = mod((rank + num_procs - 1), num_procs)
call MPI_Send(outbuf, BUFMAX, MPI_CHARACTER, sendto, 0, MPI_COMM_WORLD)
call MPI_Recv(inbuf, BUFMAX, MPI_CHARACTER, recvfrom, 0, MPI_COMM_WORLD, status)
print *, 'Process', rank, ': Process', recvfrom, ' said:', inbuf
call MPI_Finalize()
end program phello2
from mpi4py import MPI
comm = MPI.COMM_WORLD
rank = comm.Get_rank()
size = comm.Get_size()
outbuf = "Hello, world! from process %d of %d" % (rank, size)
sendto = (rank + 1) % size;
recvfrom = (rank + size - 1) % size;
comm.send(outbuf, dest=sendto, tag=0)
inbuf = comm.recv(source=recvfrom, tag=0)
print('[P_%d] process %d said: "%s"]' % (rank, recvfrom, inbuf))
Compilez ce programme et faites-le exécuter avec 2, 4 et 8 processus. Le fonctionnement semble approprié, mais il y a cependant un problème caché. En effet, la norme MPI n'offre aucune garantie que MPI_Send retournera avant que le message ait été livré. Dans la plupart des implémentations, les données sont mises en mémoire temporaire par MPI_Send et retournent sans attendre d'être livrées. Par contre, si la mémoire tampon n'était pas utilisée, notre code bloquerait. Chaque processus appellerait MPI_Send et attendrait que le processus voisin appelle MPI_Recv. Puisque le processus voisin serait aussi en attente d'une réponse de MPI_Send, tous les processus seraient indéfiniment en attente. Les bibliothèques des systèmes de Calcul Canada utilisent les buffers puisque notre code n'a pas bloqué; ce modèle de conception n'est toutefois pas fiable. Sans mémoire tampon offerte par la bibliothèque, le programme pourrait faire défaut, et malgré la mémoire tampon, un appel pourrait être bloqué si celle-ci est saturée.
[~]$ mpicc -Wall phello2.c -o phello2 [~]$ mpirun -np 4 ./phello2 [P_0] process 3 said: "Hello, world! from process 3 of 4"] [P_1] process 0 said: "Hello, world! from process 0 of 4"] [P_2] process 1 said: "Hello, world! from process 1 of 4"] [P_3] process 2 said: "Hello, world! from process 2 of 4"]
Éviter les impasses
Dans la norme MPI, les appels MPI_Send et MPI_Recv sont des appels bloquants. MPI_Send ne retourne pas tant qu'il n'est pas sécuritaire pour le module qui appelle de modifier le contenu de la mémoire tampon. De même, MPI_Recv ne retourne pas tant que tout le contenu du message ne se trouve pas dans la mémoire tampon.
Le message est reçu, que la bibliothèque MPI offre ou non l’accès à une mémoire tampon. À la réception des données, le contenu du message est placé dans la mémoire tampon identifiée par l’appel et ce dernier est bloqué jusqu’à ce que MPI_Recv retourne. Par contre, MPI_Send n’a pas besoin d’être bloqué si la bibliothèque offre une mémoire tampon; dès que les données sont copiées de leur lieu d’origine, ce dernier peut être modifié et l’appel peut retourner. Ceci explique pourquoi notre exemple ne mène pas à une impasse malgré le fait que chacun des processus appelle MPI_Send en premier. Puisque la norme MPI ne requiert pas l'usage de la mémoire tampon et que notre code en dépend, nous considérons le programme comme étant à risque d'une impasse.
Un programme qui ne serait pas à risque en serait un dont le bon fonctionnement ne requiert pas l’usage d’une mémoire tampon, comme illustré ici :
Interblocage
...
if (rank == 0)
{
MPI_Recv(from 1);
MPI_Send(to 1);
}
else if (rank == 1)
{
MPI_Recv(from 0);
MPI_Send(to 0);
}
...
Dans les deux cas, l'appel de réception est lancé avant l'appel d'envoi correspondant : il y a ainsi interblocage causé par MPI_Recv.
Situation à risque
...
if (rank == 0)
{
MPI_Send(to 1);
MPI_Recv(from 1);
}
else if (rank == 1)
{
MPI_Send(to 0);
MPI_Recv(from 0);
}
...
Dans cet exemple, le programme pourrait fonctionner adéquatement si la bibliothèque permet l'usage d'une mémoire tampon. Si ce n'est pas le cas, ou si le contenu des messages dépasse la capacité de la mémoire tampon, MPI_Send fera bloquer le code, créant ainsi une impasse. L'exemple suivant présente une solution.
Code fiable
...
if (rank == 0)
{
MPI_Send(to 1);
MPI_Recv(from 1);
}
else if (rank == 1)
{
MPI_Recv(from 0);
MPI_Send(to 0);
}
...
L’envoi est ici couplé avec la réception, sans usage d'une mémoire tampon. Le processus pourrait être arrêté momentanément en attente de l’appel correspondant, mais il n’y aura pas interblocage.
Comment peut-on s’assurer que notre code est correct? Une solution commode est d'utiliser des couples pair-impair et de procéder en deux temps. Dans notre exemple, la communication se fait séquentiellement d'un incrément vers la droite; notre code devrait donc être juste si tous les processus pairs exécutent l'envoi suivi de la réception et que tous les processus impairs exécutent la réception suivie de l'envoi. Les couples envoi-réception sont bien définis et la possibilité d'interblocage est éliminée.
#include <stdio.h>
#include <mpi.h>
#define BUFMAX 81
int main(int argc, char *argv[])
{
char outbuf[BUFMAX], inbuf[BUFMAX];
int rank, size;
int sendto, recvfrom;
MPI_Status status;
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);
sprintf(outbuf, "Hello, world! from process %d of %d", rank, size);
sendto = (rank + 1) % size;
recvfrom = ((rank + size) - 1) % size;
if (!(rank % 2))
{
MPI_Send(outbuf, BUFMAX, MPI_CHAR, sendto, 0, MPI_COMM_WORLD);
MPI_Recv(inbuf, BUFMAX, MPI_CHAR, recvfrom, 0, MPI_COMM_WORLD, &status);
}
else
{
MPI_Recv(inbuf, BUFMAX, MPI_CHAR, recvfrom, 0, MPI_COMM_WORLD, &status);
MPI_Send(outbuf, BUFMAX, MPI_CHAR, sendto, 0, MPI_COMM_WORLD);
}
printf("[P_%d] process %d said: \"%s\"]\n", rank, recvfrom, inbuf);
MPI_Finalize();
return(0);
}
#include <iostream>
#include <string>
#include <boost/mpi.hpp>
using namespace std;
using namespace boost;
int main(int argc, char *argv[])
{
mpi::environment env(argc, argv);
mpi::communicator world;
int rank = world.rank();
int size = world.size();
string outmessage = "Hello, world! from process " + to_string(rank) + " of " + to_string(size);
string inmessage;
int sendto = (rank + 1) % size;
int recvfrom = (rank + size - 1) % size;
cout << outmessage << endl;
if (!(rank % 2)) {
world.send(sendto,0,outmessage);
world.recv(recvfrom,0,inmessage);
}
else {
world.recv(recvfrom,0,inmessage);
world.send(sendto,0,outmessage);
}
cout << "[P_" << rank << "] process " << recvfrom << " said: \"" << inmessage << "\"" << endl;
return 0;
}
program phello3
implicit none
use mpi
integer, parameter :: BUFMAX=81
character(len=BUFMAX) :: outbuf, inbuf, tmp
integer :: rank, num_procs, ierr
integer :: sendto, recvfrom
integer :: status(MPI_STATUS_SIZE)
call MPI_INIT(ierr)
call MPI_COMM_RANK(MPI_COMM_WORLD, rank, ierr)
call MPI_COMM_SIZE(MPI_COMM_WORLD, num_procs, ierr)
outbuf = 'Hello, world! from process '
write(tmp,'(i2)') rank
outbuf = outbuf(1:len_trim(outbuf)) // tmp(1:len_trim(tmp))
write(tmp,'(i2)') num_procs
outbuf = outbuf(1:len_trim(outbuf)) // ' of ' // tmp(1:len_trim(tmp))
sendto = mod((rank + 1), num_procs)
recvfrom = mod(((rank + num_procs) - 1), num_procs)
if (MOD(rank,2) == 0) then
call MPI_SEND(outbuf, BUFMAX, MPI_CHARACTER, sendto, 0, MPI_COMM_WORLD, ierr)
call MPI_RECV(inbuf, BUFMAX, MPI_CHARACTER, recvfrom, 0, MPI_COMM_WORLD, status, ierr)
else
call MPI_RECV(inbuf, BUFMAX, MPI_CHARACTER, recvfrom, 0, MPI_COMM_WORLD, status, ierr)
call MPI_SEND(outbuf, BUFMAX, MPI_CHARACTER, sendto, 0, MPI_COMM_WORLD, ierr)
endif
print *, 'Process', rank, ': Process', recvfrom, ' said:', inbuf
call MPI_FINALIZE(ierr)
end program phello3
program phello3
implicit none
use mpi_f08
integer, parameter :: BUFMAX=81
character(len=BUFMAX) :: outbuf, inbuf, tmp
integer :: rank, num_procs
integer :: sendto, recvfrom
type(MPI_Status) :: status
call MPI_Init()
call MPI_Comm_rank(MPI_COMM_WORLD, rank)
call MPI_Comm_size(MPI_COMM_WORLD, num_procs)
outbuf = 'Hello, world! from process '
write(tmp,'(i2)') rank
outbuf = outbuf(1:len_trim(outbuf)) // tmp(1:len_trim(tmp))
write(tmp,'(i2)') num_procs
outbuf = outbuf(1:len_trim(outbuf)) // ' of ' // tmp(1:len_trim(tmp))
sendto = mod((rank + 1), num_procs)
recvfrom = mod(((rank + num_procs) - 1), num_procs)
if (MOD(rank,2) == 0) then
call MPI_Send(outbuf, BUFMAX, MPI_CHARACTER, sendto, 0, MPI_COMM_WORLD)
call MPI_Recv(inbuf, BUFMAX, MPI_CHARACTER, recvfrom, 0, MPI_COMM_WORLD, status)
else
call MPI_RECV(inbuf, BUFMAX, MPI_CHARACTER, recvfrom, 0, MPI_COMM_WORLD, status)
call MPI_SEND(outbuf, BUFMAX, MPI_CHARACTER, sendto, 0, MPI_COMM_WORLD)
endif
print *, 'Process', rank, ': Process', recvfrom, ' said:', inbuf
call MPI_Finalize()
end program phello3
from mpi4py import MPI
comm = MPI.COMM_WORLD
rank = comm.Get_rank()
size = comm.Get_size()
outbuf = "Hello, world! from process %d of %d" % (rank, size)
sendto = (rank + 1) % size;
recvfrom = ((rank + size) - 1) % size;
if rank % 2 == 0:
comm.send(outbuf, dest=sendto, tag=0)
inbuf = comm.recv(source=recvfrom, tag=0)
else:
inbuf = comm.recv(source=recvfrom, tag=0)
comm.send(outbuf, dest=sendto, tag=0)
print('[P_%d] process %d said: "%s"]' % (rank, recvfrom, inbuf))
À première vue, il semblerait qu'un nombre impair de processus puisse poser un problème. En effet, le processus pair 0 lance un envoi alors que l'autre processus pair N-1 tente de lancer une réception. Cependant, l'envoi fait par le processus 0 est correctement apparié à la réception lancée par le processus 1; puisque le processus impair 1 commence par une réception, la transaction sera sûrement complétée. Sur ce, le processus 0 reçoit le message du processus 1; il pourrait y avoir un certain délai (minime), mais il n'y a pas de risque d'interblocage. [~]$ mpicc -Wall phello3.c -o phello3
[~]$ mpirun -np 16 ./phello3 [P_1] process 0 said: "Hello, world! from process 0 of 16"] [P_2] process 1 said: "Hello, world! from process 1 of 16"] [P_5] process 4 said: "Hello, world! from process 4 of 16"] [P_3] process 2 said: "Hello, world! from process 2 of 16"] [P_9] process 8 said: "Hello, world! from process 8 of 16"] [P_0] process 15 said: "Hello, world! from process 15 of 16"] [P_12] process 11 said: "Hello, world! from process 11 of 16"] [P_6] process 5 said: "Hello, world! from process 5 of 16"] [P_13] process 12 said: "Hello, world! from process 12 of 16"] [P_8] process 7 said: "Hello, world! from process 7 of 16"] [P_7] process 6 said: "Hello, world! from process 6 of 16"] [P_14] process 13 said: "Hello, world! from process 13 of 16"] [P_10] process 9 said: "Hello, world! from process 9 of 16"] [P_4] process 3 said: "Hello, world! from process 3 of 16"] [P_15] process 14 said: "Hello, world! from process 14 of 16"] [P_11] process 10 said: "Hello, world! from process 10 of 16"]
Plusieurs modèles sont décrits dans la page Web Introduction and Overview. Nous vous recommandons d'utiliser la fonction de communication qui s'applique à votre situation, plutôt que de la créer par vous-même.
Enveloppes de compilateurs
Les paquets des bibliothèques MPI offrent généralement des enveloppes (wrappers) pour les compilateurs. Ce ne sont pas des compilateurs, mais ces enveloppes appellent d'autres compilateurs tout en s'assurant que le compilateur reçoive les indicateurs spécifiques à MPI.
Voici une liste partielle :
mpicc
pour Cmpicxx
pour C++mpifort
et/oumpif77
et/oumpif90
pour Fortran
Avec Open MPI, ces enveloppes ont l'option --showme
qui imprime le nom du compilateur qui sera appellé et les options qui y seront ajoutées.
Remarque : Notre pile logicielle contient plusieurs modules du même paquet Open MPI (par exemple openmpi/4.0.3
) qui ont été construits avec des compilateurs différents et qui prennent en charge ou non CUDA. Les enveloppes de compilateurs MPI utiliseront toujours le compilateur et la version du compilateur que vous avez chargés avec la commande code>module load.
Par exemple, si vous avez chargé les modules intel/2020.1.217
et openmpi/4.0.3
:
[name@server ~]$ mpicc -showme
icc -I/cvmfs/…/intel2020/openmpi/4.0.3/include -L/cvmfs/…/intel2020/openmpi/4.0.3/lib -lmpi
Si vous avez chargé les modules gcc/9.3.0
et openmpi/4.0.3
:
[name@server ~]$ mpicc -showme
gcc -I/…/gcc9/openmpi/4.0.3/include -L/cvmfs/…/gcc9/openmpi/4.0.3/lib -lmpi
Autres considérations
Nous avons abordé la conception et la syntaxe de programmes MPI; il y a cependant beaucoup d'autres éléments à considérer dans le développement d'applications parallèles robustes avec MPI. Sans être exhaustive, la liste suivante en donne un aperçu.
- variantes de MPI_Send/MPI_Recv
- communication collective
- communicateurs et topologies
- communication unilatérale et fonctionnalités de MPI-2
- types de données dérivés
- performance
- débogage
- tutoriel Boost MPI (en français)
Lectures recommandées
- GROPP, William, Ewing LUSK et Anthony SKJELLUM. Using MPI: Portable Parallel Programming with the Message-Passing Interface, 2e éd, MIT Press, 1999.
- information complète sur les interfaces avec Fortran, C et C++
- PACHECO, Peter S., Parallel Programming with MPI, Morgan Kaufmann, 1997.
- tutoriel avec langage C
- BARNEY, Blaise Message Passing Interface (MPI), Lawrence Livermore National Labs.
- KENDALL, Wes, et autres. mpitutorial.com
- Institut du développement et des ressources en informatique scientifique, Formation "MPI" (en français).