#
< Hello World Kommunikation MPI::Send MPI::Send an n Prozesse P2PComm.hpp MPI::Bsend MPI::Ssend MPI::Rsend Kommunikation Teil 2 >


Kommunikation

Die Kommunikation zwischen Prozessen eines parallelen Programms ist ein wichtiger Faktor für dessen Schnelligkeit. Daher gibt es eine Vielzahl unterschiedlicher Funktionen, die im Folgenden vorgestellt werden.

Es existieren zwei Gruppen von Funktionen für die Kommunikation. Die erste beinhaltet die Methoden für die Punkt-zu-Punkt-Kommunikation, also die Kommunikation zwischen zwei Prozessen. Die zweite Gruppe beinhaltet die Methoden für die kollektive Kommunikation, wobei mehrere Prozesse miteinander kommunizieren. Beginnen werden wir mit der Punkt-zu-Punkt-Kommunikation, die sich in weitere zwei Gruppen, die blockierende und nicht blockierende, teilt.

Blockierende Punkt-zu-Punkt-Kommunikation

Alle MPI-Befehle dieser Kommunikationsart benötigen für das Verschicken fünf Parameter:

  1. Zeiger auf den Anfang des zu verschickenden Datenbereichs (const void*)
  2. Anzahl der zu verschickenden Elemente (int)
  3. zu verschickender MPI-Datentyp (const MPI::Datatype&)
  4. ID des Empfängers (int)
  5. spezieller tag für diese Nachricht (int)

Für das Empfangen werden fünf Parameter benötigt, wobei diese den obigen ähneln. Der sechste Parameter ist optional.

  1. Zeiger auf den Anfang des empfangenden Datenbereichs (void*)
  2. Anzahl der zu empfangenden Elemente (int)
  3. zu empfangender MPI-Datentyp (const MPI::Datatype&)
  4. ID des Senders (int)
  5. spezieller tag für diese Nachricht (int)
  6. ein MPI-Status-Objekt (MPI::Status&)

Für das Empfangen gibt es nur einen Befehl, nähmlich MPI::Recv:

void MPI::Comm.Recv(void* data, int cnt, const MPI::Datatype& type, int from, int tag, MPI::Status& status);

Für das Senden hingegen existieren vier verschiedene Modi mit unterschiedlichen Eigenschaften:

  • Send()
  • Bsend()
  • Ssend()
  • Rsend()

Zunächst sollten wir uns daran erinnern, dass alle hier aufgeführten Methoden blockierend sind. Das heißt, wenn eine Methode im Programm aufgerufen wird, wird das Programm erst fortgesetzt, wenn die Methode abgearbeitet wurde. Für das Empfangen einer Nachricht bedeutet das, dass erst nach dem vollständigen Empfangen (der Empfangspuffer ist gefüllt) weitere Befehle abgearbeitet werden. Für das Senden gibt es allerdings etwas zu beachten. Bei Send() wird entweder die Nachricht in einen internen Puffer kopiert und später abgeschickt (asynchrones Senden) oder die Nachricht wird nach einem Handshake mit dem Kommunikationspartner gleich zu diesen abgeschickt (synchrones Senden). Wird synchrones Senden verwendet ist die Nachricht nach dem Verlassen von Send() verschickt worden. Wird hingegen asynchrones Senden benutzt, kann nicht garantiert werden, dass die Nachricht nach dem Verlassen von Send() verschickt worden ist. Einen direkten Einfluss auf den Sende-Modus hat man nicht. MPI entscheidet sich anhand der Übertragungsgröße für eine von beiden Möglichkeiten (abhängig von der Größe des internen Puffers). Die andere Möglichkeit ist das explizite Vorgeben eines Modus. So kann mit dem Befehl Bsend() asynchron gesendet werden. Allerdings muss dafür ein extra Puffer MPI zur Verfügung gestellt werden. Das synchrone Senden kann mit dem Befehl Ssend() erreicht werden. Die vierte Möglichkeit ist das Senden im Ready-Modus mittels Rsend(). Durch den Programmablauf muss dabei sicher gestellt sein, dass bevor Rsend() aufgerufen wird, der Empfänger bereits durch Recv() auf die Nachricht wartet. Dadurch wird etwas Overhead eingespart. Zu allererst sollte immer Send() benutzt werden. Auf andere Modi umzusteigen lohnt sich erst nach einem ausführlichen Profiling, welches als Ergebnis Performance-Probleme bei der Kommunikation sieht.

