message passing interface (mpi) - wi1.uni- · pdf filewestfälische...
TRANSCRIPT
Westfälische Wilhelms-Universität Münster
Ausarbeitung
Message Passing Interface (MPI)
Im Rahmen des Seminars „Parallele und Verteilte Programmierung“
Julian Pascal Werra
Themensteller: Prof. Dr. Herbert Kuchen Betreuer: Dipl.-Wirt.-Inform. Philipp Ciechanowicz Institut für Wirtschaftsinformatik Praktische Informatik in der Wirtschaft
II
Inhaltsverzeichnis
1 Motivation.................................................................................................................. 3
2 Parallele und Verteilte Programmierung ................................................................... 4
2.1 Klassifizierung von Parallelrechnern................................................................ 4
2.2 Verteilter und gemeinsamer Speicher............................................................... 5
3 MPI ............................................................................................................................ 6
3.1 Das Message Passing Programmiermodell....................................................... 6
3.2 Grundlagen........................................................................................................ 6
3.3 Vergleich MPI / OpenMP................................................................................. 8
3.4 Kommunikation ................................................................................................ 8
3.4.1 Prozessgruppen und Kommunikatoren......................................................... 9 3.4.2 Einzeltransfer-Operationen......................................................................... 12 3.4.3 Globale Kommunikations-Operationen...................................................... 15
3.5 Zeitmessung (Benchmarking)......................................................................... 20
3.6 Prozesstopologien ........................................................................................... 21
4 Fazit ......................................................................................................................... 22
A Quelltexte................................................................................................................. 23
Literaturverzeichnis ........................................................................................................ 24
Kapitel 1: Motivation
3
1 Motivation
Seit Mitte der 1980er Jahre gab es unter Anderem zwei für das Thema parallele und
verteilte Programmierung bedeutende technologische Fortschritte:
• Hardware (im Besonderen Prozessoren) ist günstiger und leistungsfähiger
geworden
• Entwicklung von Hochgeschwindigkeitsnetzwerken (z.B. LAN)
Diese beiden Entwicklungen führten dazu, Parallelrechner zu entwickeln, die
grundlegend als „eine Ansammlung von Berechnungseinheiten (Prozessoren), die durch
koordinierte Zusammenarbeit große Probleme schnell lösen können“ [RR00, S.17]
beschrieben werden können. Die Vorteilhaftigkeit eines solchen Systems liegt unter
Anderem in der einfachen Skalierbarkeit, der Redundanz (ständige Verfügbarkeit),
sowie im preislichen Vorteil gegenüber einem Super-Computer.
Diese Seminararbeit beschäftigt sich mit dem Message Passing Interface (MPI), einem
momentan dominierenden Standard zum Nachrichtenaustausch bei parallelen
Berechnungen auf Systemen mit verteiltem Speicher.
Die Arbeit ist in zwei Kapitel gegliedert. Kapitel 2 führt in die Grundlagen paralleler
Berechnungen ein und klassifiziert die Varianten von Parallelrechnern und die
möglichen Speicherstrukturen. Das Kapitel 3 gibt anschließend anhand der MPI-
Spezifikation einen tieferen Einblick in die parallele Programmierung auf Systemen mit
verteiltem Speicher. Der Vollständigkeit halber wird zudem am Beispiel OpenMP ein
Vergleich mit einem Äquivalent für Systeme mit gemeinsamem Speicher angestellt.
Kapitel 2: Parallele und Verteilte Programmierung
4
2 Parallele und Verteilte Programmierung
2.1 Klassifizierung von Parallelrechnern
Parallelrechner werden üblicherweise in vier Klassen (Flynnsche Klassifizierung)
aufgeteilt, deren Charakterisierung nach den Merkmalen „globale Kontrolle“ und den
„resultierenden Daten- und Kontrollflüssen“ erfolgt [RR00, S.18]:
• SISD – Single Instruction, Single Data
o ein Prozessor mit Zugriff auf je einen Daten- und Programmspeicher.
Einprozessorsysteme
• MISD – Multiple Instruction, Single Data
o mehrere Prozessoren mit Zugriff auf einen gemeinsamen Datenspeicher
und je einen eigenen Programmspeicher. Jeder Prozessor erhält dasselbe
Datum aus dem Datenspeicher.
wenig sinnvoll. Die Klasse existiert in der Praxis nicht
• SIMD – Single Instruction, Multiple Data
o mehrere Prozessoren mit Zugriff auf einen gemeinsamen Daten- und
Programmspeicher. Alle Prozessoren arbeiten dieselbe Instruktion
gleichzeitig, d.h. synchron ab (Datenparallelität).
Vektor- und Feldrechner
• MIMD – Multiple Instruction, Multiple Data
o mehrere Prozessoren mit Zugriff auf einen gemeinsamen Datenspeicher
und je einen eigenen Programmspeicher. Jeder Prozessor erhält ein
separates Datum aus dem Datenspeicher.
alle Arten von Multiprozessorsystemen
Für uns relevant ist lediglich die vierte Klasse (MIMD), da sie ein System von
Prozessoren mit jeweils eigenem Programmspeicher definiert.
Kapitel 2: Parallele und Verteilte Programmierung
5
Abb. 2-1.1
Der generelle Ablauf eines MIMD-Verarbeitungsschrittes sieht vor, dass jeder
Prozessor einen Instruktionsschritt aus seinem Programmspeicher auf ein aus dem
Datenspeicher geladenes Datum ausführt und eventuelle Ergebnisse in den
Datenspeicher zurück schreibt. Genauere Erläuterungen zu den übrigen Klassen finden
sich in [RR00, S.17ff].
2.2 Verteilter und gemeinsamer Speicher
Es soll nun eine weitere Unterteilung dieser Klasse vorgenommen werden, da
heutzutage zwar viele Parallelrechner nach MIMD arbeiten, sich aber in der
Speicherorganisation unterscheiden. Hierbei wird grundlegend zwischen Rechnern mit
verteiltem Speicher (distributed memory machine - DMM) und jenen mit gemeinsamem
Speicher (shared memory machine - SMM) unterschieden. Darüber hinaus existiert
noch eine Mischform, die als virtuell gemeinsamer Speicher bezeichnet wird, hier aber
vernachlässigt wird.
Hierbei wird zwischen zwei Ebenen unterschieden, wobei die erste die physikalische
Speicherstruktur betrachtet, die zweite hingegen die Sicht des Programmierers abbildet.
Der Einfachheit halber betrachten wir nur die physikalische Ebene und setzen voraus,
dass die Speicherstruktur auf Programmierebene nicht von der physikalischen abweicht.
Das Hauptaugenmerk wird auf den verteilten Speicher gelegt, da der MPI-Standard für
solche Systeme konzipiert wurde. Der Vollständigkeit halber wird aber im Kapitel 3.3
ein Vergleich zwischen den beiden Strukturvarianten angestellt.
Es sei vorweggenommen, dass ein solches System, da es keinen gemeinsamer Speicher
gibt, auf Nachrichtenaustausch zwischen den Prozessen angewiesen ist. Näheres hierzu
folgt im nächsten Kapitel.
Kapitel 3: MPI
6
3 MPI
3.1 Das Message Passing Programmiermodell
Das Message Passing Programmiermodell sieht eine Kollektion von Prozessoren mit
jeweils einem eigenem lokalem Speicher vor, was einem System der Klasse MIMD mit
einer DMM Speicher-Struktur entspricht. Der notwendige Nachrichtenaustausch erfolgt
über ein Netzwerk, mit dem jedes Einzelsystem verbunden sein muss. Um welche Form
von Netzwerk es sich handelt ist für uns vorerst nicht von Bedeutung, kann aber bei der
Analyse von Geschwindigkeit und Zuverlässigkeit eine große Rolle spielen.
Abb. 3-1.1
3.2 Grundlagen
MPI ist eine Spezifikation, die beschreibt wie Nachrichten bei parallelen Berechnungen
auf verteilten Systemen (verteilter Speicher) ausgetauscht werden. Dabei legt MPI
Programm-Bindings (Sprachkonstrukte) fest, die für in C oder FORTRAN geschriebene
Programme definiert sind. Sämtliche hier vorgestellten Funktionen und Beispiel-
Quelltexte sind in der Sprache C geschrieben. Kurz gefasst ist MPI also eine Bibliothek
zur Parallelprogrammierung für nachrichtengekoppelte Systeme.
1997 wurde im Zuge einer Erweiterung der bisherigen MPI-Spezifikation MPI-1, die
seit 1994 besteht und mittlerweile in Version 1.2 vorliegt, eine um dynamische
Prozessverwaltung, parallele Ein-/Ausgabe und einseitige Kommunikationsoperationen
erweiterte Spezifikation MPI-2 vorgeschlagen. MPI-2 stellt eine Obermenge von MPI-1
dar, somit ist jedes gültige MPI-1-Programm auch ein gültiges MPI-2-Programm. Im
Zuge der Einführung durch diese Ausarbeitung werden keine der durch MPI-2
gegebenen Erweiterungen benötigt. Somit wird auch nicht weiter auf Unterschiede der
beiden Spezifikationen eingegangen und im Allgemeinen einfach von MPI gesprochen.
Im Folgenden ist unter dem Begriff MPI-Programm ein C/FORTRAN-Programm mit
MPI-Aufrufen zu verstehen.
Kapitel 3: MPI
7
Grundsätzlich sind MPI-Programme portabel, sprich unabhängig vom physikalisch
vorliegenden System nutzbar. Dies wird durch die einheitlichen Schnittstellen
sichergestellt.
In den folgenden Kapiteln wird zuerst in die wichtigsten Kommunikationsoperationen
eingewiesen. Anschließend wird, von diesen Grundlagen ausgehend, auf einige
speziellere Themen eingegangen. Dieses Kapitel soll somit in Basis-Wissen von MPI
einführen und einige wichtige Themen aufgreifen, die für die Erstellung eines MPI-
Programms essentiell sind.
Einige Erläuterungen werden zum Verständnis von Beispiel-Quelltexten begleitet. Zur
Vorbereitung wird deshalb kurz die Ausführung eines MPI-Programms aus Benutzer-
Sicht erläutert.
Kompiliert wird der Quelltext mit dem Befehl
mpicc –o runnable example.c,
wobei example.c die Quelltext-Datei angibt und runnable den Namen der
kompilierten Datei definiert. Das anschließende Ausführen des Programms geschieht
per
mpirun –np 5 runnable.
Über den Parameter –np wird festgelegt wie viel Prozesse für die Ausführung erstellt
werden sollen, im obigen Beispiel sind es fünf.
Zum Aufbau eines MPI-Programms sei gesagt, dass die Funktion MPI_Init(&argc,
&argv) stets die erste aufgerufene MPI-Funktion sein muss. Die Parameter entsprechen
den vom Programm beim Start entgegengenommenen. Die Funktion erlaubt dem
System entsprechende Vorbereitungen für die Arbeit mit der MPI-Bibliothek
vorzunehmen. Entsprechend gibt es eine äquivalente Funktion MPI_Finalize(),
welche das System nach Beendigung aller MPI-Operationen dazu veranlasst, die
genutzten Ressourcen wieder freizugeben.
Mit [CH00] und [LA00] seien zwei kostenlose Distributionen zur MPI-Programmierung
genannt.
Kapitel 3: MPI
8
3.3 Vergleich MPI / OpenMP
Vor der weiteren Einführung in MPI werden kurz einige Unterschiede und
Gemeinsamkeiten zwischen Systemen mit verteiltem und gemeinsamem Speicher
erklärt. Für diesen Vergleich wird OpenMP herangezogen, da es einer der
Hauptvertreter für parallele Programmierung auf Systemen mit gemeinsamem Speicher
ist aber auch in Kombination mit MPI angewandt werden kann.
Während MPI zur Parallelisierung auf Nachrichtenaustausch zurückgreift, kann auf
einem System mit gemeinsamem Speicher auf Schleifen- oder Thread-Ebene
parallelisiert werden. OpenMP stellt eine Spezifikation für Thread-basierte
Parallelisierung dar. Hierbei wird nach dem fork-join-Prinzip gearbeitet, sprich ein von
vornherein bestehender Master-Thread führt das Programm so lange aus, bis ein parallel
auszuführender Programmteil auftaucht, welcher über einen vom Programmierer
angelegten Anweisungsblock explizit als solcher gekennzeichnet ist. Der Master erzeugt
nun ein Team von Threads (fork), die unter seiner Leitung den entsprechenden
Programmteil bearbeiten. Alle Threads arbeiten auf demselben Datenspeicher, eine
Änderung durch einen Thread ist also sofort für alle anderen, auch für Threads
außerhalb des Teams, sichtbar. Ein Nachrichtenaustausch ist hier somit nicht nötig.
Haben die Threads ihre Arbeit erfolgreich beendet, erfolgt der join, d.h. die erzeugten
Threads werden synchronisiert und anschließend beendet; lediglich der Master läuft
weiter.
Wie bereits erwähnt können MPI und OpenMP auch in Verbindung miteinander genutzt
werden. Dies ist vor allem dann sinnvoll, wenn mehrere Shared-Memory-Clients zu
einem Cluster zusammengeschlossen sind. Hier kann innerhalb eines Clients mit
OpenMP gearbeitet und zum Nachrichtenaustausch mit anderen Clients auf MPI
zurückgegriffen werden.
Ein tieferer Einblick in OpenMP bleibt an dieser Stelle aus, als Literatur für weitere
Recherche sei aber [RR00, S.273ff] oder [OP00] empfohlen.
3.4 Kommunikation
Wie bereits erwähnt, sind parallele Prozesse auf Systemen ohne gemeinsamen Speicher
darauf angewiesen Nachrichten über ein Netzwerk miteinander austauschen zu können.
Dieses Kapitel erläutert die Funktionsweise des Nachrichtenaustauschs mit MPI. Dabei
Kapitel 3: MPI
9
wird zuerst auf die generellen Rahmenbedingungen eingegangen unter denen Prozesse
kommunizieren können. Anschließend werden konkrete Punkt-zu-Punkt- und globale
Kommunikations-Operationen eingeführt.
3.4.1 Prozessgruppen und Kommunikatoren
Unter einer Prozessgruppe versteht sich eine Menge von geordneten, also durch so
genannte Ränge fortlaufend nummerierten Prozessen. Solche Gruppen sind besonders
interessant, wenn es um die Realisierung taskparalleler Programme geht, worunter man
Programme versteht, in denen verschiedene Programmteile unabhängig voneinander
und somit parallel, ausgeführt werden können. Prozessgruppen sind nicht zwingend
überschneidungsfrei, ein Prozess kann somit mehreren Prozessgruppen angehören und
hat innerhalb dieser jeweils einen gruppenspezifischen Rang. Jede Kommunikation
findet innerhalb eines Kommunikationsgebietes statt, welches lokal durch so genannte
Kommunikatoren dargestellt wird.
Prozesse können also eindeutig identifizierbar in Prozessgruppen zusammengefasst
werden und anhand des der Prozessgruppe zugeordneten Kommunikators miteinander
Nachrichten austauschen. Im Folgenden soll nun ein kurzer Einblick in die durch MPI
gegebenen Operationen für Prozessgruppen und Kommunikatoren gegeben werden.
Innerhalb der Kommunikatoren muss zwischen Inter- und Intra-Kommunikatoren
unterschieden werden. Mit den zuerst genannten lassen sich Punkt-zu-Punkt-
Kommunikationen zwischen zwei Prozess-Gruppen realisieren. Letztere erlauben die
Kommunikation der Prozesse einer Gruppe untereinander. Im Folgenden ist stets der
Intra-Kommunikator gemeint, die Kommunikation zwischen verschiedenen
Prozessgruppen wird hier nicht weiter behandelt.
Standardmäßig kann der vordefinierte Kommunikator MPI_COMM_WORLD genutzt
werden, der alle laufenden Prozesse miteinander kommunizieren lässt. Möchte man
allerdings die Möglichkeiten der Prozessgruppen nutzen, bietet es sich an eigene
Kommunikatoren zu definieren, die entweder auf bereits bestehenden Gruppen basieren
können, oder auch vorerst für sich allein gestellt angelegt werden können.
Möchte man eine Prozessgruppe erstellen, nutzt man je nach Zweck eine der in der
folgenden Erläuterung aufgeführten Funktionen
Kapitel 3: MPI
10
Die ersten drei Funktionen, die sich allesamt mit Mengenbildung zweier bestehender
Gruppen beschäftigen, benötigen die drei Parameter
MPI_Group g1, MPI_Group g2, MPI_Group *ng,
womit die zwei zu vermengenden Gruppen (g1 und g2) und die Zielgruppe (ng)
definiert werden.
Vereinigung zweier Gruppen g1 und g2 in eine neue Gruppe ng
int MPI_Group_union ( ... )
Schnittmengenbildung zweier Gruppen g1 und g2 in eine neue Gruppe ng
int MPI_Group_intersection ( ... ) Differenzmengenbildung zweier Gruppen g1 und g2 in eine neue Gruppe ng
int MPI_Group_difference ( ... ) Neben dem Vermengen von Gruppen stehen auch Funktionen zur Verfügung, die
Änderungen innerhalb einer Gruppe vornehmen können und folgende Parameter
verlangen:
MPI_Group g1, int p, int *ranks, MPI_Group *ng
Hierbei ist g1 wieder die zu betrachtende und ng die neu zu erstellende Gruppe. ranks
zeigt auf ein p-elementiges Array von Integern, welches die betroffenen Prozesse-
Indizies der bestehenden Gruppe angibt.
Die durch ranks indizierten Prozesse werden nun entweder in eine neue Gruppe
gepackt (Untermenge)
int MPI_Group_( ... ),
oder es wird eine neue Gruppe erzeugt, die alle nicht in ranks aufgeführten Prozesse
beinhaltet (Löschen)
int MPI_Group_excl( ... ),
Kapitel 3: MPI
11
Darüber hinaus bietet MPI Funktionen an, die Informationen über eine Prozessgruppe
liefern können. So lässt sich zum Beispiel die Größe (Anzahl der Prozesse) einer
Gruppe bestimmen. Das Ergebnis wird in size zurückgeliefert:
int MPI_Group_size ( MPI_Group group, int *size )
Entsprechend findet auch die Bestimmung des Indizes des aufrufenden Prozesses statt,
deren Ergebnis in rank zurückgeliefert wird:
int MPI_Group_rank ( MPI_Group group, int *rank )
Außerdem ist eine Gleichheitsprüfung zweier Gruppen möglich:
int MPI_Group_compare ( MPI_Group g1, MPI_Group g2, int *res )
Hierbei wird für res zwischen drei möglichen Rückgabewerten für identisch,
gleichartig (gleiche Prozesse, aber unterschiedliche Reihenfolge) und ungleich
unterschieden wird
Zu guter Letzt kann eine Gruppe über die Anweisung MPI_Group_free(MPI_Group
*group) wieder freigegeben werden.
Die drei genannten, Informationen liefernden Funktionen, sowie die Freigabe-Funktion
sind entsprechend auch für Kommunikatoren definiert (MPI_Comm_size,
MPI_Comm_rank, MPI_Comm_compare, MPI_Comm_free) und werden hier nicht
nochmals erläutert. Wichtig ist lediglich, dass hier Prozesse betroffen sind, die einer
dem Kommunikator zugeordneten Prozessgruppe angehören.
Einem Kommunikator eigen sind allerdings drei Funktionen, die das Erstellen, das
Duplizieren oder das Splitten eines solchen zur Aufgabe haben.
Die Erzeugung eines Kommunikators wird durch folgenden Aufruf erreicht:
int MPI_Comm_create ( MPI_Comm comm, MPI_Group group, MPI_Comm *ncomm )
Zum Einen muss group eine Teilmenge einer zum Kommunikator comm gehörenden
Gruppe sein, zum Anderen muss die Funktion von alle beteiligten Prozessen mit dem
selben Gruppen-Argument aufgerufen werden, damit diese einen Zeiger ncomm auf den
neuen Kommunikator erhalten. Alle Prozesse, die zwar einen Kommunikator teilen,
aber nicht zur durch group definierten Teilmenge gehören erhalten MPI_COMM_NULL
als Hinweis darauf, dass sie keinen neuen Kommunikator zugewiesen bekommen.
Kapitel 3: MPI
12
Das Duplizieren eines Kommunikators ist vergleichsweise simpel und enthält lediglich
zwei Argumente für den bestehenden und den neu zu erstellenden Kommunikator. Die
zugeordnete Gruppe und Topologie bleibt dabei erhalten, das Kommunikationsgebiet
allerdings ist ein neues.
int MPI_Comm_dup ( MPI_Comm comm, MPI_Comm *ncomm )
Zur Aufspaltung eines Kommunikators steht ebenfalls eine Funktion zur Verfügung:
int MPI_Comm_split ( MPI_Comm comm, int color, int key, MPI_Comm *ncomm )
Dabei werden die Prozesse der dem Kommunikator comm zugeordneten Prozessgruppe
in disjunktive Teilgruppen unterteilt. Dabei wird über den Parameter color die
Zugehörigkeit definiert, Prozesse die für color denselben Wert angeben, werden also
derselben neuen Untergruppe zugeteilt, wobei die Reihenfolge innerhalb der Gruppe
durch key festgelegt ist. Jeder beteiligte Prozess erhält in ncomm einen Zeiger auf den
Kommunikator der neuen Teilgruppe.
Näheres zu diesem Thema findet sich in [RR00, S197ff].
3.4.2 Einzeltransfer-Operationen
An einem Einzeltransfer (Punkt-zu-Punkt-Kommunikation) sind immer genau zwei
Prozesse in einer klassischen Sender-Empfänger-Beziehung beteiligt. Zur Durchführung
ist von beiden Prozessen die Ausführung von entsprechenden
Kommunikationsanweisungen nötig. Hierfür definiert MPI drei Funktionen. Zuerst sei
die Sendeoperation
int MPI_SEND( void *smessage, int count, MPI_Datatype type, int dest, int tag,
MPI_Comm comm )
genannt, welche eine Nachricht der Größe count und des Typs type aus dem
Sendepuffer smessage an den Prozess mit dem Index dest schickt, der sich innerhalb
des durch comm definierten Kommunikationsgebietes befindet. Zur Unterscheidung von
mehreren Nachrichten desselben Senders dient der Parameter tag als Markierung.
Kapitel 3: MPI
13
Entsprechend existiert eine Empfangsoperation
int MPI_RECV( void *rmessage, int count, MPI_Datatype type, int source, int tag, MPI_Comm comm, MPI_Status *status ),
die eine Nachricht vom Prozess mit dem Index source entgegennimmt, der durch die
Angabe von MPI_ANY_SOURCE aber nicht direkt bekannt sein muss. Der Parameter
status enthält Informationen über die empfangene Nachricht. Die übrigen Parameter
korrespondieren mit denen der Sendeoperation. Im Anhang A findet sich das Beispiel
QT01, welches eine einfache Nachrichtenübertragung abbildet.
Die dritte Funktion MPI_Sendrecv(...) ist eine Mischoperation zum
Senden/Empfangen von Nachrichten und enthält somit alle für das Senden und
Empfangen notwendigen Parameter aus den bereits bekannten Funktionen. Da diese
bereits besprochen wurden, werden sie hier nicht nochmals aufgeführt. Sollte man
allerdings nur einen Puffer für das Senden/Empfangen zur Verfügung haben, muss man
auf die Funktion MPI_Sendrecv_replace(...) ausweichen, die lediglich einen
Puffer als Parameter erwartet.
Wichtig ist hier, dass grundsätzlich jeder Prozess mit jedem anderen kommunizieren
kann. Wie erwähnt, muss der Sender den Empfänger dabei allerdings kennen, der
Empfänger hingegen kann Nachrichten auch von unbekannten Gesprächspartnern
entgegennehmen.
MPI_SEND und MPI_RECV sind so genannte blockierende Operationen, dass heißt sie
können unabhängig voneinander aufgerufen werden und müssen notfalls aufeinander
warten. Dabei blockieren sie jeweils den aufrufenden Prozess. Tatsächlich hängt das
Verhalten von der konkreten Implementierung ab, wobei meist eine der folgenden
Möglichkeiten angewandt wird:
• Die Nachricht wird ohne Zwischenspeicherung verschickt. Der Sender muss
warten bis der Empfänger die Nachricht entgegennimmt.
• Die Nachricht wird im Systempuffer des Senders gespeichert und kann vom
Empfänger dort abgerufen werden. Der Sender blockiert somit nur kurz,
Kapitel 3: MPI
14
allerdings setzt diese Methode somit zusätzlichen Speicher voraus und nimmt
zusätzliche Zeit für das Kopieren in den Puffer in Kauf.
Näheres wird im Laufe des Kapitels in der Thematik Übertragungsmodi erarbeitet.
Bei der Verwendung dieser beiden Operationen kann es unter Umständen zu
Verklemmungen (Deadlocks) kommen. Als einfaches Beispiel seien zwei Prozesse
genannt, die beide zuerst eine Nachricht empfangen und erst anschließend selbst eine
Nachricht verschicken. Beide Prozesse würden in der MPI_RECV-Operation verhungern.
Die Operation MPI_Sendrecv(...) beugt diesem Problem vor, da sie Sende- und
Empfangsoperation gemeinsam abbildet. Da Verklemmungen aber ein generelles, nicht
MPI spezifisches Problem darstellen, werden sie im Rahmen dieser Ausarbeitung nicht
weiter behandelt. Näheres findet sich aber in [RR00, S195ff] oder auch in [QU03,
S.505ff].
Allerdings stehen auch nicht blockierende Operationen zur Verfügung,
MPI_Isend(...) und MPI_Irecv(...) genannt. Diese benachrichtigen Das
System, dass eine Nachricht im Sendepuffer zur Verfügung steht, bzw. der
Empfangspuffer auf eine Nachricht wartet. Während auf die Abholung/Ankunft der
Daten gewartet wird, können die Prozesse sich anderen Aufgaben widmen, der
jeweilige Puffer wird aber bis zur vollständigen Ausführung der Operation gesperrt. Zu
diesem Zweck erwarten die beiden Operationen grundsätzlich die selben Parameter wie
die blockierenden Äquivalente, erweitern die Parameterliste aber um MPI_Request
*request. Dieser Parameter bezeichnet eine Datenstruktur, die zur Identifikation und
Informationsgewinnung über den Status der Ausführung der Operation dient. Diese
Informationen werden vom System dort abgelegt. Ob die Ausführung beendet wurde
kann über die Methode
int MPI_Test( MPI_Request *request, int *flag, MPI_Status *status ),
abgerufen werden, wobei request die die betroffene Operation adressiert. Ist sie
beendet, erhält flag den Rückgabewerte 1, sonst 0. Der Parameter status hingegen
ist nur dann mit einem Rückgabewert belegt, wenn man eine bereit beendete
Empfangsoperation prüft und enthält dann von ihr beschriebene Informationen. In allen
anderen Fällen ist er undefiniert.
Kapitel 3: MPI
15
Für sämtliche Operationen existieren drei verschiedene Übertragungsmodi. Im
Standardmodus wird über das System entschieden ob eine Zwischenspeicherung der
Nachrichten erfolgt. Um die Portabilität zu anderen Systemen sicherzustellen muss der
Programmierer die korrekte Funktionalität des Programms sicherstellen, sprich darauf
achten, dass es auch ohne Zwischenspeicherung richtig arbeitet.
Der synchrone Modus sorgt dafür, dass, wie der Name schon sagt, eine Synchronisation
zwischen Sender und Empfänger vorgenommen wird. im Rahmen der blockierenden
Operationen lassen sich dafür die Funktionen MPI_Ssend(...) und
MPI_Srecv(...) heranziehen, deren Parameter den bereits bekannten Methoden
entsprechen. Für die nicht blockierenden Operationen stehen MPI_Issend(...) und
MPI_Irecv(...) bereit. Diese benötigen allerdings zusätzliche Unterstützung der
Methode MPI_Wait(...) um die Synchronisierung zu gewährleisten, da sie ihrer Art
wegen normalerweise nicht warten.
Der Puffermodus besagt, dass nicht lokale Ereignisse keinen Einfluss auf die
Ausführung/Beendigung der lokalen Methoden haben dürfen. Die Nachrichten werden
im Bedarfsfall (Sendeoperation läuft, Empfangsoperation aber noch nicht) vom
Laufzeitsystem in Puffern zwischengespeichert. Diese müssen aber vom Programmierer
in ausreichender Größe zur Verfügung gestellt werden. Genutzt werden können die
blockierende Funktion MPI_Bsend(...) und die nicht blockierende
MPI_Ibsend(...). Als fortführende Lektüre sei auf [RR00, S.179ff] verwiesen.
3.4.3 Globale Kommunikations-Operationen
Neben den Einzeltransfer-Operationen gibt es auch solche, die Kommunikationen
zwischen allen oder zumindest mehreren Prozessen ermöglichen. Eine solche globale
Kommunikation kann verschiedene Zwecke erfüllen, welche im Folgenden anhand der
durch MPI zur Verfügung gestellten Funktionen vorgestellt werden. Im Gegensatz zu
den Punkt-zu-Punkt-Operationen muss hier jeder beteiligte Prozess dieselbe Funktion
mit ggf. für Sender und Empfänger unterschiedlichen Werten für die Parameter
aufrufen.
Sämtliche hier aufgeführten Operationen sind blockierend. Allerdings kann jeder
beteiligte Prozess sofort andere Programmteile ausführen, sobald seine Beteiligung an
der Operation abgeschlossen ist. Eine Synchronisation ist somit nicht unbedingt
gegeben, da es z.B. vorkommen kann, dass einige Prozesse bereits andere Operationen
Kapitel 3: MPI
16
ausführen, während andere die entsprechende Kommunikationsoperation noch nicht
aufgerufen haben. Die Funktionen werden durch Abbildungen ergänzend beschrieben,
wobei pi jeweils den Prozess mit dem Index i beschreibt.
Bei der Broadcastoperation
int MPI_Bcast( void *message, int count, MPI_Datatype type, int root, MPI_Comm comm )
werden alle Prozesse von einem einzigen Prozess (im Folgenden als root oder
Wurzelprozess bezeichnet) mit denselben Daten beschickt. Jeder empfangende Prozess
muss denselben Prozess als root definieren und den gleichen Kommunikator comm
nutzen. message, count und type geben wie üblich den Nachrichtenpuffer, die -größe
und den -typ an. im Folgenden werden diese Parameter, sofern frei von Besonderheiten,
auch nicht mehr aufgeführt. Da die Broadcastoperation keinen Markierungs-Parameter
enthält ist wichtig zu wissen, dass, sollte der root mehrere Broadcast-Nachrichten
versenden, diese auch in derselben Reihenfolge von den betroffenen Prozessen
empfangen werden (auch wenn die Funktionen asynchron aufgerufen werden).
Abb. 3.4.3-1
Die Akkumulationsoperation
int MPI_Reduce( void *sendbuf, void *recvbuf, int count, MPI_Datatype type,
MPI_Op op, int root, MPI_Comm comm)
wird dann angewandt, wenn mehrere Prozesse Daten zur Verfügung stellen, die anhand
einer vordefinierten Reduktionsoperation bearbeitet werden (z.B. Maximum, Minimum
oder Summe). Hier stellen die beteiligten Prozesse dem root Nachrichten zur
Verfügung, für die eine bestimmte Reduktion op durchgeführt wird. Man ist nicht auf
die Verwendung der von MPI vordefinierten Reduktionsoperationen angewiesen, da
sich über die Funktion
int MPI_OP_create( MPI_User_function *function, int commute, MPI_OP *op )
Kapitel 3: MPI
17
eigene Operationen konstruieren lassen. function verweist dabei auf eine vom
Programmierer zur Verfügung zu stellende Funktion, die vier Parameter void *a,
void *b, int *len und MPI_Datatype *type mitbringen muss. Es wird dann eine
Reduktion in der Form b[i] = a[i] op b[i] vorgenommen, wobei der Parameter
commute angibt, ob eine kommutative Operation vorliegt.
Abb. 3.4.3-2
Durch Anwendung der Gatheroperation
int MPI_Gather( void *sendbuf, void sendcount, MPI_Datatype sendtype, void *recvbuf, void recvcount, MPI_Datatype recvtype, int root, MPI_Comm comm )
wird der Wurzelprozess root von allen anderen Prozessen mit Daten versorgt, ohne
dass eine Reduktionsoperation durchgeführt wird. Die Größe der insgesamt
empfangenen Nachricht ist beim root somit größer ist als die von den Prozessen
jeweils versandten. Jeder beteiligte Prozess muss denselben Prozess als root
adressieren und eine Nachricht in einer einheitlichen Größe versenden. Möchte man
letzteres vermeiden, kann auf die Vektorvariante MPI_Gatherv(...) zurückgegriffen
werden. Statt dem Parameter recvcount der die konkrete Nachrichtengröße angibt
werden hier zwei Parameter recvcounts und displs geführt, wobei der erste ein
Integerfeld adressiert, welches in i-ter Stelle die Nachrichtengröße für den Prozess i
angibt, der zweite hingegen festlegt an welcher Stelle im Empfangspuffer des
Wurzelprozesses die jeweilige Nachricht abgelegt wird. Hier liegt es im
Aufgabenbereich des Programmierers, sicherzustellen, dass Stellen im Empfangspuffer
nicht doppelt belegt sind, und Nachrichtengrößen bei Sender und Empfänger identisch
festgelegt sind.
Kapitel 3: MPI
18
Abb. 3.4.3-3
Die Scatteroperation MPI_Scatter(...) entspricht in ihren Parametern der eben
besprochenen Gatheroperation. Grundlegend entspricht sie in ihrer Funktion einem
Broadcast, allerdings kann der Wurzelprozess hier unterschiedliche Daten (derselben
Größe) an jeden beteiligten Prozess senden. Hier gilt, dass, sollte man Nachrichten
unterschiedlicher Größe versenden wollen, man ebenfalls eine Vektorvariante
MPI_Scatterv(...) nutzen kann, welche dieselben Besonderheiten wie die zuvor im
Zuge der Gatheroperation vorgestellte aufweist.
Abb. 3.4.3-4
Die Multi-Broadcastoperation
int MPI_Allgather( void *sendbuf, void sendcount, MPI_Datatype sendtype, void *recvbuf, void recvcount, MPI_Datatype recvtype, MPI_Comm comm )
ist die erste Funktion, die ohne ausgezeichneten root auskommt. Alle beteiligten
Prozesse versorgen sich gegenseitig mit Daten (beispielsweise Teilergebnisse von
Berechnungen), hier werden somit keine Daten von zentraler Stelle verteilt, oder an
zentraler Stelle gesammelt. Auch hier gilt wieder: Erst durch eine Vektorvariante
MPI_Allscatterv(...) lassen sich Nachrichten mit unterschiedlicher Anzahl von
Elementen verschicken. Auch hier muss auf die zuvor erwähnten Besonderheiten
geachtet werden.
Kapitel 3: MPI
19
Abb. 3.4.3-5
Der Zweck der Multi-Akkumulationsoperation
int MPI_Allreduce( void *sendbuf, void *recvbuf, int count, MPI_Datatype type, MPI_Op op, int root, MPI_Comm comm )
liegt lediglich darin, alle beteiligten Prozesse mit dem Ergebnis der durchgeführten
Akkumulation zu versorgen. Diese Operation ließe sich also auch durch ein
MPI_Reduce(...) gefolgt von einem MPI_Bcast(...) ersetzen.
Abb. 3.4.3-6
Von einem totalen Austausch
int MPI_Alltoall( void *sendbuf, void sendcount, MPI_Datatype sendtype, void *recvbuf, void recvcount, MPI_Datatype recvtype, MPI_Comm comm )
spricht man, wenn jeder Prozess mit jedem anderen interagiert, also (durchaus
unterschiedliche) Nachrichten austauscht. Auch diese Funktion braucht somit keinen
ausgezeichneten Wurzelprozess. Da diese Operation ebenfalls standardmäßig mit
Nachrichten indentischer Größe arbeitet, gibt es wieder eine Vektorvariante
MPI_Alltoallv(...). Auch hier gelten die bereits erwähnten speziellen
Eigenschaften.
Kapitel 3: MPI
20
Abb. 3.4.3-7
Näheres zu allen, auch den hier nicht aufgeführten, Funktionen findet sich in [RR00, S.
182ff] oder in [QU03, S450, ff]. Zur Verdeutlichung ist ein die Broadcast- und
Akkumulationsoperation nutzendes Beispiel angehängt (Anhang A, QT02).
3.5 Zeitmessung (Benchmarking)
Die Zeitmessung eines MPI-Programms hebt sich nicht von der eines Programms im
Allgemeinen ab. Allerdings ist das so genannte Benchmarking gerade bei verteilter
Programmierung interessant um bestimmte Fragestellungen zu klären:
• Wie lange nimmt die Bearbeitung eines speziellen Problems in Anspruch?
• Welcher Zeitvorteil ergibt sich durch zusätzliche Hardware? Eine beispielhafte
Problematik wäre: Entspricht eine Verdopplung der Prozessorkapazität auch
einer Verdopplung der effektiven Bearbeitungsgeschwindigkeit?
• Identifizierung und Quantifizierung von brachliegenden Ressourcen, wenn
beispielsweise unterschiedlich schnelle Prozessoren genutzt werden und die
schnelleren Modelle ihre Teilaufgabe längst bearbeitet haben, während die
langsameren noch rechnen.
MPI bietet zu diesem Zweck eigene Funktionen an, die eine integrierte Zeitmessung
möglich machen. Der Ablauf einer solchen Zeitmessung kann wie folgt aussehen
(Ausschnitt):
start = MPI_Wtime(); ... zu messender Programmteil ... end = MPI_Wtime();
Die Differenz zwischen den beiden Werten (end-start) liefert die benötigte Zeit in
Sekunden. Über eine weitere Funktion MPI_Wtick() lässt sich außerdem bestimmen,
mit welcher Auflösung die Zeitmessung erfolgt ist, z.B. liefert sie auf einem System
dessen Zeitzähler jede Millisekunde inkrementiert eine 10-3 als Rückgabewert.
Kapitel 3: MPI
21
3.6 Prozesstopologien
Bisher wurde eine Kommunikation mit einem Prozess stets über seinen innerhalb einer
Gruppe festgelegten Index abgewickelt. Spätestens dann, wenn man komplexere
Berechnungen in mehrdimensionalen Räumen vornehmen will, in dem jeder einem
Gitterpunkt zugeordnete Prozess mit seinen direkten Nachbarn im Gitter kommuniziert,
ist es sinnvoll andere Adressierungen einzuführen. MPI sieht hierfür so genannte
virtuelle Topologien vor. Diese lassen sich über die Funktion
int MPI_Cart_create( MPI_Comm comm, int ndims, int *dims, int *periods,
int reorder, MPI_Comm *ncomm)
anlegen. Basierend auf einem bestehenden Kommunikator comm wird ein Gitter mit
ndims Dimensionen angelegt. dims verweist auf ein Feld, dass für jede Dimension die
Anzahl der Prozesse definiert und über den korrespondierenden Eintrag des Feldes
periods wird festgelegt ob die Prozesse einer Dimension zyklisch verbunden werden
sollen. Ob die durch comm vorgegebene Reihenfolge übernommen, oder für den neuen
Kommunikator ncomm eine eventuell durch das Laufzeit für die Gitterdarstellung
optimierte Anordnung vorgenommen wird, bestimmt der Parameter reorder.
Bedingt durch die Komplexität einer solchen mehrdimensionalen Ausrichtung stellt
MPI eine weitere Funktion
int MPI_Dims_create( int nnodes, int ndims, int *dims)
zur Verfügung, welche das Verteilen der Prozesse auf die gewünschten Dimensionen
erleichtern soll. Hierbei wird über nnodes die Anzahl der Prozesse für das Gitter und
über ndims die Anzahl der Dimensionen vorgegeben. Das Feld dims entspricht dem
der zuvor besprochenen Funktion.
MPI stellt weitere Funktionen zur Verfügung, die das Umrechnen zwischen den
kartesischen Koordinaten der Gitterdarstellung und dem Prozess-Index ermöglichen,
bzw. umgekehrt. Darüber hinaus lassen sich die direkten Nachbarn im Gitter ermitteln
oder das Gitter in Teilgitter zerlegen. Dieses Kapitel aber bereits über die Grundlagen
von MPI hinausgeht, deshalb bleibt einer weitere Erläuterung dieser Funktionen aus.
Wichtig ist hier, dass MPI ein solches Konzept vorgibt und dieses durch verschiedenste
Funktionen bestückt.
Kapitel 4: Fazit
22
4 Fazit
Die vorangegangenen Kapitel haben wesentliche Grundlagen der parallelen
Programmierung mit MPI erläutert. Dabei wurden Parallel-Rechner zuerst definiert und
anschließend eine Klassifizierung vorgenommen in welche MPI dann eingeordnet
werden konnte.
Basierend auf Systemen mit verteiltem Speicher und dem daraus hervorgehenden
Zwang zum Nachrichtenaustausch erfolgte im Anschluss eine Einführung in das
Message Passing Programmiermodell. Darüber hinaus wurde anhand von OpenMP eine
Abgrenzung zu äquivalenten Spezifikationen für Systeme mit gemeinsamem Speicher
vorgenommen.
Um in die Möglichkeiten von MPI einzuführen, wurde die Thematik der
Kommunikation beginnend bei Prozessgruppen und Kommunikatoren über die
Einzeltransfer-Operationen bis hin zum globalen Nachrichtenaustausch eingehend
erläutert und anhand von Beispielen verdeutlicht.
Mit den darauf folgenden Kapiteln Zeitmessung (Benchmarking) und Prozesstopologien
wurden zwei Themenbereiche bearbeitet, die gerade im Zuge der MPI-Programmierung
von besonderem Interesse sind.
Im Rahmen der Seminararbeit ist es nicht möglich auf alle Einzelheiten des Themas
MPI einzugehen. Sie soll lediglich als Einstieg und Überblick dienen. Für eine
umfassendere Analyse des Themas sei auf die Quellen im Literaturverzeichnis
verwiesen.
A Quelltexte
QT01 – Nachrichtenübertragung von Prozess 0 an Prozess 1
#include <stdio.h> #include <string.h> #include “mpi.h” int main (int argc, char *argv[]) { int my_rank, source, dest, tag=0; char msg [20]; MPI_Status status; // MPI Initialisieren MPI_Init (&argc, &argv); // Eigenen Rang bestimmen (wird in Kapitel 3.4.3 erläutert) MPI_Comm_rank ( MPI_COMM_WORLD, &my_rank); // Der Prozess mit Rang 0 sendet eine Nachricht... if (my_rank == 0) { strcpy (msg, “Testnachricht”); MPI_Send ( msg, strlen(msg)+1, MPI_Char, 1, tag, MPI_COMM_WORLD); } // ...und Prozess 1 empfängt sie if (my_rank == 1) { MPI_Recv ( msg, 20, MPI_Char, 0, tag, MPI_COMM_WORLD, &status); } // MPI beenden MPI_Finalize(); }
QT02 – Broadcast & Akkumulation am Beispiel der parallelen Berechnung von PI
Die Funktion dient lediglich der Erläuterung der Funktionsweise. Wie genau die
Berechnung von PI hier erfolgt ist nicht von Belang und wird deshalb nicht eingehend
erläutert.
#include <stdio.h> #include <string.h> #include “mpi.h” int main(int argc, char *argv[]) { int rank, size; double x, pi, pi_part=0, start, stop, step; // MPI Initialisieren MPI_Init(&argc, &argv); // Eigenen Rang bestimmen MPI_Comm_rank(MPI_COMM_WORLD, &rank); MPI_Comm_size(MPI_COMM_WORLD, &size); /* Prozess 0 liest eine für die Bestimmung von PI * notwendige Berechnung von Teilintegralen * vorgesehene Schrittweite ‘step’ ein */ if(rank==0) scanf("%lf", &step); // ‘step’ wird an die beteiligten Prozesse verteilt MPI_Bcast(&step, 1, MPI_DOUBLE, 0, MPI_COMM_WORLD); // Teilintegral berechnen start = (1.0*rank) / size; stop = start + 1.0/size; for(x=start; x<stop; x+=step) { pi_part += step * (4/(1+x*x)); } /* Mit Hilfe der Akkumulationsoperation MPI_SUM werden die * berechneten Teilintegrale aller Prozesse im Puffer pi * des Wurzelprozesses (Prozess 0) aufaddiert */ MPI_Reduce( &pi_part, &pi, 1, MPI_DOUBLE, MPI_SUM, 0,MPI_COMM_WORLD); // berechneten PI-Wert ausgeben if(rank==0) printf("pi=%.16f\n", pi); // MPI beenden MPI_Finalize(); }
Literaturverzeichnis
[MP00] MPI-Website http://www.mpi-forum.org/
[RR00] Thomas Rauber, Gudula Rünger: Parallele und verteilte Programmierung, Springer-Lehrbuch, 2000
[QU03] M.J. Quinn: Parallel Programming in C with MPI and OpenMP, McGraw-Hill, 2003
[OP00] OpenMP-Website http://www.openmp.org
[CH00] MPI-CH-Website: http://www-unix.mcs.anl.gov/mpi/mpich/
[LA00] LAM-MPI-Website: http://www.lam-mpi.org/