Hier noch einmal alle Sende-Funktionen mit Parameter im Überblick:

	void MPI::Comm.Send(const void* data, int cnt, const MPI::Datatype& type, int dest, int tag)
	void MPI::Comm.Bsend(const void* data, int cnt, const MPI::Datatype& type, int dest, int tag)
	void MPI::Comm.Ssend(const void* data, int cnt, const MPI::Datatype& type, int dest, int tag)
	void MPI::Comm.Rsend(const void* data, int cnt, const MPI::Datatype& type, int dest, int tag)
	

Kommen wir nun zu einem ersten kleinen Beispiel: Die Aufgabe ist die Zahl 13.13 von Prozess 0 an Prozess 1 zu schicken. Dafür müssen zwei Dinge beachtet werden: Als erstes dürfen nur genau zwei Prozesse gestartet werden und zweitens muss explizit im Programm zwischen diesen beiden Prozessen mit Hilfe einer Wenn-Bedingung unterschieden werden. Das Programm sieht dann wie folgt aus:

	 1 #define _MPI_CPP_BINDINGS
	 2 #include <mpi.h>
	 3 #include <iostream>
	 4 
	 5 using std::cout;
	 6 using std::endl;
	 7 
	 8 int main(int argc, char* argv[])
	 9 {
	10   MPI::Init(argc, argv);
	11 
	12   const int size = MPI::COMM_WORLD.Get_size();
	13   if (size != 2) return MPI::ERR_SIZE;   //check for 2 processes
	14 
	15   const int rank = MPI::COMM_WORLD.Get_rank();
	16 
	17   if (rank==0)  //the sender
	18   {
	19     double toSend = 13.13;
	20     const int cnt = 1;
	21     const int dest = 1;
	22     const int tag = 0;
	23 
	24     cout << "process " << rank << " sends " << toSend << " to process " << dest << endl;
	25     MPI::COMM_WORLD.Send(&toSend, cnt, MPI::DOUBLE, dest, tag);
	26   }
	27   else  //the receiver
	28   {
	29     double toRecv;
	30     const int cnt = 1;
	31     const int from = 0;
	32     const int tag = 0;
	33 
	34     MPI::COMM_WORLD.Recv(&toRecv, cnt, MPI::DOUBLE, from, tag);
	35     cout << "process " << rank << " received " << toRecv << " from process " << from << endl;
	36   }
	37   MPI::Finalize();
	38 }
	

Die Ausgabe sollte dann folgendes ergeben:

	process 0 sends 13.13 to process 1
	process 1 received 13.13 from process 0
	

Als Erweiterung soll nun Prozess 1 die empfangene Zahl mit 2 multiplizieren und zurück schicken.

	17   if (rank==0)  //the sender
	18   {
	19     double toSend = 13.13;
	20     const int cnt = 1;
	21     const int dest = 1;
	22     const int tag = 0;
	23     cout << "process " << rank << " sends " << toSend << " to process " << dest << endl;
	24     MPI::COMM_WORLD.Send(&toSend, cnt, MPI::DOUBLE, dest, tag);
	25 
	26     double toRecv;
	27     MPI::COMM_WORLD.Recv(&toRecv, cnt, MPI::DOUBLE, dest, tag);
	28     cout << "process " << rank << " received " << toRecv << " from process " << dest << endl;
	29   }
	30   else  //the receiver
	31   {
	32     double toRecv;
	33     const int cnt = 1;
	34     const int from = 0;
	35     const int tag = 0;
	36 
	37     MPI::COMM_WORLD.Recv(&toRecv, cnt, MPI::DOUBLE, from, tag);
	38     cout << "process " << rank << " received " << toRecv << " from process " << from << endl;
	39     toRecv*=2.0;
	40     MPI::COMM_WORLD.Send(&toRecv, cnt, MPI::DOUBLE, from, tag);
	41   }
	

Die Ausgabe sollte dann folgendes ergeben:

	process 0 sends 13.13 to process 1
	process 1 received 13.13 from process 0
	process 0 received 26.26 from process 1
	

Beim Nachrichtenaustausch können bei der blockierenden Kommunikation so genannte Deadlocks entstehen. Zum Beispiel wenn zwei Prozesse durch Recv() auf eine Nachricht vom jeweils anderen warten. Deshalb muss die Abfolge des Sendens und Empfangens beachtet werden. Soll zwischen Prozess 0 und Prozess 1 jeweils eine Nachricht versendet und empfangen werden, so kann der spezielle Befehl SendRecv() verwendet werden, was die Gefahr eines Deadlocks im Programm verringert.

Wie sich unschwer erkennen lässt, ist das bisherige Programm ziemlich undynamisch. Es können nur zwei Prozesse miteinander kommunizieren. Eine Möglichkeit mehrere Prozesse miteinander kommunizieren zu lassen, ist das Master-Slave-Verfahren. Dabei dient ein Prozess als Master, welcher Nachrichten zu den anderen Prozessen schickt und von diesen empfängt. Dafür muss das obige Beispiel etwas abgeändert werden:

	 1 #define _MPI_CPP_BINDINGS
	 2 #include <mpi.h>
	 3 #include <iostream>
	 4 
	 5 using std::cout;
	 6 using std::endl;
	 7 
	 8 int main(int argc, char* argv[])
	 9 {
	10   MPI::Init(argc, argv);
	11 
	12   const int size = MPI::COMM_WORLD.Get_size();
	13   if (size < 2) return MPI::ERR_SIZE;  //check for min 2 processes
	14 
	15   const int rank = MPI::COMM_WORLD.Get_rank();
	16   const int master = 0;
	17 
	18   if (rank == master)  //the master
	19   {
	20     double toSend = 13.13;
	21     const int cnt = 1;
	22     const int tag = 0;
	23 
	24     for (int dest=1; dest<size; ++dest)
	25     {
	26       cout << "process " << rank << " sends " << toSend << " to process " << dest << endl;
	27       MPI::COMM_WORLD.Send(&toSend, cnt, MPI::DOUBLE, dest, tag);
	28     }
	29   }
	30   else
	31   {
	32     double toRecv;
	33     const int cnt = 1;
	34     const int from = master;
	35     const int tag = 0;
	36 
	37     MPI::COMM_WORLD.Recv(&toRecv, cnt, MPI::DOUBLE, from, tag);
	38     cout << "process " << rank << " received " << toRecv << " from process " << from << endl;
	39   }
	40   MPI::Finalize();
	41 }
	

Jetzt muss sichergestellt werden, dass mindestens zwei Prozesse gestartet wurden. Der Master sendet mit Hilfe einer for-Schleife Nachrichten an die anderen Prozesse. Diese warten auf Nachricht vom Master. Die Ausgabe sollte bei drei Prozessen folgendes ergeben:

	process 0 sends 13.13 to process 1
	process 1 received 13.13 from process 0
	process 0 sends 13.13 to process 2
	process 2 received 13.13 from process 0
	

Eine weitere Art der Kommunikation ist der Aufbau eines Ringes, wobei jeder Prozess die Nachricht an seinen Nachbarn schickt. Darauf wird im Detail im Kapitel Virtuelle Topologie näher eingegangen.

Alle bisherigen Nachrichten wurden mit dem Standard-Send verschickt. Deshalb sollen jetzt die anderen drei Befehle zum Verschicken der Zahl 13.13 verwendet werden. Dafür habe ich verschiedene Funktoren implementiert, die das Umschalten zwischen den verschiedenen Modi vereinfachen. Diese sind in der Header-Datei P2PComm.hpp zusammengefasst. Zunächst stelle ich die Klasse Transceiver vor. Diese dient dem Zugriff auf den jeweiligen Modus, der als Template-Parameter übergeben wird.

	 1 template <typename T=Send, typename R=Recv>
	 2 class Transceiver
	 3 {
	 4 public:
	 5   void send(const void* data, int cnt, const MPI::Datatype& type, int dest, int tag)
	 6   {
	 7     T()(data, cnt, type, dest, tag);
	 8   }
	 9 
	10   void recv(void* data, int cnt, const MPI::Datatype& type, int from, int tag, MPI::Status& status)
	11   {
	12     R()(data, cnt, type, from, tag, status);
	13   }
	14 };
	

Von den Funktoren stelle ich Bsend vor, weil dieser durch das Anlegen eines Puffers am Schwierigsten ist.

	 1 class BSend
	 2 {
	 3 public:
	 4   void operator()(const void* data, int cnt, const MPI::Datatype& type, int dest, int tag ) const
	 5   {
	 6 #ifdef DEBUG_EXTRA
	 7     std::cout << *MyMPI::instance() << " sends with MPI::Bsend() to process " << dest;
	 8     std::cout << " at address " << data << " count: " << cnt << " type: " << type << " tag: "<< tag;
	 9     std::cout << std::endl;
	10 #endif
	11 
	12     int bufsize = type.Pack_size(cnt, MyMPI::instance()->world());
	13     bufsize += MPI::BSEND_OVERHEAD;
	14     void* buf = new char[bufsize];
	15     MPI::Attach_buffer(buf, bufsize);
	16     MyMPI::instance()->world().Bsend(data, cnt, type, dest, tag);
	17     MPI::Detach_buffer(buf);
	18     delete [] buf;
	19     }
	20 };
	

Der Funktor beginnt zuallerst mit der Ausgabe der übergebenen Parametern, falls die Präprozessor-Anweisung DEBUG_EXTRA definiert ist. Danach folgt die Berechnung der Puffergröße mit Hilfe des MPI-Befehls Pack_size(). Zu dieser Puffergröße muss noch ein Overhead addiert werden. Dann erst kann der eigentliche Puffer angelegt und MPI mittels Attach_buffer() zur Verfügung gestellt werden. Danach erfolgt das Verschicken durch das zuvor vorgestellte Singleton. Nach dem Versenden wird mit Detach_buffer() der Puffer von MPI gelöst und mit delete gelöscht.

Genutzt werden können die Funktoren wie folgt:

	 1 #include "MyMPI.hpp"
	 2 #include "P2PComm.hpp"
	 3 #include <iostream>
	 4 
	 5 using namespace MF;
	 6 using std::cout;
	 7 using std::endl;
	 8 
	 9 int main(int argc, char* argv[])
	10 {
	11   const MyMPI* mpi = MyMPI::instance();
	12   const int master = 0;
	13 
	14   if (mpi->rank() == master)  //master
	15   {
	16     Transceiver<BSend, Recv> transceiver;
	17     const double toSend = 13.13;
	18     const int cnt = 1;
	19     const int tag = 0;
	20     for (int dest=1; dest<mpi->size(); ++dest)
	21     {
	22       transceiver.send(&toSend, cnt, MPI::DOUBLE, dest, tag);
	23     }
	24   }
	25   else
	26   {
	27     double toRecv;
	28     const int cnt = 1;
	29     const int from = master;
	30     const int tag = 0;
	31     MPI::Status status;
	32 
	33     Transceiver<> transceiver;
	34     transceiver.recv(&toRecv, cnt, MPI::DOUBLE, from, tag, status);
	35     cout << toRecv << endl;
	36   }
	37 
	38   return 0;
	39 }
	

Mit Hilfe des Transceiver-Objektes lassen sich nun einfach alle Modi ausprobieren. Für das Senden muss nur der zu verwendende Funktor (Zeile 16) geändert werden. Der Recv-Funktor kann auch weggelassen werden, wie in Zeile 33 geschehen.


Version 1.1