235 98 20MB
German Pages 387 [388] Year 2007
Für Patty, Brigid und Jennifer
MPI-Eine Einführung Portable parallele Programmierung mit dem Message-Passing Interface von William Gropp, Ewing Lusk, Anthony Skjellum
Übersetzt von Dr. Holger Blaar, Halle Wissenschaftliche Leitung der Übersetzung: Prof. Dr. Paul Molitor, Martin-Luther-Universität Halle-Wittenberg
Oldenbourg Verlag München Wien
Autorisierte Übersetzung der englischsprachigen Ausgabe, die bei The MIT Press, Massachusetts Institute of Technology unter dem Titel: Using MPI: portable parallel programming with the message-passing interface / William Gropp, Ewing Lusk, Anthony Skjellum.- 2nd ed. erschienen ist. Copyright © 1999 Massachusetts Institute of Technology Autoren: William Gropp, Senior Computer Scientist, Argonne National Laboratory Ewing Lusk, Senior Computer Scientist, Argonne National Laboratory Anthony Skjellum, Professor für Informatik, University of Alabama at Birmingham Übersetzung: Dr. Holger Blaar, Wissenschaftlicher Mitarbeiter an der Martin-Luther-Universität HalleWittenberg Wissenschaftliche Leitung der deutschen Übersetzung: Prof. Dr. Paul Molitor, Institut für Informatik, Martin-Luther-Universität Halle-Wittenberg
Bibliografische Information der Deutschen Nationalbibliothek Die Deutsche Nationalbibliothek verzeichnet diese Publikation in der Deutschen Nationalbibliografie; detaillierte bibliografische Daten sind im Internet über abrufbar.
© 2007 Oldenbourg Wissenschaftsverlag GmbH Rosenheimer Straße 145, D-81671 München Telefon: (089) 45051-0 oldenbourg.de Das Werk einschließlich aller Abbildungen ist urheberrechtlich geschützt. Jede Verwertung außerhalb der Grenzen des Urheberrechtsgesetzes ist ohne Zustimmung des Verlages unzulässig und strafbar. Das gilt insbesondere für Vervielfältigungen, Übersetzungen, Mikroverfilmungen und die Einspeicherung und Bearbeitung in elektronischen Systemen. Lektorat: Dr. Margit Roth Herstellung: Dr. Rolf Jäger Coverentwurf: Kochan & Partner, München Gedruckt auf säure- und chlorfreiem Papier Druck: Grafik + Druck, München Bindung: Thomas Buchbinderei GmbH, Augsburg ISBN 978-3-486-58068-6
Reihenvorwort Die moderne Computerwelt hält zahlreiche nützliche Methoden und Werkzeuge für Wissenschaftler und Anwender bereit, doch die rasante Entwicklung von Hardware, Software und Algorithmen erschwert oft den praktischen Einsatz moderner Technologien. Die Reihe „Scientific and Engineering Computation" konzentriert sich auf neueste Entwicklungen in der Computertechnologie und versucht, den Weg dieser Technologien in Anwendungen für Forschung und Industrie zu ebnen. Sie wird Bücher über Theorien, Methoden und praktische Anwendungen aus Gebieten wie Parallelität, Hochleistungssimulation, zeitkritische Berechnungen, computergestütztes Entwerfen, Einsatz von Computern in der Produktion, Visualisierung wissenschaftlicher Daten oder Mensch-MaschineKommunikation enthalten. Diese Serie wird Wissenschaftlern und Anwendern helfen, einerseits ihr Wissen zu modernen, hochentwickelten Rechenprozessen aktuell zu halten und andererseits zukünftige Entwicklungen abzusehen, die sich auf die Bedingungen des computergestützten Rechnens auswirken werden und ihnen neue Ressourcen und Formen hierfür offenbaren können. Das vorliegende Buch unserer Reihe beschreibt die Anwendung des Message-Passing Interface (MPI), einer Kommunikationsbibliothek für Parallelrechner und WorkstationNetzwerke, die als Standard für den Nachrichtenaustausch und verwandte Operationen entwickelt wurde. Die Nutzung des Standards durch Anwender einerseits und Entwickler andererseits gibt der Gemeinschaft derer, die mit der Parallelverarbeitung befasst sind, die erforderliche Portabilität und Funktionalität, um Programme und parallele Bibliotheken zu implementieren, die die Leistung heutiger (und zukünftiger) Hochleistungsrechner erschließt.
Janusz S. Kowalik
Vorwort zur zweiten Auflage Als Using MPI im Jahr 1994 veröffentlicht wurde, war die Zukunft von MPI noch ungewiss. Die Standardisierung durch das MPI-Forum war gerade abgeschlossen, und es blieb abzuwarten, ob die Hersteller optimierte Implementierungen anbieten würden oder ob die Anwender nun MPI zum Schreiben neuer paralleler Programme nutzen oder sogar bereits vorhandene Quellcodes zu MPI-Programmen portieren würden. Diese Zeit der Ungewissheit gehört inzwischen der Vergangenheit an. MPI ist überall verfügbar und von kleineren Workstation-Netzwerken bis hin zu den schnellsten Rechnern der Welt mit Tausenden von Prozessoren verbreitet. Jeder Hersteller von Parallelrechnern bietet eine MPI-Implementierung an, viele davon sind kostenlos und auf einer Reihe verschiedener Plattformen ausführbar. Kleine und große Anwendungen wurden nach MPI portiert oder von Anfang an als MPI-Programme geschrieben. MPI wird weltweit in Kursen zur parallelen Programmierung gelehrt. 1995 trat das MPI-Forum erneut zusammen. Dabei wurde die MPI-Spezifikation sowohl überarbeitet — unter Erhaltung der Kompatibilität — als auch entscheidend erweitert. Die Version 1.2, die die Themenkreise der originalen Spezifikation (Version 1.0) behandelt und Version 2.0, die vollkommen neue Gebiete umfasst, wurden im Sommer des Jahres 1997 veröffentlicht. Daher wird im vorliegenden Buch die erste Auflage von Using MPI auf den aktuellen Stand gebracht, um die neuesten Festlegungen des MPIForums zu reflektieren. Kurz gesagt: dieses Buch beschreibt die Verwendung von MPI 1.2, während Using MPI 2, welches bei MIT Press als eine begleitende Ausgabe zum vorliegenden Buch erschienen ist, die Erweiterungen von Version 2.0 abdeckt. Neue Themen in MPI 2.0 sind parallele Ein- und Ausgabe, einseitige Operationen und dynamische Prozessverwaltung. Viele der für die originalen MPI-Funktionen wichtigen Aspekte sind aber geändert worden — diese werden hier besprochen. Das vorliegende Buch kann also als aktualisierte Fassung der Originalausgabe angesehen werden.
Uber die zweite Ausgabe Diese zweite Ausgabe von Using MPI: Portable Programming with the Message-Passing Interface beinhaltet viele Änderungen und Erweiterungen gegenüber der ersten Auflage: • Wir haben zahlreiche neue Beispiele und zusätzliche Erläuterungen zu den Beispielen der ersten Ausgabe hinzugefügt. • Mehrere Kapitel wurden um Abschnitte zu typischen Fehlern und Missverständnissen erweitert. • Wir haben neues Material, das den Einfluss alternativer MPI-Anwendungen auf die Laufzeit zeigt, hinzugefügt.
Vorwort zur zweiten Auflage
VII
• Es wurde ein Kapitel zu Problemen der Implementierung hinzugefügt, um verständlich zu machen, wie und warum sich MPI-Implementierungen unterscheiden, vor allem hinsichtlich ihrer Leistung. • Da mit „Fortran" heute Fortran 90 (oder Fortran 95 [ABM+97]) gemeint ist, wurden alle Fortran-Beispiele entsprechend der Syntax von Fortran 90 aktualisiert. Zusätzlich erklären wir alle notwendigen Änderungen, um diese Beispiele in Fortran 77 ausführen zu können. • Zu allen Funktionen, die in diesem Buch beschrieben werden, ist auch die Notation in C++ angegeben, einige C++-Beispiele wurden in den Text aufgenommen. • Zusätzlich zu den neuen Funktionen der MPI 1.2 Spezifikation haben wir auch die Funktionen des Standards 2.0 hinzugefügt, deren Einführung offensichtlich mit Funktionen von MPI 1.2 verknüpft ist. • Wir beschreiben neue Werkzeuge des MPE Toolkits und betrachten deren Entwicklung seit der Veröffentlichung der ersten Auflage. • Das Kapitel zum Übergang von den frühen Message-Passing-Systemen zu MPI haben wir grundlegend überarbeitet, da inzwischen viele dieser Systeme vollständig von MPI verdrängt worden sind. Aufgrund der weiter gehenden Uberführung von PVM-Programmen zu MPI-Programmen werden in einer Ergänzung beide Systeme mit Blick auf ihre Syntax und Semantik verglichen. Außerdem stellen wir MPI der Verwendung von Unix-Sockets gegenüber. • Einige Funktionen von MPI 1.0 werden mittlerweile nicht weiter unterstützt, da es bereits bessere Definitionen gibt. Wir nennen diese und beschreiben ihre Nachfolger. • Fehler, insbesondere in den Beispielprogrammen, wurden korrigiert. Die Reihenfolge unserer Präsentation ist erneut durch die Komplexität der Algorithmen, die wir betrachten, bedingt. Diese lehrbuchartige Herangehensweise unterscheidet sich deutlich von der Art und Weise, in der formellere Darstellungen des MPI-Standards, wie ζ. B. [SOHL+98], gehalten sind. In Kapitel 1 beginnen wir mit einem kurzen Uberblick zur derzeitigen Situation paralleler Entwicklungsumgebungen, zum Message-Passing-Modell und zur Entwicklung, aus der heraus MPI entstand. Kapitel 2 führt in die grundlegenden Konzepte ein, die direkt aus dem Message-Passing-Modell hervorgehen, und erklärt, wie MPI diese erweitert, um eine voll funktionsfähige, hochgradig leistungsfähige Schnittstelle zu definieren. In Kapitel 3 legen wir ein Schema fest, nach dem wir auch in den folgenden Kapiteln vorgehen. Neben einigen Beispielen und der kleinen Mindestmenge an MPI-Funktionen, die zu deren Umsetzung erforderlich ist, beschreiben wir, wie die Beispiele unter Verwendung einer MPI-Modellimplementierung ausgeführt werden müssen. Außerdem demonstrieren wir, wie die Leistungsfähigkeit eines Programms mit Hilfe eines graphischen Analysewerkzeugs eingeschätzt werden kann. Wir schließen mit einem Beispiel für eine
VIII
Vorwort zur zweiten Auflage
sehr aufwändige Anwendung: ein Programm des Argonne National Laboratory zur Nuklearstruktur, das nur die in diesem Kapitel bereits vorgestellten Funktionen benutzt. Kapitel 4 stellt die grundlegenden Fähigkeiten von M P I heraus, indem, stellvertretend für eine ganze Klasse von Problemen, das Poisson-Problem als spezielle Anwendung in den Blickpunkt gerückt wird. Wir führen hierbei die Funktionalität von M P I zur Umsetzung anwendungsorientierter Prozessstrukturen, die sogenannten virtuellen Topologien, ein. Mit Hilfe unseres Werkzeugs zur Leistungsanalyse veranschaulichen wir, wie die Laufzeit durch geringfügig erweiterte MPI-Funktionen für den Nachrichtenaustausch verbessert werden kann. Wir schließen mit der Erläuterung eines Anwendungscodes, der bei der Erforschung des Phänomens der Hochtemperatur-Supraleitung eingesetzt wird. Einige der anspruchsvolleren Funktionen für das Message-Passing in M P I werden in Kapitel 5 besprochen. Zur Illustration nutzen wir das N-Körper-Problem. Wir komplettieren unsere Diskussion der abgeleiteten Datentypen und demonstrieren die Verwendung der MPE-Graphikbibliothek mit einer speziellen Berechnung von Mandelbrotmengen. Wir glauben, dass sich die Mehrzahl der Programmierer paralleler Systeme den Zugang zur Parallelität langfristig über Bibliotheken erschließen wird. Tatsächlich ist die Erstellung robuster Bibliotheken eines der Hauptanliegen bei der Entwicklung von M P I gewesen — und vielleicht sogar das einzige Merkmal, welches M P I sehr deutlich von anderen parallelen Entwicklungsumgebungen unterscheidet. In Kapitel 6 besprechen wir diesen Sachverhalt anhand einer Reihe von Beispielen. Bis zu dieser Stelle des Buches wurde vieles der hochentwickelten Funktionalität in M P I nur angeschnitten oder in einfachster Form präsentiert. Es gibt aber sorgfältig ausgearbeitete Schemata für die kollektive Datenverteilung und -Sammlung, Routinen für die Fehlerbehandlung sowie Möglichkeiten für die Implementierung von Client-ServerAnwendungen. In Kapitel 7 werden wir die Beschreibung dieser Funktionen mit Hilfe weiterer Beispielanwendungen vervollständigen. Auch auf Funktionen zur Ermittlung von Eigenschaften bzw. Grenzen der spezifischen MPI-Umgebung werden wir im Detail eingehen. Kapitel 8 erklärt, was „unter der Motorhaube" von MPI-Implementierungen steckt. Das Verständnis für die Möglichkeiten, unter denen bei einer Implementierung von M P I gewählt werden kann, sollte das Verhalten von MPI-Programmen in verschiedenen Umgebungen besser erklärbar machen. Kapitel 9 zeigt einen Vergleich von M P I mit zwei anderen Systemen, die oft zur Umsetzung des Message-Passing-Modells verwendet werden. P V M ist älter als M P I und immer noch weit verbreitet. Die „Socket"-Schnittstelle sowohl für Unix- als auch Microsoftsysteme ermöglicht den Betriebssystemen die Kommunikation zwischen Prozessen auf verschiedenen Maschinen. Wir wissen sehr gut, dass mit dem Message-Passing als Berechnungsmodell das letzte Wort in der parallelen Programmierung noch nicht gesprochen ist. So werfen wir in Kapitel 10 dieses Buches einen kurzen Blick auf die Thematiken „aktive Nachrichten", „Threads", „verteilter gemeinsamer Speicher" (engl.: distributed shared memory) sowie auf einige andere Themen. Schließlich versuchen wir, die zukünftige Entwicklung sowie Erweiterungen von M P I zu prognostizieren.
Vorwort zur zweiten Auflage
IX
Die Anhänge beschäftigen sich zum größten Teil mit der Software, die zur Ausführung und Analyse der im Buch verwendeten Beispiele benötigt wird. Anhang Α umfasst die Sprachfestlegungen für die C-, C++- und Fortran-Versionen aller MPI-Routinen. Anhang Β beschreibt kurz die Architektur von MPICH, einer freien MPI-Implementation, und gibt Unterstützung zur Installation und Ausführung. Anhang C erläutert Zugang und Nutzung der Bibliothek M P E (Multiprocessing Environment) zusammen mit dem Programm upshot zur Programmvisualisierung, das wir in diesem Buch benutzen. Anhang D gibt an, wie man ergänzende Materialien zu diesem Buch erhalten kann, so die kompletten Quelltexte der Beispiele oder Materialien zu MPI. Alles steht über anonymes f t p und das World Wide Web zur Verfügung. Im Anhang Ε werden einige C- und Fortran-Details erläutert, die für MPI relevant und einigen Lesern möglicherweise nicht bekannt sind.
Danksagungen zur zweiten Auflage Wir danken Peter Lyster vom Goddard Space Flight Center der NASA, der uns sein bearbeitetes Exemplar der ersten Auflage von Using MPI zur Verfügung stellte. Wir danken Puri Bangalore, Nicholas Carriero, Robert van de Geijn, Peter Junglas, David Levine, Bryan Putnam, Bill Saphir, David J. Schneider, Barry Smith und Stacey Smith (und jeden, den wir vergessen haben) für die Hinweise auf Fehler in der ersten Ausgabe. Desweiteren danken wir Anand Pillai, der einige der Beispiele in Kapitel 6 korrigiert hat. Die Rezensenten des Verkaufsprospektes für dieses Buch lieferten viele hilfreiche Vorschläge zur Thematik. Wir danken auch Gail Peiper für ihr sorgfältiges und sachkundiges Lektorat.
Vorwort zur ersten Auflage
Über dieses Buch Im Jahr 1993 arbeitete eine recht breit gefächerte Gruppe von Parallelrechnerfirmen, Softwareentwicklern und Anwendern gemeinsam an der Entwicklung der Definition einer standardisierten, portablen Message-Passing-Bibliothek. So entstand MPI (MessagePassing Interface), die Spezifikation einer Bibliothek von Routinen, die in C- oder Fortran-Programmen aufgerufen werden können. Seit der Mitte des Jahres 1994 wurden zahlreiche Implementierungen entwickelt, erste Anwendungen bereits portiert. Using MPI: Portable Parallel Programming with the Message-Passing Interface wurde geschrieben, um die Entwicklung paralleler Anwendungsprogramme und Bibliotheken zu beschleunigen — das Buch veranschaulicht den Umgang mit dem neuen Standard. Es füllt die Lücke zwischen Lehrbüchern mit Einführungen in die Parallelverarbeitung, theoretischen Abhandlungen über parallele Algorithmen für wissenschaftliche Berechnungen sowie Handbüchern zu verschiedensten parallelen Programmiersprachen und Systemen. Jede Thematik wird mit einigen einfachen Beispielen eingeleitet und schließt mit Anwendungen aus der Praxis, die auf den derzeit schnellsten Rechnern eingesetzt werden. Dabei benutzen wir sowohl Fortran (Fortran 77) als auch C. Von Anfang an diskutieren wir die Zeit- und Leistungsbewertung unter Benutzung einer Bibliothek von speziell für diese Publikation entwickelten Werkzeugen. Daher ist das Buch nicht nur ein Tutorial zu MPI als ein Mittel zur Formulierung paralleler Algorithmen, sondern auch ein Handbuch für alle, die die Leistungsstärke von aufwändigen Anwendungen und Bibliotheken verstehen und verbessern wollen. Ohne einen Standard wie MPI könnte man sich zu Problemen der Parallelverarbeitung nur mit solchen Anwendern austauschen, die über die gleiche Entwicklungsumgebung verfügen, über die viele andere Anwender wiederum nicht verfügen. MPI besitzt die erforderliche Portabilität, um konkrete Diskussionen zur Parallelverarbeitung mit breiter Anwendungsrelevanz führen zu können. Gleichzeitig ist MPI eine mächtige und vollständige Spezifikation, mit der zahlreiche parallele Algorithmen einfacher und problemnäher als je zuvor umgesetzt werden können, ohne Abstriche hinsichtlich der Effizienz in Kauf nehmen zu müssen. Natürlich gehen die Belange der Parallelverarbeitung über die Möglichkeiten von MPI hinaus. Deswegen führen wir hier eine kleine Zusammenstellung von Werkzeugen ein, die den entsprechenden Anwendern (computational scientists) dabei helfen können, die Leistung ihrer parallelen Programme zu messen, zu analysieren und somit zu verbessern. Zu diesen Werkzeugen gehören Routinen zur Steuerung, eine Bibliothek für die Erzeugung eines Ereignisprotokolls zur posi-moriem-Programmvisualisierung sowie eine einfache Echtzeit-Graphikbibliothek zur Laufzeit Visualisierung. Darüber hinaus enthält das Paket eine Reihe von Dienstprogrammen, die die Anwendbarkeit der MPI-Routinen selbst verbessern. Die Gesamtheit dieser Bibliotheken bezeichnen wir mit Multiprocessing Environment (MPE). Sämtliche Beispielprogramme und Werkzeuge sind frei erhält-
Vorwort zur ersten Auflage
XI
lieh, ebenso eine portable Modellimplementierung von MPI, die am Argonne National Laboratory und an der Mississippi State University entwickelt wurde [GLDS96]. Die Reihenfolge unserer Darstellungen orientiert sich an der Komplexität der Algorithmen, die wir besprechen. Das ist ein wesentlicher Unterschied zu formelleren Abhandlungen des MPI Standards. [Um die Leser nicht zu irritieren, wurde auf die Inhaltsangabe der ersten Auflage an dieser Stelle verzichtet.] Zusätzlich zu dem herkömmlichen Stichwortverzeichnis gibt es in diesem Buch einen Index zu den Definitionen und Anwendungsbeispielen der verwendeten MPI-Funktionen. Ein Glossar zu den im Buch verwendeten Fachausdrücken befindet sich vor den Anhängen. Bei der Verwendung von C und Fortran für die Beispiele versuchen wir neutral zu sein; viele Beispiele sind in beiden Sprachen angegeben. Der MPI-Standard bemüht sich darum, die Syntax der Aufrufe in C und Fortran ähnlich zu halten; Unterschiede bestehen meist nur bei der Groß- und Kleinschreibung (ausschließlich Großbuchstaben in Fortran, obwohl die meisten Compiler auch Kleinbuchstaben akzeptieren, wobei in C nur „MPI" und der folgende Buchstabe groß geschrieben werden) und im Umgang mit der return-Anweisung (das letzte Argument in Fortran und der Rückgabewert in C). Wenn es notwendig ist, den Namen einer MPI-Funktion zu nennen, ohne genauer anzugeben, ob in Fortran oder C, werden wir die C-Variante benutzen, jedoch nur, weil sich der laufende Text dann etwas leichter lesen lässt. Dieses Buch ist kein Referenzwerk, in dem MPI-Routinen entsprechend ihrer Funktionalität gruppiert und vollständig definiert wären. Stattdessen stellen wir die MPI-Routinen eher informell vor, im Kontext der jeweiligen Beispielprogramme. Genaue Definitionen sind in [Mes94b] aufgeführt. Für eine bessere Ubersicht geben wir aber zu jeder MPIRoutine, die wir beprechen, an, wie sie in Fortran und C aufgerufen wird. So wird die Verwendung des Buches für diejenigen erleichtert, die intensiver mit MPI arbeiten. Diese Auflistungen ziehen sich durch das ganze Buch hindurch, sind in Boxen dargestellt und jeweils in der Nähe der Texte mit den Einführungen zu den entsprechenden Routinen eingefügt. In den Boxen für C verwenden wir die Deklaration in ANSI C. Argumente, die verschiedenen Typs sein können (in der Regel die Nachrichtenpuffer), sind als void* deklariert. In den Fortran-Boxen sind die Typen solcher Argumente als eingetragen. Das heißt, dass ein passender Fortran-Datentyp dafür eingesetzt werden muss. Die entsprechende Box für eine bestimmte MPI-Routine ist über den dazugehörigen fett gedruckten Eintrag im Funktionsindex (f77 für Fortran, C für C) zu finden. Man kann auch im Anhang Α nachschlagen, der alle MPI-Funktionen in alphabetischer Reihenfolge auflistet.
Danksagungen Unser erster Dank gilt dem Message-Passing Interface Forum (MPIF), dessen Mitglieder MPI in eineinhalb Jahren mit größtem Einsatz entwickelten. Erst das Entstehen eines solchen Standards hat es uns ermöglicht, auf überzeugende Weise darzustellen, wie der
XII
Vorwort zur ersten Auflage
Prozess der Entwicklung von Anwendungsprogrammen und Bibliotheken für parallele Entwicklungsumgebungen gestaltet werden könnte. Das Ziel dieses Buches ist es, zu zeigen, dass dieser Entwicklungsprozess nun leichter, verständlicher und mit besseren Erfolgsaussichten durchlaufen werden kann als vor der Existenz von MPI. Das MPIF erstellt sowohl eine endgültige Beschreibung des Standards als auch eine kommentierte Referenz, um den Standard mit den Diskussionen zu untersetzen, die notwendig sind, um die volle Flexibilität und Mächtigkeit von MPI zu verstehen. Auf die Gefahr mehrfacher Danksagungen in den genannten Ausgaben hin, danken wir hier den folgenden Mitgliedern des MPIF, mit denen wir im MPI-Projekt zusammengearbeitet haben. Besondere Anstrengungen unternahmen die für vielfältige Aktivitäten Verantwortlichen: Lyndon Clarke, James Cownie, Jack Dongarra, Al Geist, Rolf Hempel, Steven Huss-Lederman, Bob Knighten, Richard Littlefield, Steve Otto, Mark Sears, Marc Snir und David Walker. Weitere Teilnehmer waren Ed Anderson, Joe Baron, Eric Barszcz, Scott Berryman, Rob Bjornson, Anne Elster, Jim Feeney, Vince Fernando, Sam Fineberg, Jon Flower, Daniel Frye, Ian Glendinning, Adam Greenberg, Robert Harrison, Leslie Hart, Tom Haupt, Don Heller, Tom Henderson, Alex Ho, C.T. Howard Ho, John Kapenga, Bob Leary, Arthur Maccabe, Peter Madams, Alan Mainwaring, Oliver McBryan, Phil McKinley, Charles Mosher, Dan Nessett, Peter Pacheco, Howard Palmer, Paul Pierce, Sanjay Ranka, Peter Rigsbee, Arch Robison, Erich Schikuta, Ambuj Singh, Alan Sussman, Robert Tomlinson, Robert G. Voigt, Dennis Weeks, Stephen Wheat und Steven Zenith. Während jeder der hier Genannten wichtige Beiträge beisteuerte, viele sogar sehr wesentlich mitarbeiteten, wäre MPI weit weniger bedeutend, wenn es nicht von der besonderen Energie und Intelligenz von James Cownie von Meiko, Paul Pierce von Intel und Marc Snir von IBM profitiert hätte. Unterstützung für die MPI-Treffen kam von ARPA und NSF mit der Beihilfe ASC931330, NSF Science and Technology Center Cooperative Agreement No. CCR-8809615 und der Kommission der Europäischen Gemeinschaft mit dem Esprit-Projekt P6643. Während die Organisatoren nach einer dauerhaften Finanzierung suchten, gewährte die Universität von Tennessee dem MPIF finanzielle Hilfe. Die Autoren danken besonders ihren Dienststellen, dem Argonne National Laboratory und der Mississippi State University, für die Zeit und die Mittel, um das Gebiet der Parallelverarbeitung zu erschließen und am Prozess der MPI-Entwicklung mitzuwirken. Die ersten beiden Autoren wurden durch das U.S. Department of Energy unter Vertrag W-31-109-Eng-38 unterstützt. Dem dritten Autor wurde zu einem Teil durch das NSF Engineering Research Center for Computational Field Simulation an der Mississippi State University Beihilfe gewährt. The MPI Language Specification ist urheberrechtlich geschützt durch die Universität von Tennessee und wird als eine Sonderausgabe des International Journal of Supercomputer Applications, herausgegeben von MIT Press, erscheinen. Beide Organisationen haben die Sprachdefinitionen als frei verfügbar zugänglich gemacht. Wir danken außerdem Nathan Doss von der Mississippi State University und Hubertus Franke von der IBM Corporation, die an dem frühen Implementierungsprojekt mitarbeiteten, welches es uns ermöglichte, alle Beispiele des Buches auszuführen. Wir danken
Vorwort zur ersten Auflage
XIII
Ed Karreis, einem Gaststudenten am Argonne, der den größten Teil der Arbeit an der MPE-Bibliothek und den Beispielen zum Profiling-Interface leistete. Er war auch allein verantwortlich für die neue Version des Programms upshot zur Untersuchung der Log-Dateien. Wir danken James Cownie von Meiko und Brian Grant von der Universität Washington für die Durchsicht des Manuskripts und viele hilfreiche Anregungen. Gail Pieper verbesserte den Schreibstil erheblich. Wir danken darüber hinaus denen, die es uns gestatteten, ihre Forschungsprojekte als Beispiele zu verwenden: Robert Harrison, Dave Levine und Steven Pieper. Schließlich möchten wir noch mehreren Absolventen der Mississippi State University danken, deren gemeinsame Untersuchungen zusammen mit uns zu vielen der großangelegten Beispiele im Buch beitrugen. Die Teilnehmer des Kurses Parallel Scientific Computing am Department of Computer Science an der MSU im Frühjahr 1994 halfen bei der Fehlerbeseitigung und Verbesserung der Modellimplementierung und erstellten einige Projekte, die als Beispiele ihren Weg in dieses Buch gefunden haben. Wir danken vor allem Purushotham V. Bangalore, Ramesh Pankajakshan, Kishore Viswanathan und John E. West für die Beispiele (aus Lehre und Forschung), die sie uns für die Verwendung in diesem Buch zukommen ließen.
Inhaltsverzeichnis 1
Motivation
1
1.1
Parallelverarbeitung - warum?
1
1.2
Hemmnisse
2
1.3 1.3.1
Warum Message-Passing? Parallele Berechnungsmodelle
3 3
1.3.2
Vorteile des Message-Passing-Modells
8
1.4
Entwicklung der Message-Passing-Systeme
9
1.5
Das MPI-Forum
10
2
Einführung in M P I
13
2.1
Ziel
13
2.2
Was ist MPI?
13
2.3
Grundlegende MPI-Konzepte
14
2.4
Weitere interessante Konzepte in MPI
18
2.5
Ist MPI umfangreich oder knapp?
20
2.6
Dem Entwickler verbleibende Entscheidungen
21
3
M P I in einfachen P r o g r a m m e n
23
3.1
Ein erstes MPI-Programm
23
3.2
Ausführung des ersten MPI-Programms
28
3.3
Ein erstes MPI-Programm in C
29
3.4
Ein erstes MPI-Programm in C + +
29
3.5
Zeitmessung in MPI-Programmen
33
3.6
Ein Beispiel für autonome Prozesskoordination
35
3.7 3.7.1 3.7.2 3.7.3 3.7.4
Untersuchung der parallelen Leistung Elementare Berechnungen zur Skalierbarkeit Aufzeichnung von Informationen über die Programmausführung MPE-Protokollierung in parallelen Programmen Ereignisse und Zustände
42 42 44 45 46
XVI
Inhaltsverzeichnis
3.7.5 3.7.6 3.7.7
Protokollierung im Programm zur Matrizenmultiplikation Bemerkungen zur Implementierung der Protokollierung Aufbereitung der Logdateien mit Upshot
46 48 51
3.8
Die Arbeit mit Kommunikatoren
52
3.9
Ein anderer Weg zur Bildung neuer Kommunikatoren
60
3.10
Eine praktische Graphikbibliothek für parallele Programme
61
3.11
Typische Fehler und Missverständnisse
63
3.12
Anwendung: Quanten-Monte-Carlo Berechnungen in der Kernphysik . . .
65
3.13
Zusammenfassung
66
4
M P I für Fortgeschrittene
67
4.1
Das Poisson-Problem
68
4.2
Topologien
71
4.3
Ein Programm für das Poisson-Problem
80
4.4
Anwendung nichtblockierender Kommunikationen
89
4.5
Synchrones Senden und „sichere" Programme
91
4.6
Mehr zur Skalierbarkeit
92
4.7
Jacobi-Verfahren mit 2D-Dekomposition
95
4.8
Ein erweiterter MPI-Datentyp
96
4.9
Überlagern von Kommunikation und Berechnung
99
4.10
Mehr zur Laufzeitmessung in Programmen
101
4.11
Drei Dimensionen
103
4.12
Typische Fehler und Missverständnisse
104
4.13
Simulation der Wirbelevolution in supraleitenden Materialien
105
5
W e i t e r g e h e n d e Details z u m M e s s a g e - P a s s i n g mit M P I
107
5.1 5.1.1 5.1.2 5.1.3
MPI-Datentypen Basisdatentypen und Konzepte Abgeleitete Datentypen Was genau ist die Länge?
107 107 112 113
5.2 5.2.1 5.2.2 5.2.3 5.2.4
Das N-Körper-Problem Aufsammeln von Informationen Nichtblockierende Pipeline Verschieben von Teilchen zwischen Prozessen Senden dynamisch erzeugter Daten
113 115 118 121 127
Inhaltsverzeichnis
XVII
5.2.5
Nutzergesteuertes Packen von Daten
129
5.3 5.3.1
Visualisierung der Mandelbrotmenge Senden von Feldern von Strukturen
131 139
5.4
Lücken in Datentypen
140
5.4.1
Funktionen in MPI-2 zur Manipulation der Länge eines Datentyps
141
5.5
Neue Datentyp-Routinen in MPI-2
143
5.6
Weiteres zu Datentypen für Strukturen
145
5.7
Veraltete Funktionen
148
5.8
Typische Fehler und Missverständnisse
149
6
Parallele Bibliotheken
151
6.1 6.1.1 6.1.2 6.1.3
Motivation Die Notwendigkeit paralleler Bibliotheken Bekannte Schwächen älterer Message-Passing-Systeme Überblick zu MPI-Eigenschaften für die Bibliotheksunterstützung
151 151 152 154
6.2 6.2.1 6.2.2
Eine erste MPI-Bibliothek Routinen für das Attribut-Caching in MPI-2 Eine Alternative in C++ zu MPI_Comm_dup
157 165 165
6.3 6.3.1 6.3.2 6.3.3
Lineare Algebra auf Gittern Abbildungen und Logische Gitter Vektoren und Matrizen Komponenten einer parallelen Bibliothek
170 171 176 178
6.4
Der LINPACK-Benchmark mit MPI
181
6.5
Strategien für die Erstellung von Bibliotheken
182
6.6
Beispiele für Bibliotheken
183
7
Weitere Eigenschaften von M P I
185
7.1 7.1.1 7.1.2 7.1.3
185 185 186
7.1.4 7.1.5
Simulation von Shared-Memory-Operationen Gemeinsamer und verteilter Speicher Ein Zähler-Beispiel Realisierung des gemeinsamen Zählers per Polling anstelle eines gesonderten Prozesses Fairness im Message-Passing Ausnutzung von Anfrage-Antwort-Mustern
7.2
Die vollständige Konfigurationswechselwirkung als Anwendungsbeispiel. 195
7.3 7.3.1 7.3.2 7.3.3
Erweiterte kollektive Operationen Bewegen von Daten Kollektive Berechnung Typische Fehler und Missverständnisse
190 191 192
196 196 196 202
XVIII
Inhaltsverzeichnis
7.4
Interkommunikatoren
203
7.5
Rechnen in einer heterogenen Umgebung
210
7.6 7.6.1 7.6.2 7.6.3
Die MPI-Schnittstelle zur Programmanalyse Entdecken von Pufferproblemen Erkennung ungleichmäßiger Lastverteilung Der Mechanismus zur Nutzung der Analyseschnittstelle
210 214 216 217
7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5
Fehlerbehandlung Routinen zur Fehlerbehandlung Ein Beispiel zur Fehlerbehandlung Anwenderdefinierte Fehlerbehandlungen Abbruch von MPI-Programmen Funktionen in MPI-2 zur Fehlerbehandlung
218 218 221 223 224 226
7.8 7.8.1 7.8.2
Die MPI-Umgebung Prozessorname Ist MPI initialisiert?
227 229 229
7.9
Ermittlung der Version von MPI
230
7.10
Weitere Funktionen in MPI
231
7.11 7.11.1 7.11.2
Eine Anwendung: Numerische Strömungsmechanik Parallelisierung Parallele Implementierung
232 233 235
8
W i e arbeiten M P I - I m p l e m e n t i e r u n g e n ?
239
8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.1.5 8.1.6 8.1.7
Einführung Senden von Daten Empfangen von Daten Rendezvous-Protokoll Zuordnung zwischen Protokollen und MPI-Sendemodi Auswirkungen auf die Leistung Alternative Strategien für MPI-Implementierungen Anpassung von MPI-Implementierungen
239 240 240 241 241 242 243 243
8.2
Wie schwierig ist es, MPI zu implementieren?
244
8.3
Zusammenspiel von Hardware-Eigenschaften und MPI-Bibliothek
244
8.4
Sicherheit der Datenübertragung
245
9
M P I i m Vergleich
247
9.1 9.1.1 9.1.2
Sockets Erzeugen und Beenden von Prozessen Behandlung von Fehlern
247 249 251
9.2 9.2.1
PVM 3 Grundlagen
252 253
Inhaltsverzeichnis
XIX
9.2.2 9.2.3 9.2.4 9.2.5 9.2.6 9.2.7
Weitere Funktionen Kollektive Operationen MPI-Gegenparts weiterer PVM-Funktionalitäten Funktionalitäten, die sich in MPI nicht wiederfinden Starten von Prozessen Zu MPI und PVM ähnliche Werkzeuge
254 254 255 256 256 257
9.3
Wo kann man noch mehr erfahren?
257
10
Ü b e r M e s s a g e - P a s s i n g hinaus
259
10.1
Dynamische Prozessverwaltung
260
10.2
Threads
261
10.3
Ausführung aus der Ferne
262
10.4
Parallele Ein- und Ausgabe
263
10.5
MPI-2
264
10.6
Wird es MPI-3 geben?
264
10.7
Schlusswort
264
Glossar
265
Α
Z u s a m m e n f a s s u n g der M P I - l - R o u t i n e n
275
A.l
C-Funktionen
275
A.2
Fortran-Routinen
289
A.3
C++-Methoden
307
Β
Die MPI-Implementierung M P I C H
321
B.l B.l.l B.1.2 B.l.3 B.l.4 B.l.5
Eigenschaften der Modellimplementierung Besonderheiten für den Anwender Portierbarkeit Effizienz Zusätzliche Programme MPICH in der Forschung
321 321 322 323 323 323
B.2
Installation und Ausführung der Modellimplementierung
326
B.3
Die Geschichte von MPICH
326
C
Die MPE-Bibliothek
329
C.l
Protokollierung mit MPE
329
C.2
MPE-Graphik
331
C.3
Hilfen in MPE
331
XX
Inhaltsverzeichnis
C.4
Das Upshot-Visualisierungssystem
332
D
MPI-Quellen im Internet
337
Ε
Sprachdetails
339
E.l E.l.l E.l.2 E.1.3
Felder in C und Fortran Spalten- und zeilenweise Anordnung Gitter versus Matrizen Höherdimensionale Felder
339 339 340 340
E.2
Aliasing
343
Literaturverzeichnis
345
Index
357
Index für Funktionen, Typen, Datenfelder
363
1
Motivation
In diesem Kapitel werfen wir einen Blick auf die Rahmenbedingungen, unter denen sich der MPI-Standard entwickelte, beginnend mit der derzeitigen Situation in der Parallelverarbeitung und der weiten Verbreitung des Message-Passing-Modells für parallele Berechnungen bis hin zu dem eigentlichen Prozess, durch den MPI entwickelt wurde.
1.1
Parallelverarbeitung - warum?
Leistungsstarke Rechner haben in den letzten Jahren eine neue, immer mehr akzeptierte Art und Weise, Wissenschaft zu betreiben, gefördert. Aus den zwei klassischen Zweigen „Theoretische Wissenschaft" und „Experimentelle Wissenschaft" entwickelte sich eine Berechnungswissenschafl (engl.: computational science). Computational scientists simulieren auf Höchstleistungsrechnern Phänomene, die zu komplex sind, um sie alleine durch theoretische Betrachtungen zuverlässig vorhersagen zu können, und die zu gefährlich oder zu aufwändig sind, um sie im Labor reproduzieren zu können. Die im Bereich der computational science errungenen Erfolge und erworbenen Erkenntnisse haben in den letzten zehn Jahren die Forderung nach Höchstleistungsrechnern stark anwachsen lassen. Während dieser Zeit entwickelten sich die Parallelrechner von Experimentalmaschinen, die in der Regel nur im Labor zu finden waren, zum alltäglichen Werkzeug der computational scientists, die zur Lösung ihrer Probleme das Beste an Rechenressourcen benötigen. Mehrere Faktoren haben diese Entwicklung stimuliert. Nicht nur, dass Lichtgeschwindigkeit und Effektivität der Wärmeabfuhr der Geschwindigkeit eines Einzelrechners physikalische Grenzen setzen. (Um einen größeren Wagen ziehen zu lassen, ist es leichter, mehr Ochsen einzuspannen als einen Riesenochsen zu züchten.) Auch steigen die Kosten zeitgemäßer Einprozessorrechner stärker als ihre Leistung. (Große Ochsen sind teuer.) Preis-Leistungsverhältnisse werden besonders interessant, wenn erforderliche Rechenressourcen vor Ort verfügbar sind und nicht gekauft werden müssen. Dieser Umstand hat zahlreiche Ansätze hervorgebracht, um vorhandene Rechnernetzwerke, die an sich für gewöhnliche Anwendungen installiert worden sind, als Parallelrechner für komplexere Aufgaben zu nutzen, also als SCAN (Supercomputers At Night). Aktuell bieten Cluster von PCs (personal computer) bzw. Workstations beträchtliche Rechenleistungen bei moderaten Kosten, was eine Folge der gestiegenen Leistung der PCs bei fallenden Preisen sowohl bei PCs als auch bei der Netzwerkhardware ist. Die größten dieser Cluster, gebaut aus handelsüblichen Komponenten ( C O T S - Commercial Off-The-Shelf), konkurrieren mit den Angeboten der traditionellen Supercomputer-Hersteller. Eine besondere Art dieses Zugangs, die offene Systemsoftware und dedizierte Netzwerke ver-
1 Motivation
2
wendet, firmiert unter dem Namen „Beowulf" [SSBS99]. Zudem hat es das Wachstum von Geschwindigkeit und Kapazität der Weitverkehrsnetze (WANs - Wide-Area Networks) ermöglicht, Anwendungen zu implementieren, die auf Rechenkapazitäten zugreifen, die über die ganze Welt verteilt sind. Viele Forscher nutzen das Konzept eines „Grid" [FK99a] von Rechenressourcen und Netzen, das in gewisser Weise dem Elektroenergienetz ähnelt. Somit drängen die Überlegungen sowohl zu Spitzenleistung als auch zum Preis-LeistungsVerhältnis den Bereich des Hochleistungsrechnens (engl.: large-scale computing) in Richtung Parallelität. Doch warum ist dann die Parallelverarbeitung nicht vorherrschend? Warum schreibt nicht jeder parallele Programme?
1.2
Hemmnisse
Barrieren bei der breiten Nutzung von Parallelität gibt es in allen der drei üblichen Bereiche des computergestützten Rechnens: Hardware, Algorithmen, Software. Bei der Hardware wird versucht, Verbindungsnetzwerke (auch Switches genannt) zu bauen, die mit der Geschwindigkeit schneller Einzelprozessoren Schritt halten. Obwohl nicht für jede Anforderung notwendig (viele erfolgreiche parallele Programme nutzen zur Kommunikation Ethernet-Verbindungen, einige sogar nur E-Mail), erfordern schnellere Rechner in der Regel auch schnellere Verbindungen, damit ein Großteil der Anwendungen Gewinne aus der höheren Rechenleistung erzielen kann. In den letzten Jahren wurden große Fortschritte auf diesem Gebiet erzielt. Heutige Superrechner weisen eine bessere Balance zwischen Rechen- und Kommunikationsleistung auf als je zuvor. Diese Balance wird nun auch in Workstation- und PC-Cluster eingebracht - dank der Weiterentwicklung von lokalen (wie auch Weitverkehrs-) Hochgeschwindigkeitsnetzwerken. Die Forschung im Bereich der Algorithmen hat ebenso viel zur Geschwindigkeit moderner paralleler Programme beigetragen wie die Forschung in der Hardwaretechnik. Parallelität in Algorithmen kann aus drei Quellen herrühren: aus der Physik (Unabhängigkeit von physikalischen Prozessen), aus der Mathematik (von einander unabhängige mathematische Operationen) sowie vom Ideenreichtum des Programmierers (Unabhängigkeit von Berechnungsteilaufgaben). Ein Flaschenhals entsteht jedoch, wenn diese drei verschiedenen Parallelitätsformen in einem realen Programm ausgedrückt werden müssen, um auf einem realen Parallelrechner ausgeführt werden zu können. Dadurch verlagert sich das Problem in die Software. Das größte Hemmnis bei der Verbreitung der Parallelverarbeitung und ihrer Vorteile in Wirtschaftlichkeit und Leistung ist das Problem unzureichender Software. Autoren paralleler Algorithmen zur Lösung wichtiger wissenschaftlicher Berechnungsaufgaben können den Eindruck gewinnen, dass die aktuellen Softwareentwicklungswerkzeuge die Nutzung der sehr leistungsfähigen und kosteneffektiven Hardware eher hemmen als unterstützen. Ein Teil dieser Erschwernisse besteht in dem, was es nicht gibt. Ubersetzer, die sequentielle Algorithmen automatisch parallelisieren, sind in ihrer Anwendbarkeit stark begrenzt. Obwohl viel Forschungsarbeit in parallelisierende Ubersetzer investiert wurde
1.3 Warum Message-Passing?
3
und sie für einige Programme gut arbeiten, wird die beste Leistung immer noch dann erreicht, wenn der Programmierer den parallelen Algorithmus selbst implementiert. Wenn Parallelität nicht automatisch von Ubersetzern erzielt werden kann, wie sieht es dann mit Bibliotheken aus? Hier gibt es Fortschritte, aber die Schwierigkeiten, Bibliotheken zu entwickeln, die in unterschiedlichen Umgebungen arbeiten, sind groß. Die Anforderungen an Bibliotheken und wie diese von MPI umgesetzt werden, sind Gegenstand von Kapitel 6. Andere Erschwernisse liegen in dem, was es gibt. Der ideale Mechanismus, einen parallelen Algorithmus auf einem Parallelrechner zu implementieren, sollte ausdrucksstark, effizient und portabel sein. Die derzeitigen Mechanismen repräsentieren Kompromisse zwischen diesen drei Zielen. Einige kommerzielle Bibliotheken sind effizient, aber nicht portabel, und in den meisten Fällen minimal in der Ausdrucksstärke. Hochsprachen ziehen die Portabilität der Effizienz vor. Und Programmierer sind nie zufrieden mit der Ausdruckskraft ihrer Programmiersprache. (Turing-Vollständigkeit ist notwendig, aber nicht hinreichend.) MPI ist selbstverständlich auch ein Kompromiss, aber der Entwurf von MPI erfolgte im klaren Bewusstsein über diese Ziele im Kontext der nächsten Generation paralleler Systeme. MPI ist portabel und wurde so gestaltet, dass keine semantischen Beschränkungen hinsichtlich der Effizienz zugelassen wurden. Das heißt, das Design von MPI (im Gegensatz zu einzelnen MPI-Implementierungen) erzwingt keinerlei Verlust an Effizienz. Darüber hinaus hat die starke Einbeziehung von Herstellern in die Entwicklung von MPI gesichert, dass kommerzielle MPI-Implementierungen effizient sein können. Bezüglich der Ausdruckskraft wurde MPI als eine komfortable, vollständige Definition des Message-Passing-Modells entwickelt, das wir im nächsten Abschnitt vorstellen.
1.3
Warum Message-Passing?
Um unsere Diskussion des Message-Passing-Modells ins rechte Licht zu rücken, wollen wir kurz die grundlegenden parallelen Berechnungsmodelle behandeln. Danach werden wir uns auf die Vorteile des Message-Passing-Modells konzentrieren.
1.3.1
Parallele Berechnungsmodelle
Ein Berechnungsmodell ist eine konzeptionelle Sicht auf die verfügbaren Arten von Operationen eines Programms. Es enthält keine spezifische Syntax einer bestimmten Programmiersprache oder Bibliothek und ist (fast) unabhängig von der Hardwareplattform, die es unterstützt. Somit kann jedes der Modelle, die wir vorstellen, mit nur wenig Unterstützung des Betriebssystems auf jedem modernen Parallelrechner implementiert werden. Die Effektivität einer solchen Implementierung hängt jedoch von der Diskrepanz zwischen Modell und Maschine ab. Parallele Berechnungsmodelle bilden eine komplizierte Struktur. Sie können anhand mehrerer Kriterien unterschieden werden: ob es sich um physikalisch gemeinsamen oder verteilten Speicher handelt, wie hoch der Anteil der Kommunikation in Hardware oder Software ist, wie die Ausführungseinheit wirklich aussieht usw. Dieses Bild ist verwir-
4
1 Motivation
rend, weil die Software eine Implementierung eines jeden Berechnungsmodells auf jeder Hardware ermöglicht. So gibt dieser Abschnitt keine Taxonomie; vielmehr möchten wir unsere Begriffe so definieren, dass unsere Diskussion zum Message-Passing-Modell, welches der Fokus von MPI ist, klar abgesteckt werden kann. Datenparallelität. Parallelität ist an vielen Stellen und auf vielen Ebenen in einem modernen Parallelrechner möglich. Sie wurde für den Programmierer zuerst vor allem mit Vektorprozessoren zugänglich. So stand die Vektormaschine am Beginn des Supercomputing. Die Idee bei Vektorrechnern, auf einem Feld gleichartiger Daten parallel die gleiche Operation auszuführen, wurde um die Möglichkeit der Arbeit ganzer Programme auf Mengen von Datenstrukturen erweitert, so in SIMD-Maschinen (Single Instruction, Multiple Data-Maschinen) wie der ICL DAP und der Thinking Machines CM-2. Die Parallelität muss nicht notwendigerweise in der schrittweisen Ausführung von Instruktionen bestehen, um als datenparallel klassifiziert zu werden. Datenparallelität ist heute eher ein Programmierstil als eine Rechnerarchitektur. Die CM-2 gibt es nicht mehr. Unabhängig von der Ebene bleibt das Modell dasselbe: die Parallelität rührt vollständig von den Daten her; das Programm selbst sieht einem sequentiellen Programm sehr ähnlich. Die dem Modell zugrunde liegende Aufteilung der Daten kann von einem Ubersetzer erbracht werden; der High Performance Fortran (HPF) Standard [KLS+93] spezifiziert Erweiterungen für Fortran, um dem Ubersetzer die Datenaufteilung zu gestatten. Ubersetzer-Direktiven, wie ζ. B. die durch OpenMP [ope97, ope98] definierten, erlauben es dem Programmierer, Hinweise auf Datenparallelität in sequentiell kodierten Schleifen an den Ubersetzer weiter zu geben. Gemeinsamer Speicher (shared memory). Parallelität, die nicht implizitvon Datenunabhängigkeit bestimmt ist, sondern explizit vom Programmierer spezifiziert wird, ist Programmparallelität (oder Steuerflussparallelität). Ein einfaches Modell für Programmparallelität ist das Modell des gemeinsamen Speichers (shared-memory model), in dem jeder Prozessor den üblichen Lese- und Schreibzugriff auf den gesamten gemeinsamen Adressraum hat. Abbildung 1.1 zeigt eine einfache Darstellung dieses Modells. Der Zugriff von mehreren Prozessen auf Speicherplätze wird durch bestimmte Sperr-Mechanismen koordiniert, wobei Hochsprachen den expliziten Einsatz dieser so genannten Locks verbergen können. Frühe Beispiele für dieses Modell waren die Shared-Memory-Multiprozessorrechner wie Denelcor HEP und Rechner von Alliant, Sequent und Encore. Die parallelen Vektorrechner von Cray oder die Serie der SGI Power Challenge gehören ebenso dazu. Heute findet man viele small-scale shared-memoryMaschinen vor, die auch als „symmetrische Multiprozessoren" (SMPs) bezeichnet werden. Es ist schwierig und aufwändig, Rechner mit mehr als etwa 20 bis 30 Prozessoren als wirkliche shared-memory-Maschinen zu bauen. Um das Modell des gemeinsamen Speichers mit einer großen Prozessoranzahl zu ermöglichen, muss erlaubt werden, dass einige Speicherzugriffe länger dauern als andere. Die BBN-Familie (GP-1000, TC-2000) realisierte das shared-memory-Modell auf Hardware-Architekturen, die einen Speicherzugriff mit nonuniform memory access (NUMA) zur Verfügung stellten. Darauf folgte die Reihe der Rechner von Kendall Square. Die aktuellen Vertreter dieses Zugangs sind die
1.3 Warum Message-Passing?
Abb.
1.1: Das
5
Shared-Memory-Modell
Origin-Rechner von SGI und die Hewlett Packard Maschinen. Eine Diskussion zu einer anderen Möglichkeit, nach dieser Methode gemeinsamen Speicher zu erreichen, findet man in [HLH92], Eine Variation des Modells eines gemeinsamen Speichers ist gegeben, wenn Prozesse sowohl über lokalen Speicher verfügen (nur für einen Prozess zugreifbar) als auch einen Teil des Speichers gemeinsam mit anderen Prozessen teilen (zugreifbar von einigen oder allen Prozessen). Das Linda-Programmiermodell [CG89] ist von diesem Typ. Message Passing. Das Message-Passing-Modell postuliert eine Menge von Prozessen, die nur Zugriff auf lokalen Speicher haben, aber mit anderen Prozessen durch Senden bzw. Empfangen von Nachrichten kommunizieren können. Es ist ein wesentliches Merkmal des Message-Passing-Modells, dass die Datenübertragung vom lokalen Speicher eines Prozesses zum lokalen Speicher eines anderen Prozesses Operationen erfordert, die von beiden Prozessen auszuführen sind. Da MPI eine spezifische Umsetzung des Message-Passing-Modells ist, werden wir den Nachrichtenaustausch weiter unten genauer erläutern. In Abbildung 1.2 zeigen wir kein spezielles Verbindungsnetzwerk, da es nicht Teil des Berechnungsmodells ist. Die Rechner Intel IPSC/860 hatten eine Hypercube-Topologie, Intel Paragon (und die größere Variante ASCI TFLOPS) sind über Gitter verbunden. Maschinen wie Meiko CS-2, Thinking Machines CM-5 oder IBM SP-1 hatten als Verbindungsnetzwerk verschiedene Formen von mehrstufigen Schaltnetzen. Es war ein langer Weg, bis die präzise Verbindungstopologie für den Programmierer irrelevant wurde. Nun sind Message-Passing-Modelle (repräsentiert durch MPI) auf zahlreichen verschiedenartigen Rechnerarchitekturen implementiert. Remote-Memory-Operationen. Zwischen dem Modell mit gemeinsamem Speicher, in dem die Prozesse auf Speicher zugreifen, ohne Kenntnis darüber zu haben, ob sie dabei entfernte Kommunikationen auf Hardwareebene auslösen, und dem Message-PassingModell, in dem sowohl lokale als auch entfernte Prozesse beteiligt sind, wird das Modell
1 Motivation
6
• Abb.
1.2: Das
Adressraum
Ο
Prozess
Message-Passing-Modell
der Remote-Memory-Operationen eingeordnet. Dieses Modell ist durch put- und getOperationen gekennzeichnet, wie es ζ. B. bei der Cray T3E der Fall ist. Hier kann ein Prozess auf den Speicher eines anderen ohne dessen Beteiligung zugreifen. Dies erfolgt jedoch explizit, auf andere Weise als ein Zugriff auf den eigenen lokalen Speicher. Ein verwandter Operationstyp ist die „aktive Nachricht" [vECGS92], die die Ausführung einer (in der Regel kurzen) Routine im Adressraum des anderen Prozesses auslöst. Aktive Nachrichten werden oft genutzt, um im entfernten Speicher zu kopieren, was als Teil dieses Modells angesehen werden kann. Diese Kopieroperationen im entfernten Speicher entsprechen gerade dem „einseitigen" Senden und Empfangen, die im MessagePassing-Modell nicht unterstützt werden. Die erste kommerzielle Maschine, die dieses Modell bekannt machte, war die TMC CM-5, die aktive Nachrichten sowohl direkt als auch zur Implementierung für die TMC Message-Passing-Bibliothek nutzte. Die Remote-Memory-Operationen in MPI-Notation sind Teil des MPI-2 Standards und werden, zusammen mit den anderen Teilen von MPI-2, in [GHLL+98] und [GLT99] beschrieben. Hardwareunterstützung für einseitige Operationen, auch für „handelsübliche" Netzwerke, wird dadurch zur Verfügung gestellt. Neben proprietären Schnittstellen wie LAPI von IBM [SNM + 98] entstehen Industriestandards wie z.B. Virtual Interface Architecture (VIA) [VIA], die die Möglichkeit einer guten Unterstützung der Operationen für den Zugriff auf entfernten Speicher bieten, vor allem auch für kostengünstige Parallelrechner. Threads. Frühe Formen des Modells des gemeinsamen Speichers sahen Prozesse mit separatem Adressraum vor, die gemeinsamen Speicher mittels expliziter Speicheropera-
1.3 Warum Message-Passing?
7
tionen, wie spezieller Arten der malloc-Operation in C, bilden konnten. Die gebräuchlichere Version des Shared-Memory-Modells spezifiziert heute, dass der gesamte Speicher gemeinsam genutzt wird. Das erlaubt die Anwendung des Modells auf MultithreadSysteme, in denen einem Einzelprozess (Adressraum) mehrere Programmzähler und Laufzeitstacks zugeordnet sind. Da das Modell ein schnelles Umschalten zwischen einzelnen Threads gestattet, ohne explizite Speicheroperationen ausführen zu müssen, kann es portabel in Fortran-Programmen genutzt werden. Das Problem des Thread-Modells hierbei ist, dass jeder durch die Werte der Programmvariablen definierte „Zustand" des Programms gleichzeitig durch alle Threads benutzt wird, obwohl es in den meisten Thread-Systemen möglich ist, Speicher lokal durch Threads zu belegen. Das am weitesten verbreitete Thread-Modell wird durch den POSIX Standard [Ins93] spezifiziert. Einen Zugang zur Thread-Programmierung auf höherem Niveau bietet auch OpenMP [ope97, ope98]. K o m b i n i e r t e M o d e l l e . Ebenfalls möglich sind Kombinationen der oben erläuterten Modelle, in denen eine Prozessgruppe Speicher untereinander teilt und mit anderen Gruppen via Message-Passing kommuniziert (Abbildung 1.3) oder in denen Einzelprozesse mit mehreren Threads (multithreaded) arbeiten (die einzelnen Threads greifen auf gemeinsamen Speicher zu), aber untereinander keinen gemeinsamen Speicher nutzen. Dieses Modell ist noch nicht weit verbreitet, erhält aber starken Aufschwung aus zwei Richtungen: • Mit sinkenden Preisen für Prozessoren wird es möglich, Prozessoren zu Knoten von vorhandenen Rechnern mit verteiltem Speicher hinzuzufügen. Das bewirkt, dass sogar Systeme vom Beowulf-Typ wie herkömmliche PCs zu kleinen symmetrischen Multiprozessoren mutieren. • Workstation-Hersteller wie Sun, DEC oder SGI bieten inzwischen auf Basis ihrer Standardprodukte Multiprozessorrechner mit gemeinsamem Speicher an. Diese Maschinen repräsentieren im Verbund mit einem Hochgeschwindigkeitsnetzwerk erste Plattformen für kombinierte Modelle. Die drei leistungsfähigsten Parallelrechner der Welt im Jahr 1999 arbeiteten auf der Hardware-Ebene mit einem kombinierten Modell, obwohl sie in der Regel meist mit MPI programmiert wurden. • Der ASCI Red (Sandia) hat zwei Prozessoren je Knoten (Speichersystem). Zwar ist der zweite Prozessor primär für die Kommunikation vorgesehen, aber einige Anwendungen sind so kodiert, dass er auch für Berechnungen genutzt wird. • Der ASCI Blue Mountain (Los Alamos) besteht (während der Entstehung dieses Buches) aus vielen SGI Origin Rechnern mit jeweils 128 Knoten, die über ein HiPPI-Netzwerk verbunden sind. Jede Origin ist ein NUMA-Rechner mit gemeinsamem Speicher. Die Strategie von SGI geht offenbar zu etwas kleineren sharedmemory-Maschinen bei einer größeren Anzahl von Maschinen im Netzwerk.
8
1 Motivation
όδόδ 6öc
6"
öööö Abb. 1.3: Das
Cluster-Modell
• Die ASCI Blue Pacific Maschine (Livermore) ist eine I B M SP mit Knoten aus VierProzessor-SMPs. Die Perspektive dieser Maschine besteht in der Vergrößerung der Prozessoranzahl in den Knoten. MPI-Implementierungen können von solcher Hybrid-Hardware profitieren, indem sie den gemeinsamen Speicher ausnutzen, um die Message-Passing-Operationen zwischen den Prozessen zu beschleunigen, die auf den gemeinsamen Speicher zugreifen. Die kombinierten Modelle führen zu komplexer Software, in der der Ansatz für gemeinsamen Speicher (wie OpenMP) mit dem des Message-Passing (wie M P I ) verknüpft wird. Ob die Leistungsgewinne die Kosten dieser Komplexität aufwiegen werden, wird gegenwärtig untersucht. Die von uns gegebene Beschreibung von Modellen zur Parallelverarbeitung ist auf die Sicht des Programmierers fokussiert. Die zugehörige Hardware, die diese und zukünftige Modelle unterstützen soll, ist in Entwicklung. Eine Richtung, die erforscht wird, ist das Multithreading auf der Hardware-Ebene, wie es durch die Tera MTA (von der Architektur her ein Nachfolger des Denelcor H E P ) und die Architektur der H T M T (Hybrid Technology Multithreaded) [HTM] in einem aktuellen Forschungsprojekt exemplarisch umgesetzt wird.
1.3.2
Vorteile des Message-Passing-Modells
In diesem Buch legen wir den Schwerpunkt auf das Message-Passing-Modell der Parallelverarbeitung und dabei speziell auf die Realisierung dieses Modells durch MPI. Wir behaupten nicht, dass das Message-Passing-Modell den anderen Modellen in jeder Hinsicht überlegen ist, aber wir können erklären, warum es weit verbreitet ist und warum wir annehmen, dass dies lange so bleiben wird. U n i v e r s a l i t ä t . Das Message-Passing-Modell ist sehr gut geeignet für separate Prozessoren, die über ein (langsames oder schnelles) Netzwerk verbunden sind. So passt es zur
1.4 Entwicklung der Message-Passing-Systeme
9
Hardware der meisten heutigen parallelen Supercomputer genauso wie zu den konkurrierenden Workstation-Netzwerken und dedizierten PC-Clustern. Bietet die Maschine spezielle Hardware zur Unterstützung des shared-memory-Modells, kann das MessagePassing-Modell diese Hardware ausnutzen, um den Datenaustausch zu beschleunigen. Ausdrucksstärke. Message-Passing hat sich als nützliches und vollständiges Modell erwiesen, parallele Algorithmen zu formulieren. Es erlaubt mittels Datenlokalität eine direkte Kontrolle, die bei Datenparallelitäts- und Ubersetzer basierten Modellen fehlt. Manche finden seine anthropomorphe Ausprägung sehr hilfreich zur Formulierung paralleler Algorithmen. Message-Passing ist sehr gut geeignet für adaptive, selbst planende Algorithmen sowie für Programme, die Schwankungen in den Prozessgeschwindigkeiten, wie sie in gemeinsam genutzten Netzwerken typisch sind, tolerieren können. Einfache Fehlersuche. Das Austesten von parallelen Programmen bleibt ein herausforderndes Forschungsfeld. Während Debugger für parallele Programme im sharedmemory-Modell vielleicht leichter zu schreiben sind, ist wohl das Debugging selbst im Message-Passing-Paradigma einfacher. Die Ursache liegt darin, dass einer der häufigsten Fehlerfälle das versehentliche Uberschreiben von Speicherzellen ist. Das MessagePassing-Modell macht es leichter, fehlerhaftes Lesen und Schreiben im Speicher zu entdecken, da es mit Speicheradressen expliziter als jedes andere Modell arbeitet (nur ein Prozess hat direkten Zugriff auf eine bestimmte Adresse). Einige Parallel-Debugger können sogar Nachrichtenfolgen ausgeben, die für den Programmierer sonst nicht sichtbar sind. Leistungsfähigkeit. Der zwingendste Grund, warum das Message-Passing ein dauerhafter Teil im Umfeld der Parallelverarbeitung bleiben wird, ist seine Leistungsfähigkeit. So wie moderne CPUs schneller wurden, als so bedeutsam erweist sich die Verwaltung ihrer Caches und Speicherhierarchie als grundlegend, um die hohe Leistung dieser Maschinen auch nutzbar zu machen. Mit Message-Passing ist es für den Programmierer möglich, bestimmte Daten Prozessen explizit zuzuordnen, wodurch Ubersetzer und Hardware zur Cache-Verwaltung ohne Einschränkung arbeiten können. Rechner mit verteiltem Speicher haben in der Tat einen wichtigen Vorteil gegenüber den sogar größten Einprozessorrechnern: sie verfügen typischerweise über mehr Speicher und mehr Cache. Speicherintensive Anwendungen können, portiert auf solche Maschinen, superlinearen Speedup aufweisen. Die Arbeit mit dem Message-Passing-Modell kann sogar die Leistung auf shared-memory-Rechnern erhöhen, da der Programmierer größeren Einfluss auf die Datenlokalität in der Speicherhierarchie besitzt. Diese Analyse erklärt, warum sich das Message-Passing als eines der am meisten genutzten Paradigmen zum Darstellen paralleler Algorithmen herausgebildet hat. Obwohl es auch Schwächen gibt, erweist sich das Message-Passing überzeugender als jedes andere Paradigma als maßgeblicher Ansatz zur Implementierung paralleler Anwendungen.
1.4
Entwicklung der Message-Passing-Systeme
Message-Passing wurde jedoch erst vor kurzem zu einem Standard für Portabilität, was sowohl die Syntax als auch die Semantik angeht. Vor MPI gab es zahlreiche konkurrierende Variationen zum Thema Message-Passing, und Programme konnten nur schwer
10
1 Motivation
von einem System auf ein anderes portiert werden. Verschiedene Faktoren trugen zu dieser Situation bei. Hersteller von Parallelrechnersystemen, noch eng verbunden mit sequentiellen Standardsprachen, boten verschiedene, proprietäre Message-Passing-Bibliotheken an. Es gab zwei (gute) Gründe für diese Situation: • Es zeichnete sich kein Standard ab, und - bis zu MPI - wurden keine ernst zu nehmenden Anstrengungen unternommen, einen solchen Standard zu erarbeiten. Diese Situation spiegelte den Zustand wider, dass die Parallelverarbeitung eine neue Wissenschaft ist und experimentelle Untersuchungen erforderlich waren, um die nützlichsten Konzepte zu ermitteln. • Wegen des fehlenden Standards betrachteten die Hersteller die Güte ihrer proprietären Bibliotheken völlig zu Recht als einen Wettbewerbsvorteil und konzentrierten sich darauf, ihre Produkte einzigartig (also nicht portierbar) zu gestalten. Um das Portabilitätsproblem zu bewältigen, steuerte die Forschergemeinschaft eine Reihe von Bibliotheken zu der Sammlung von Alternativen bei. Die bekanntesten unter diesen sind PICL [GHPW90a], PVM [BDG+91], PARMACS [BRH90], p4 [BBD+87, BL92, BL94], Chameleon [GS93a], Zipcode [SSS+94] und TCGMSG [Har91]. Diese Bibliotheken waren kostenlos erhältlich, und einige von ihnen werden heute immer noch benutzt. Viele andere experimentelle Systeme mit unterschiedlichen Graden an Portabilität wurden an Universitäten entwickelt. Überdies entstanden kommerzielle portable Message-Passing-Bibliotheken, wie z.B. Express [Cor88], mit erheblich erweiterter Funktionalität. Diese portablen Bibliotheken standen aus Anwendersicht in Konkurrenz zueinander, so dass einige Anwender sich veranlasst fühlten, ihre eigenen metaportablen Bibliotheken zu schreiben, um die Differenzen zwischen diesen zu verbergen. Je portabler der erstellte Code wurde, desto geringer wurde leider die vom Code nutzbare Funktionalität der Bibliotheken, denn es bedurfte des kleinsten gemeinsamen Nenners der zugrunde liegenden Systeme. So musste man sich auf eine unzureichende Semantik beschränken, um portable Syntax zu erreichen, und viele Vorteile in der Leistung der nicht portablen Systeme gingen verloren. Sockets, sowohl die Variante von Berkeley (UNIX) als auch die Winsock-Variante (Microsoft), verfügen ebenfalls über eine portable Message-Passing-Schnittstelle, allerdings mit minimaler Funktionalität. Wir analysieren den Unterschied zwischen der SocketSchnittstelle und MPI im Kapitel 9.
1.5
Das MPI-Forum
Die Fülle an Lösungen, die dem Nutzer sowohl durch kommerzielle Softwareentwickler als auch durch Forscher - bestrebt, ihre fortschrittlichen Ideen weiterzugeben - angeboten wurde, forderte vom Nutzer eine unliebsame Wahl zwischen Portabilität, Leistung und Funktionalität. Die Anwendergemeinschaft, zu der auf jeden Fall die Softwareanbieter selbst gehören, beschloss, dieses Problem anzugehen. Im April 1992 förderte das Center for Research in
1.5 Das MPI-Forum
11
Parallel Computation einen eintägigen Workshop zu Standards für das Message-Passing in Umgebungen mit verteiltem Speicher [Wal92]. Dieser Workshop, auf dem zahlreiche Systeme vorgestellt wurden, offenbarte sowohl die großen Unterschiede zwischen den guten Ideen der existierenden Message-Passing-Systeme also auch den Willen der Beteiligten, für die Definition eines Standards zusammenzuarbeiten. Zur Supercomputing '92 im November wurde ein Komitee zur Definition eines Message-Passing-Standards gebildet. Zum Gründungszeitpunkt wussten wenige wie das Ergebnis aussehen könnte, aber der Versuch wurde mit den folgenden Zielen begonnen: • Definition eines portablen Standards für das Message-Passing, zwar nicht als offizieller ANSI-Standard, aber akzeptabel sowohl für Entwickler als auch für Anwender; • völlig offenes Vorgehen, das jedem den Zugang zur Diskussion erlaubt, entweder durch Besuch der Meetings oder durch Beteiligung an den elektronischen Foren; • Fertigstellung nach einem Jahr. Die Bemühungen um MPI erwiesen sich durch die Spannungen zwischen diesen drei Zielen als sehr lebendig. Das MPI-Forum entschied sich, der Form des Forums für High Performance Fortran zu folgen, die von dessen Gemeinschaft gut aufgenommen wurde. (Es beschloss sogar, sich im selben Hotel in Dallas zu treffen.) Das Vorhaben zum MPI-Standard war bei der Gewinnung einer großen Gruppe von Herstellern und Anwendern erfolgreich, da es selbst auf breiter Basis angelegt war. Die Hersteller von Parallelrechnern wurden repräsentiert von Convex, Cray, IBM, Intel, Meiko, nCUBE, NEC und Thinking Machines. Mitglieder von Gruppen, die für portable Software-Bibliotheken standen, waren ebenfalls beteiligt: PVM, p4, Zipcode Chameleon, PARMACS, TCGMSG und Express waren alle vertreten. Mehrere Spezialisten für parallele Anwendungen waren ebenfalls beteiligt. Zusätzlich zu den Treffen, die ein Jahr lang im Abstand von sechs Wochen stattfanden, gab es kontinuierlich Diskussionen per E-Mail, an denen sich viele Experten zur Parallelverarbeitung weltweit beteiligten. Gleichsam wichtig war, dass eine Ubereinkunft, eine Modellimplementierung [GL92b] zu entwickeln, zeigen half, dass eine Implementierung von MPI machbar war. Der MPI-Standard [Mes94b] wurde im Mai 1994 fertig gestellt. Dieses Buch ist ein Begleiter dieses Standards, indem es zeigt, wie MPI angewendet wird und wie dessen hochentwickelte Eigenschaften und Fähigkeiten in vielen Situationen verwertet werden können. Während der Veranstaltungen des MPI-Forums 1993-1995 wurden mehrere Fragen zurückgestellt, um zeitig Konsens zu einem Kern der Message-Passing-Funktionalität zu erzielen. Das Forum kam in den Jahren 1995 bis 1997 wieder zusammen, um MPI durch die Aufnahme der Remote-Memory-Operationen, parallele Ein- und Ausgabe, dynamisches Prozessmanagement und eine Reihe von Möglichkeiten zur Verbesserung von Komfort und Robustheit zu erweitern. Obgleich einige der Ergebnisse hierzu in diesem Buch beschrieben sind, sei für eine formale Behandlung auf [GHLL+98] sowie für eine eher lehrmäßige Darstellung auf [GLT99] verwiesen.
2
Einführung in MPI
In diesem Kapitel führen wir die grundlegenden Konzepte von MPI ein und zeigen, wie sie folgerichtig aus dem Message-Passing-Modell entstehen.
2.1
Ziel
Das Hauptziel der MPI-Spezifikation ist es zu zeigen, dass Anwender keine Kompromisse zwischen Effizienz, Portabilität und Funktionalität eingehen müssen. Vor allem können die Anwender portable Programme schreiben, die sogar noch von der speziellen Hardund Software einiger Hersteller profitieren können. Gleichzeitig kann man davon ausgehen, dass fortgeschrittene Konzepte, wie anwendungsorientierte Prozessstrukturen und dynamisch verwaltete Prozessgruppen mit entsprechenden Operationen, in jeder MPIImplementierung zu finden sind, sodass sie in jedem parallelen Anwendungsprogramm, in dem sie sinnvoll sind, genutzt werden können. Eine der anspruchsvollsten Anwendergruppen ist die der Entwickler paralleler Bibliotheken, für die effizienter, portabler und hoch funktioneller Code von außerordentlicher Bedeutung ist. MPI ist die erste Spezifikation, die dem Anwender die Entwicklung wirklich portabler Bibliotheken gestattet. Das Ziel von MPI ist sehr anspruchsvoll. Da aber die gemeinsamen Bemühungen um einen einheitlichen Entwurf und eine konkurrenzfähige Implementierung erfolgreich waren, erübrigt sich die Suche nach Alternativen zu MPI als Mittel zur Spezifikation von Message-Passing-Programmen, die auf jeder nach dem Message-Passing-Modell arbeitenden Rechnerplattform ausgeführt werden können. Dieses dreifache Ziel - Portabilität, Effizienz, Funktionalität - hat viele der Designentscheidungen erzwungen, die die MPI-Spezifikation ausmachen. Deswegen beschreiben wir in den folgenden Abschnitten, wie diese Entscheidungen sowohl die grundlegenden Operationen send und receive des Message-Passing-Modells als auch die in MPI enthaltenen erweiterten Message-Passing-Operationen beeinflusst haben.
2.2
Was ist MPI?
MPI ist kein revolutionär neuer Weg zur Programmierung von Parallelrechnern. Es ist eher ein Versuch, die besten Eigenschaften vieler über Jahre entwickelter MessagePassing-Systeme zu vereinen und diese, falls erforderlich, zu verbessern sowie sie zu standardisieren. Wir beginnen mit einer Zusammenfassung der Grundlagen von MPI. • MPI ist eine Bibliothek und keine Sprache. Sie spezifiziert die Namen, Parameterlisten und Rückgabewerte von Unterprogrammen, die von Fortran-Programmen
14
2 Einführung in MPI aufgerufen werden können, die Funktionen, die aus C-Programmen aufrufbar sind, sowie die Klassen und Methoden, aus denen die MPI-C++-Bibliothek besteht. Die vom Anwender in Fortran, C oder C++ geschriebenen Programme werden mit gewöhnlichen Compilern übersetzt und mit der MPI-Bibliothek gebunden. • MPI ist eine Spezifikation, keine spezielle Implementierung. In diesem Sinne bieten einerseits alle Hersteller von Parallelsystemen MPI-Implementierungen für ihre Maschinen an, andererseits sind freie Implementierungen über das Internet zugänglich. Ein korrektes MPI-Programm sollte mit allen MPI-Implementierungen ohne Änderungen lauffähig sein. • MPI bedient das Message-Passing-Modell. Obwohl es weit mehr als ein minimales System ist, geht die Funktionalität nicht über das in Kapitel 1 beschriebene grundlegende Berechnungsmodell hinaus. Eine Berechnung bleibt eine Menge von Prozessen, die über Nachrichten kommunizieren.
Die Struktur von MPI erlaubt es recht leicht, vorhandenen Code zu portieren sowie neuen zu schreiben, ohne eine Reihe neuer grundlegender Konzepte zu erlernen. Dennoch führten die Bestrebungen, Unzulänglichkeiten existierender Systeme zu beseitigen, gerade bei den Basisoperationen zu geringen Unterschieden. Wir erklären diese Unterschiede im nächsten Abschnitt.
2.3
Grundlegende MPI-Konzepte
Vielleicht ist es der beste Weg zur Einführung der MPI-Basiskonzepte, zuerst eine minimale Message-Passing-Schnittstelle vom Message-Passing-Modell selbst abzuleiten und dann zu beschreiben, wie MPI solch ein minimales Modell erweitert, um die Nutzung für Anwendungsprogrammierer und Bibliotheksentwickler zu verbessern. Im Message-Passing-Modell der Parallelverarbeitung verfügen die parallel laufenden Prozesse über separate Adressräume. Kommunikation ist dann erforderlich, wenn ein Teil des Adressraumes eines Prozesses in den Adressraum eines anderen Prozesses zu kopieren ist. Diese Operation ist zweiseitig und gelingt nur, wenn der erste Prozess die Operation send und der zweite Prozess die Operation receive ausführt. Welche Argumente für die Sende- und Empfangsfunktion werden unbedingt gebraucht? Aus Sicht des Senders müssen mindestens die zu übermittelnden Daten und der Zielprozess, zu dem die Daten zu senden sind, angegeben werden. Die einfachste Art, diese Daten zu beschreiben, ist die Angabe einer Startadresse und einer Länge (in Bytes). Prinzipiell könnte jeder Datentyp genutzt werden, um den Empfänger zu beschreiben; üblicherweise verwendet man „integer". Auf Empfängerseite müssen wenigstens die Adresse und die Größe des lokalen Speicherbereiches, in den die übermittelten Daten abgelegt werden sollen, angegeben werden. Zudem wird eine Variable zur Aufnahme der Identität des Senders benötigt, sodass der Empfängerprozess weiß, von welchem Prozess die Nachricht stammt. Obwohl eine Implementation dieser minimalen Schnittstelle für manche Anwendungen ausreichen könnte, wird in der Regel doch mehr an Funktionalität benötigt. Ein
15
2.3 Grundlegende MPI-Konzepte
Schlüsselbegriff ist das Matching: ein Prozess muss prüfen können, welche Art von Nachricht er empfängt. Dies erfolgt über eine weitere ganzzahlige Kennung, die als Typ oder Etikett (engl.: tag) bezeichnet wird. Da wir „Typ" anderweitig verwenden werden, wollen wir lieber die Bezeichnung „Etikett" für dieses Matching-Argument benutzen. Von einem Message-Passing-System wird erwartet, dass Puffermöglichkeiten derart bereit gestellt werden, dass eine Empfangsoperation mit einem bestimmten Etikett nur erfolgreich abgeschlossen wird, wenn eine gesendete Nachricht mit dazu passendem Etikett eintrifft. Diese Überlegung hat zur Folge, dass sowohl der Sender als auch der Empfänger ein solches Etikett als Parameter angeben müssen. Es ist auch günstig, wenn ein Sender mit einem zusätzlichen Parameter in einer Empfangsoperation angegeben werden kann. Schließlich ist es beim Empfangen sinnvoll, eine maximale Nachrichtengröße festlegen zu können (für Nachrichten mit gegebenem Etikett), aber das Empfangen kürzerer Nachrichten zu erlauben. Hierbei muss die tatsächliche Länge der empfangenen Nachricht in einer geeigneten Weise zurückgegeben werden können. Damit hat sich unsere minimale Schnittstelle zu send(address, length, destination, tag)
und receive(address, length, source, tag, actlen)
erweitert. Hierbei können source and tag in der Empfangsfunktion entweder als Eingangsparameter genutzt werden, um die empfangene Nachricht zu prüfen, oder als „wild card" (dargestellt durch spezielle Werte), um anzuzeigen, dass Nachrichten von jedem Sender oder mit einem beliebigen Etikett akzeptiert werden, wobei sie dann mit den Werten des tatsächlichen Senders bzw. des tatsächlichen Etiketts belegt werden. Das Argument actlen steht für die Länge der empfangenen Nachricht. In der Regel werden zu lange Nachrichten als fehlerhaft behandelt, während zu kurze toleriert werden. Viele Systeme mit Modifikationen dieser Schnittstellenart waren bereits im Einsatz, als die Erarbeitung von M P I begann. Einige wurden im vorherigen Kapitel erwähnt. Solche Message-Passing-Systeme erwiesen sich zwar als sehr nützlich, bedingten aber Einschränkungen, die einer großen Anwendergemeinschaft unliebsam waren. Das MPIForum versuchte, diese Beschränkungen zu beseitigen, indem flexiblere Definitionen der Parameter eingeführt wurden, wobei die vertrauten Konzepte der grundlegenden Operationen send und receive beibehalten wurden. Wir wollen diese Parameter nun schrittweise näher betrachten. Dazu diskutieren wir zuerst die ursprünglichen Restriktionen, bevor wir auf die Definitionen in M P I eingehen. Beschreibung der Nachrichtenpuffer.
D i e (address, length)-Spezifikation d e r
zu sendenden Nachricht war für frühere Hardware recht gut geeignet, ist aber aus zwei Gründen nicht wirklich ausreichend: • Oftmals ist die zu sendende Nachricht nicht zusammenhängend. Im einfachsten Fall kann es eine Zeile einer Matrix sein, die spaltenweise abgespeichert ist. Allgemeiner ausgedrückt kann die Nachricht aus einer irregulär verteilten Menge von
16
2 Einführung in MPI Strukturen unterschiedlicher Größe bestehen. In der Vergangenheit hielten die Programmierer (oder Bibliotheken) Code vor, um diese Daten vor dem Senden in zusammenhängende Puffer zu „packen" und sie beim Empfänger wieder zu „entpacken". Als jedoch Kommunikationsprozessoren aufkamen, die direkt mit so genannten strided data, also Daten mit Lücken oder noch allgemeiner verteilten Daten umgehen konnten, wurde es für die Leistungstärke noch entscheidender, dass das Packen vom Kommunikationsprozessor während der Übertragung (on the fly) erledigt wird, um zusätzliche Datenbewegungen zu vermeiden. Das aber ist ohne eine Beschreibung der ursprünglichen (verteilten) Form der Daten für die Kommunikationsbibliothek nicht möglich. • In den letzten Jahren ist eine wachsende Popularität des heterogenen Rechnens [BGL93] zu beobachten. Diese Popularität hat ihre Ursache in zwei Entwicklungen, zum einen in der Verteilung verschiedener Aufgaben einer komplexen Berechnung auf unterschiedlich spezialisierte Rechner (z. B. SIMD-, Vektor-, Graphikrechner), zum anderen im Einsatz von Workstation-Netzwerken als Parallelrechner. Workstation-Netzwerke, die über einen gewissen Zeitraum angeschafft worden sind, bestehen häufig aus Maschinen unterschiedlichen Typs. In den beiden gerade beschriebene Szenarien müssen Nachrichten zwischen Maschinen unterschiedlicher Architektur ausgetauscht werden, wobei das Paar ( a d d r e s s , l e n g t h ) damit keine ausreichende Spezifikation des semantischen Inhalts der Nachricht mehr ist. Zum Beispiel können bei einem Vektor von Gleitpunktzahlen nicht nur die Gleitpunktformate unterschiedlich sein, sondern auch die Länge. Das gilt genauso für ganze Zahlen. Die Kommunikationsbibliothek kann die erforderliche Transformation vornehmen, wenn präzise mitgeteilt wird, was übertragen wird.
Die Lösung beider Probleme durch MPI erfolgt dadurch, dass die Nachrichten auf einer höheren Ebene und flexibler als nur durch das Paar (address, length) beschrieben werden. Das spiegelt die Tatsache wider, dass Nachrichten deutlich mehr Struktur enthalten als ein einfacher Bitstrom. So ist ein MPI-Nachrichtenpuffer als Tripel (address, count, datatype) definiert, das eine Nachricht beschreibt, die aus count vielen Werten des Datentyps datatype besteht und ab Adresse address abgespeichert ist. Die Stärke dieses Mechanismus besteht in der Flexibilität der möglichen Werte für die DatentypVariable. Die Variable datatype kann die elementaren Datentypen der Host-Sprache als Werte annehmen. So beschreibt, um mit einigen Beispielen zu beginnen, (A, 300, MPI_REAL) einen Vektor A, bestehend aus 300 REAL-Werten in Fortran, ungeachtet der Länge oder des Formats einer Gleitpunktzahl. Eine MPI-Implementierung für heterogene Netze garantiert, dass die gleichen 300 REAL-Werte empfangen werden, auch wenn die empfangende Maschine mit einem völlig anderen Gleitpunktformat arbeitet. Die ganze Stärke dieses Datentypkonzepts rührt aber daher, dass die Anwender mit Hilfe von MPI-Routinen eigene Datentypen konstruieren können und dass diese Datentypen nicht zusammenhängende Daten beschreiben können. Details zur Konstruktion dieser „abgeleiteten" Datentypen enthält Kapitel 5. U n t e r s c h e i d u n g von Nachrichtenfamilien. Fast alle Message-Passing-Systeme stellen ein tag-Argument (das „Etikett") für die Operationen send und receive zur
2.3 Grundlegende MPI-Konzepte
17
Verfügung. Dieses Argument erlaubt dem Programmierer, das Eintreffen von Nachrichten in geordneter Weise zu behandeln, auch wenn die Nachrichten nicht in der erforderlichen Reihenfolge eintreffen. Das Message-Passing-System puffert eintreffende Nachrichten mit „falschem Etikett" bis das Programm (der Programmierer) für diese bereit ist. Gewöhnlich besteht die Möglichkeit, einen Platzhalter, also eine wild card, für das Etikett zu spezifizieren, der zu jedem Etikett passt. Dieser Mechanismus hat sich als notwendig, aber auch als unzureichend erwiesen, da die Freiheit, beliebige Etiketten wählen zu dürfen, bedeutet, dass das gesamte Programm Etiketten in vordefinierter und kohärenter Form nutzen muss. Besondere Schwierigkeiten ergeben sich bei Bibliotheken, die fern vom Anwendungsprogrammierer geschrieben worden sind, wenn Nachrichten nicht unkoordiniert im Anwendungsprogramm empfangen werden dürfen. Dieses Problem wird in M P I dadurch gelöst, dass die Idee des Etiketts um ein neues Konzept, den Kontext, erweitert wird. Kontexte werden zur Laufzeit als Antwort auf eine Anfrage von Nutzern (und Bibliotheken) vom System zugewiesen und für das Matching von Nachrichten benutzt. Sie unterscheiden sich von Etiketten darin, dass sie vom System zugewiesen werden, nicht vom Nutzer, und so kein Platzhalter-Matching erlaubt wird. Der übliche Begriff des Nachrichtenetiketts mit dem Matching von Platzhaltern ist in M P I aber ebenfalls enthalten. Bezeichnung von Prozessen. Prozesse gehören Gruppen an. Die η Prozesse einer Gruppe werden mit einer Prozessnummer (auch Rang, engl.: rank) identifiziert. Als Prozessnummern werden die Zahlen von 0 bis η— 1 verwendet. Es gibt eine Startgruppe, zu der alle Prozesse eines MPI-Programms gehören. Innerhalb dieser Gruppe werden dann alle Prozesse, ähnlich wie in vielen vorherigen Message-Passing-Systemen, von 0 beginnend bis η — 1 durchnummeriert. Kommunikatoren. Die Begriffe des Kontextes und der Gruppe werden in einem einzigen Objekt, dem Kommunikator, vereint, der in den meisten Punkt-zu-Punktund kollektiven Operationen als Parameter vorkommt. So beziehen sich die Parameter destination oder source in einer Sende- oder Empfangsoperation immer auf die Nummer des Prozesses einer Gruppe, die durch den entsprechenden Kommunikator festgelegt ist. Somit sieht die grundlegende (blockierende) Operation send in M P I wie folgt aus: MPI_Send(address, count, datatype, destination, tag, comm) mit • (address, count, datatype) zur Beschreibung von count vielen Datenelementen des Typs datatype, die ab Adresse address abgelegt sind, • destination zur Angabe der Nummer des Zielprozesses in der mit dem Kommunikator comm assoziierten Gruppe,
2 Einführung in MPI
18
• tag, einer ganzen Zahl, die beim Matching der Nachrichten benötigt wird, und • comm zur Identifizierung einer Prozessgruppe und eines Kommunikationskontextes. Die Operation receive hat die Gestalt MPI_Recv(address, maxcount, datatype, source, tag, comm, status)
mit: • (address, maxcount, datatype) beschreibt genau wie in MPI_Send den Emp-
fangspuffer. Erlaubt ist der Empfang von höchstens maxcount Elementen des Datentyps datatype. Die Argumente tag und comm sind wie in MPI_Send definiert, mit dem Unterschied, dass ein Platzhalter, der zu jedem Etikett passt, möglich ist. • source ist die Nummer der Nachrichtenquelle in der zum Kommunikator comm gehörenden Gruppe oder ein Platzhalter, der jeden Quellprozess zulässt, • status speichert Informationen zur tatsächlichen Nachrichtenlänge, zur Quelle und zum Etikett. Die tatsächlichen Werte von source, t a g und count der empfangenen Nachricht können aus der status-Variable abgerufen werden. Einige frühe Message-Passing-Systeme gaben den Status durch separate Aufrufe zurück, die sich implizit auf die letzte empfangene Nachricht bezogen. Diese in MPI gewählte Herangehensweise zeigt die Bemühungen, auch in Situationen sicher arbeiten zu können, in denen mehrere Threads Nachrichten für einen Prozess erhalten.
2.4
Weitere interessante Konzepte in MPI
Bisher haben wir uns auf die fundamentalen Operationen send und receive konzentriert, weil man die kleinen, aber bedeutenden Modifizierungen der Argumente der ursprünglichen Sende- und Empfangsoperationen mit der am Kapitelanfang beschriebenen minimalen Message-Passing-Schnittstelle durchaus als die grundlegendste Neuheit von MPI ansehen kann. Gleichwohl ist MPI eine umfangreiche Spezifikation und beinhaltet viele andere fortgeschrittene Konzepte, ζ. B. die folgenden: Kollektive Kommunikation. Ein bewährtes Konzept früherer Message-PassingBibliotheken ist das der kollektiven Operation (engl.: collective operation), die von allen Prozessen in einer Berechnung ausgeführt wird. Es gibt zwei Arten kollektiver Operationen: • Datenaustausch-Operationen werden zur Reorganisation von Daten zwischen den Prozessen genutzt. Die einfachste ist die Broadcastoperation. Jedoch können zahlreiche komplexe Verteilungs- und Sammeloperationen (engl.: scattering, gathering) definiert werden, die MPI auch unterstützt.
2.4 Weitere interessante Konzepte in MPI
19
• Kollektive Berechnungs-Operationen (wie ζ. B. Minimum, Maximum, Summe, logisches Oder als auch vom Anwender definierte Operationen). In beiden Fällen kann eine Message-Passing-Bibliothek mit dem „Wissen" um die Struktur einer Maschine die Parallelität dieser Operationen optimieren bzw. verbessern. MPI verfügt über einen höchst flexiblen Mechanismus zur Beschreibung von Routinen für den Datentransfer. Diese sind besonders leistungsstark, wenn sie für die abgeleiteten Datentypen angewendet werden. MPI beinhaltet auch eine große Menge an kollektiven Kommunikationsoperationen sowie einen Mechanismus, der es dem Anwender erlaubt, solche Operationen selbst zu erstellen. Darüber hinaus bietet MPI Operationen zur Bildung und Verwaltung von erweiterbaren Gruppen. Solche Gruppen können zur Steuerung des Geltungsbereichs kollektiver Operationen genutzt werden. Virtuelle Topologien. Man kann zur bequemeren Programmierung Prozesse in eine anwendungsbezogene Topologie fassen. Sowohl allgemeine Graphen also auch Gitter von Prozessen werden von MPI unterstützt. Topologien stellen eine Methode dar, um Prozessgruppen verwalten zu können, ohne direkt mit ihnen arbeiten zu müssen. Da Topologien ein Standardbestandteil von MPI sind, betrachten wir sie nicht als besonderes, fortgeschrittenes Konzept. Wir nutzen sie im Buch schon früh (Kapitel 4) und von da ab häufig. Debugging und Profiling. Anstatt spezielle Schnittstellen zu spezifizieren, fordert MPI die Verfügbarkeit von so genannten „Hooks", die es dem Anwender gestatten, Aufrufe von MPI-Funktionen abzufangen und so eigene Debugging- und ProfilingMechanismen zu definieren. Im Kapitel 7 zeigen wir ein Beispiel für das Schreiben solcher Hooks zur Visualisierung des Programmverhaltens. Ubertragungsmodi. MPI enthält sowohl die oben beschriebenen blockierenden Sendeund Empfangsoperationen als auch nicht blockierende Versionen zum Senden und Empfangen, bei denen explizit getestet werden kann, ob die Operation abgeschlossen ist, und gegebenenfalls explizit auf die Beendigung gewartet werden kann. Dabei kann das Testen bzw. Warten für mehrere Operationen gleichzeitig erfolgen. Desweiteren erlaubt MPI mehrere Ubertragungsmodi Der Standardmodus entspricht dem üblichen Verfahren in Message-Passing-Systemen. Der synchrone Modus erfordert einen Sendevorgang, der blockiert, bis die zugehörige Empfangsoperation aktiv wird (anders als das blockierende Senden im Standardmodus, das nur solange blockiert, bis der Puffer ausgelesen wurde). Der Readymodus (für das Senden) gibt dem Programmierer eine Möglichkeit, dem System anzuzeigen, dass das Empfangen gestartet wurde, so dass das darunter liegende System ein schnelleres Protokoll verwenden kann, sofern dies möglich ist. Der Puffermodus gestattet eine vom Nutzer steuerbare Pufferung für Sendeoperationen. Unterstützung für Bibliotheken. Die Strukturierung des gesamten Datenaustauschs durch Kommunikatoren eröffnet den Entwicklern von Bibliotheken erstmals die Möglichkeit, parallele Bibliotheken zu schreiben, die vollständig unabhängig vom Anwendercode sind und mit anderen Bibliotheken interagieren können. Bibliotheken können mit frei
20
2 Einführung in MPI
wählbaren Daten, so genannten Attributen, arbeiten, die mit den durch sie zugewiesenen Kommunikatoren assoziiert sind, und ihre eigenen Fehlerroutinen spezifizieren. Die Werkzeuge zur Erstellung paralleler Bibliotheken mit MPI, die diese Eigenschaften nutzen, werden in den Kapiteln 6 und 7 beschrieben. Unterstützung für heterogene Netzwerke. MPI-Programme sind in Netzwerken lauffähig, deren Rechner mit unterschiedlichen Längen und Formaten bei verschiedenen fundamentalen Datentypen arbeiten, da jede Kommunikationsoperation eine (möglicherweise sehr einfache) Struktur und alle Komponentendatentypen spezifiziert, so dass die Implementation jederzeit über genügend Informationen für eventuell notwendige Datenumwandlungen verfügt. MPI spezifiziert nicht, wie diese Umwandlungen vorzunehmen sind, wodurch verschiedenartige Optimierungen erlaubt sind. Wir diskutieren das Problem der Heterogenität genauer in Kapitel 7. Prozesse und Prozessoren. Der MPI-Standard behandelt Prozesse. Ein Prozess ist ein Softwarekonzept und repräsentiert einen Adressraum und einen oder mehrere Threads (jeder Thread besitzt einen Programmzähler). Ein Prozessor dagegen ist ein Teil der Hardware und enthält eine CPU (Central Processing Unit) zur Ausführung eines Programms. Einige MPI-Implementierungen lassen für ein MPI-Programm nur einen MPI-Prozess auf einem Prozessor zu; andere erlauben mehrere MPI-Prozesse je Prozessor. Überdies schränken manche Implementierungen ein MPI-Programm auf einen MPI-Prozess pro Knoten ein, wobei ein Knoten aus einem einzelnen symmetrischen Multiprozessorrechner (SMP) mit mehreren Prozessoren besteht. Ein MPI-Prozess ist in der Regel das gleiche wie ein Prozess im Betriebssystem. Dies wird jedoch nicht durch den MPI-Standard gefordert (siehe [Dem97] für ein Beispiel, in dem ein UNIX-Prozess mehrere MPI-Prozesse enthält).
2.5
Ist MPI umfangreich oder knapp?
Die grundlegendste Entscheidung für das MPI-Forum war vielleicht die, ob MPI „klein und exklusiv" werden, also nur den minimalen Durchschnitt der existierenden Bibliotheken enthalten sollte, oder „groß und allumfassend", d.h. die Vereinigung der Funktionalität der existierenden Systeme umfassen sollte. Am Ende wurde, obwohl man einige Ideen fallen ließ, ein Ansatz gewählt, der einen relativ großen Umfang an Fähigkeiten aufnahm, die sich in zahlreichen Bibliotheken und Anwendungen als nützlich erwiesen hatten. Gleichzeitig ist die Anzahl an Ideen in MPI klein; die Anzahl der Funktionen in MPI entsteht durch die Kombination einer kleinen Menge orthogonaler Konzepte. Um zu zeigen, wie wenig erforderlich ist, um MPI-Programme zu schreiben, stellen wir nun die unentbehrlichen Funktionen vor, ohne die der Programmierer nicht auskommen kann. Es sind lediglich sechs. Mit nur diesen Funktionen kann eine enorme Anzahl nützlicher und effizienter Programme geschrieben werden. Die anderen Funktionen ergänzen das Ganze um Flexibilität (Datentypen), Robustheit (nicht blockierendes Senden/Empfangen), Effizienz („ready" Modus), Modularität (Gruppen, Kommunikatoren) und Komfort (kollektive Operationen, Topologien). Gleichwohl kann man auf alle
2.6 Dem Entwickler verbleibende Entscheidungen MPI_Init MPI_Comm_size MPI_Comm_rank MPI_Send MPI_Recv MPI_Finalize Tabelle
21
Initialisierung von MPI Ermittlung der Prozessanzahl Ermittlung der eigenen Prozessnummer Senden einer Nachricht Empfangen einer Nachricht Beenden von MPI
2.1: Die Sechs-Funktionen-Version
von MPI
diese Konzepte verzichten und nur die in Tabelle 2.1 gezeigten sechs Routinen nutzen, um vollständige Message-Passing-Programme zu schreiben. Die Entwickler von MPI versuchten, die Konzepte von MPI konsistent und orthogonal zu gestalten. Folglich können die Nutzer Punktionen, die sie benötigen, schrittweise in ihr Repertoire aufnehmen, ohne alles auf einmal lernen zu müssen. So kann man z.B. bei kollektiver Kommunikation allein mit MPI_Bcast and MPI_Reduce viel erreichen, wie wir in Kapitel 3 zeigen. Als Nächstes erweitert man sein Repertoire wahrscheinlich um die nicht blockierenden Operationen, die wir in Kapitel 4 vorstellen, gefolgt von abgeleiteten Datentypen, die in Kapitel 4 eingeführt und in Kapitel 5 detaillierter untersucht werden. Die Entwicklung der Themenfelder, denen sich dieses Buch widmet, erfolgt über Beispiele, die die Einführung der einzelnen MPI-Routinen jeweils motivieren.
2.6
Dem Entwickler verbleibende Entscheidungen
Der MPI-Standard spezifiziert nicht jeden Aspekt eines parallelen Programms. Aspekte der parallelen Programmierung, die für eine bestimmte Implementierung spezifiziert werden müssen, sind ζ. B. die folgenden: • Der Start der Prozesse wird der Implementierung überlassen. Diese Strategie gestattet eine erhebliche Flexibilität in der Art der Ausführung von MPI-Programmen, allerdings zu Lasten der Portabilität der parallelen Programmierumgebung. 1 • Obwohl MPI eine gewisse Anzahl von Fehlercodes spezifiziert, kann die Implementierung über eine größere Menge an Fehlercodes verfügen, als dies im Standard vorgesehen ist. • Die Größe der Systempufferung von Nachrichten ist implementierungsabhängig, obgleich die Nutzer, wenn sie wollen, auch steuernd eingreifen können. Was wir unter Pufferung und Techniken zum Umgang mit dem Pufferproblem verstehen, beschreiben wir in Kapitel 4. Einen Blick aus Sicht des Implementierers auf Aspekte der Pufferung enthält Kapitel 8. ' D i e Fähigkeit von MPI-Programmen, neue Prozesse dynamisch zur Laufzeit zu starten, ist Bestandteil von MPI-2 des Standards. Sie wird in dem Buch [GLT99] beschrieben, das das vorliegende Buch ergänzt.
22
2 Einführung in MPI • Weitergehende Prägen, die bei einer tiefer gehenden Betrachtung der Implementierungsdetails auftreten, ζ. B. solche, die die Leistungsfähigkeit beeinflussen, werden in Kapitel 8 beantwortet.
Die Aufbereitung der in diesem Buch aufgeführten Beispiele erfolgte mit der freien, portablen Implementierung MPICH [GLDS96]. Hinweise zum Erhalt von MPICH und den Quellen der Beispiele sind im Anhang D zu finden. Informationen zu anderen Implementierungen von MPI findet man unter h t t p : / / w w w . m c s . a n l . g o v / m p i .
3
MPI in einfachen Programmen
In diesem Kapitel führen wir die grundlegendsten MPI-Funktionen ein, um einige einfache parallele Programme entwickeln zu können. Die Einfachheit eines parallelen Programms schränkt nicht etwa dessen Brauchbarkeit ein: eine kleine Anzahl von Basisroutinen reicht in der Regel völlig aus, um große und mächtige Anwendungen zu schreiben. Wir stellen in diesem Kapitel auch einige der Werkzeuge vor, die wir im Buch benutzen, um das Verhalten paralleler Programme zu untersuchen.
3.1
Ein erstes MPI-Programm
Als unser erstes paralleles Programm wählen wir ein „perfektes" paralleles Programm: es kommt mit einem Minimum an Kommunikation aus, die Lastverteilung ist automatisch gegeben und wir können das Ergebnis leicht überprüfen - wir wollen den Wert von π durch numerische Integration ermitteln. Wegen
L
- j d x = arctan(x)|J = arctan(l) — arctan(O) = arctan(l) = ^
werden wir die Funktion f(x) = 4/(1 + x2) integrieren. Um diese Integration numerisch auszuführen, teilen wir das Intervall von 0 bis 1 in η Teilintervalle und addieren die Flächen der Rechtecke auf, so wie es in Abbildung 3.1 für η = 5 dargestellt ist. Größere Werte für den Parameter η führen zu genaueren Näherungswerten für π. Dies ist zwar nicht gerade eine sehr gute Methode, um π zu berechnen, aber es ist für unseren Zweck ein gutes Beispiel. Um den Zusammenhang zwischen η und dem Approximationsfehler zu erkennen, erstellen wir ein interaktives Programm, das nach Eingabe von η zunächst einen Näherungswert berechnet (das ist der parallele Teil des Programms) und diesen dann mit einer bekannten, sehr genauen Approximation für π vergleicht. Die Parallelität des Algorithmus rührt daher, dass jeder Prozess die Flächen einer bestimmten Teilmenge der Rechtecke ermittelt und aufsummiert. Am Ende dieser Berechnung werden alle lokalen Summen zu einer globalen Summe, dem Wert für π, zusammengefasst. Die Anforderungen an den Datenaustausch sind dementsprechend einfach. Einer der Prozesse (wir wollen ihn Master nennen), ist verantwortlich für die Kommunikation mit dem Anwender. Der Master erhält einen Wert für η vom Anwender und verteilt ihn an alle anderen Prozesse. Jeder Prozess kann aus der Gesamtanzahl von Prozessen, seiner eigenen Prozessnummer und η feststellen, für welche Rechtecke er zuständig ist. Nach der Ausgabe eines Wertes für π sowie des Approximationsfehlers erwartet der Master einen neuen Wert für n.
24
Abb.
3 MPI in einfachen Programmen
3.1: Integration
zur Ermittlung
eines Wertes für π
Das vollständige Programm wird in Abbildung 3.2 gezeigt. Wir werden in diesem Buch in der Regel nur die „interessanten" Teile der Programme vorstellen und den Leser auf die Quellen für die vollständigen, lauffähigen Versionen des Programmtextes verweisen. Für unser erstes Programm wollen wir aber den Quelltext komplett angeben und beschreiben ihn mehr oder weniger Zeile für Zeile. In dem zum Buch gehörenden Programmverzeichnis ist das pi-Programm unter ' s i m p l e m p i / p i . f ' zu finden. Anhang D beschreibt genauer, wie man sich diesen Programmtext, weitere Beispiele sowie eine MPI-Implementierung beschaffen kann. Hinweise zur Installation der MPIImplementierung werden in Anhang Β gegeben. Unser Programm beginnt wie jedes andere Programm mit der Anweisung program main. Für Programme in Fortran 77 muss eine Headerdatei mpif .h. mit der Anweisung include "mpif.h" eingefügt werden. Programme in Fortran 90 können entweder mit i n c l u d e " m p i f . h " oder, falls es die MPI-Implementierung unterstützt, mit u s e mpi arbeiten. Diese Headerdatei bzw. das Modul mpi darf in keinem MPI-Fortran-Programm oder Unterprogramm fehlen, da hiermit notwendige Konstanten und Variablen definiert werden. Für Fortran 77 Compiler, die die include-Direktive nicht unterstützen, muss
3.1 Ein erstes MPI-Programm
! !
!
program main use mpi Use the following include if the mpi module is not available include "mpif.h" double precision PI25DT parameter (PI25DT = 3.141592653589793238462643d0) double precision mypi, pi, h, sum, x, f, a integer n, myid, numprocs, i, ierr function to integrate f(a) = 4.d0 / (l.dO + a*a) call MPI_INIT(ierr) call MPI_COMM_RANK(MPI_COMM_WORLD, myid, ierr) call MPI_COMM_SIZE(MPI_COMM_WORLD, numprocs, ierr)
10
! ! !
20 !
!
30
if ( myid .eq. 0 ) then print *, 'Enter the number of intervals: (0 quits) ' read(*,*) η endif broadcast η call MPI_BCAST(η,1,MPI_INTEGER,0,MPI_C0MM_WORLD,ierr) check for quit signal if ( η .le. 0 ) goto 30 calculate the interval size h = 1.0d0/n sum = O.OdO do 20 i = myid+1, n, numprocs χ = h * (dble(i) - 0.5d0) sum = sum + f(x) continue mypi = h * sum collect all the partial sums call MPI_REDUCE(mypi,pi,1,MPI_D0UBLE_PRECISI0N,MPI_SUM,0, k MPI_C0MM_W0RLD,ierr) node 0 prints the answer, if (myid .eq. 0) then print #, 'pi is ', pi, ' Error is', abs(pi - PI25DT) endif goto 10 call MPI_FINALIZE(ierr) stop end
Abb. 3.2: Fortran-Programm
zur Berechnung von π
25
26
3 MPI in einfachen Programmen
der Quellcode dieser Datei manuell in jede Funktion und Subroutine, die MPI-Aufrufe benutzt, eingefügt werden. In diesem Buch wird in allen Beispielen use mpi verwendet, da das MPI-Modul auf Korrektheit der Anzahl der Argumente und ihres jeweiligen Typs prüft. Falls eine MPI-Implementierung über kein MPI-Modul verfügt, kann die Headerdatei mpif .h eingefügt werden. Nach einigen Zeilen mit Variablendeklarationen sehen wir im Programmtext drei Zeilen, die wahrscheinlich in jedem Fortran-MPI-Programm weit vorn zu finden sind: call MPI_INIT( ierr ) call MPI_C0MM_RANK( MPI_C0MM_W0RLD, myid, ierr ) call MPI_COMM_SIZE( MPI_C0MM_W0RLD, numprocs, ierr ) Der Aufruf von MPI.INIT ist in jedem MPI-Programm erforderlich und muss der erste Aufruf einer MPI-Funktion sein. 1 Er richtet die MPI-„Umgebung" ein. In jeder Programmausführung darf nur ein MPI_INIT vorkommen. Das einzige Argument ist ein Fehlercode. Jede Fortran-MPI-Routine gibt in ihrem letzten Argument einen Fehlercode zurück: entweder MPI_SUCCESS oder einen durch die Implementierung definierten Fehlercode. In diesem und vielen anderen Beispielen werden wir etwas nachlässig sein und die Rückgabewerte unserer MPI-Routinen in der Annahme, dass sie stets auf MPI_SUCCESS gesetzt sind, nicht überprüfen. Dieses Vorgehen verbessert die Lesbarkeit der Programmtexte, allerdings auf Kosten der Zeit bei einer eventuellen Fehlersuche. Wir werden später (in Abschnitt 7.7) besprechen, wie Fehler getestet, behandelt und protokolliert werden können. Wie in Kapitel 2 beschrieben wurde, ist jeder MPI-Datenaustausch mit einem Kommunikator assoziiert, der den Kommunikationskontext und eine zugehörige Gruppe von Prozessen beschreibt. In diesem Programm werden wir nur den vordefinierten Standardkommunikator MPI_C0MM_W0RLD verwenden. Er definiert einen Kontext und die Menge aller Prozesse. MPI_C0MM_W0RLD ist eine der Definitionen in 'mpif . h ' . Der Aufruf von MPI_COMM_SIZE gibt (in dem Parameter numprocs) die Anzahl der Prozesse zurück, die der Anwender für das Programm gestartet hat. Wie Prozesse durch den Nutzer gestartet werden können, ist abhängig von der Implementierung. Die Anzahl der gestarteten Prozesse kann jedoch durch jedes Programm mit diesem Prozeduraufruf ermittelt werden. Der Wert von numprocs ist genau genommen die Größe der Gruppe, die mit dem Standardkommunikator MPI_C0MM_W0RLD assoziiert ist. Die Prozesse einer jeden Gruppe können wir uns als mit 0 beginnend fortlaufend durchnummeriert vorstellen. Diese Nummern werden als Rang (engl.: rank) bezeichnet. Durch den Aufruf von MPI_COMM_RANK ermittelt jeder Prozess seinen Rang in der zum Kommunikator gehörenden Gruppe. So wird jeder Prozess den gleichen Wert für numprocs, aber einen jeweils anderen für myid erhalten. Als nächstes liest der Masterprozess (er kann sich selbst über seinen Wert von myid identifizieren) den vom Nutzer eingegebenen Wert für n, der die Anzahl der Rechtecke festlegt, ein. 1 Eine Ausnahme ist die Routine M P I . I n i t i a l i z e d , die von einer Bibliothek aufgerufen werden kann, um festzustellen, ob MPI_Init aufgerufen wurde (siehe dazu Abschnitt 7.8.2).
3.1 Ein erstes MPI-Programm
27
Die Zeile call
MPI_BCAST(η,1,MPI_INTEGER,0,MPI_C0MM_W0RLD,ierr)
sendet den Wert von η an alle Prozesse. Man beachte, dass alle Prozesse MPI_BCAST aufrufen, also sowohl der die Daten sendende Prozess (mit Rang 0) als auch alle anderen Prozesse in MPI_C0MM_W0RLD.2 MPI_BCAST terminiert in jedem Prozess (der Gruppe, die durch den Kommunikator im fünften Argument festgelegt wird) mit einer Kopie von n. Die versendeten Daten werden spezifiziert durch die Adresse (n), den Datentyp (MP I-INTEGER), und die Anzahl der Datenelemente (1). Der Prozess mit dem Originalwert wird im vierten Argument angegeben (in diesem Fall 0, also der Masterprozess, der die Daten einliest). (MPI weist jedem Datenelement einen Typ zu. MPI-Datentypen werden ausführlich in Abschnitt 5.1 beschrieben.) Nach dem Aufruf von MPIJ3CAST kennen nun alle Prozesse den Wert η und den eigenen Rang, was ausreicht, um das eigene Teilergebnis, mypi, zu berechnen. Jeder Prozess ermittelt die Fläche jedes numprocs-ten Rechtecks, beginnend mit dem (myid+l)-ten Rechteck und addiert diese auf. Danach müssen die Werte von mypi, die von den einzelnen Prozessen berechnet wurden, addiert werden. M P I stellt hierfür eine umfangreiche Menge an Operationen zur Verfügung, die alle mit der Routine MPI_REDUCE genutzt werden können. In dieser Routine wird durch ein Argument festgelegt, welche arithmetische oder logische Operation ausgeführt werden soll. In unserem Fall rufen wir die Routine mit c a l l MPI_REDUCE(mypi,pi,1,MPI_D0UBLE_PRECISI0N,MPI_SUM,0, & MPI_C0MM_W0RLD,ierr) auf. Die ersten zwei Argumente legen die Adressen von Quelle bzw. Ergebnis fest. Der jeweils „einzusammelnde" Datenwert besteht aus einem Element (1, dritter Parameter) vom Typ MPI_D0UBLE_PRECISI0N (vierter Parameter). Die Operation ist die Addition (MPI_SUM, fünftes Argument). Das Ergebnis der Operation wird in p i im Prozess mit Rang 0 (sechstes Argument) abgespeichert. Die zwei letzten Argumente sind wie gewöhnlich der Kommunikator und der Fehlercode. Die ersten beiden Argumente von MPI .REDUCE dürfen sich nicht überlappen (d. h. müssen verschiedene Variablen oder Teile eines Felds sein). Im Abschnitt 7.3.2 wird eine vollständige Liste der Operationen angegeben; anwenderdefinierte Operationen werden im Abschnitt 7.3.2 besprochen. Alle Prozesse kehren schließlich zum Schleifenanfang zurück (der Master gibt vorher das Ergebnis aus). MPIJ3CAST bewirkt, dass alle Prozesse mit Ausnahme des Masterprozesses auf den nächsten Wert für η warten. 2 I n einigen anderen Message-Passing-Systemen können per Broadcast gesendete Nachrichten mit einem receive empfangen werden, so wie eine Nachricht mit einem send gesendet wird. In M P I sind Kommunikationen mit mehr als zwei beteiligten Prozessen kollektive Kommunikationen, und alle beteiligten Prozesse rufen dieselbe Routine auf. MPI_BCAST ist ein Beispiel für eine kollektive Kommunikationsroutine.
28
3 M P I in einfachen Programmen
MPLINIT(ierror) integer ierror M P I _ C O M M _ S I Z E ( c o m m , size, ierror) integer comm, size, ierror M P L C O M M - R A N K f c o m m , rank, ierror) integer comm, rank, ierror M P I _ B C A S T ( b u f f e r , count, datatype, root, comm, ierror) buffer(*) integer count, datatype, root, comm, ierror M P L R E D U C E ( s e n d b u f , recvbuf, count, datatype, op, root, comm, ierror) sendbuf(*), recvbuf(*) integer count, datatype, op, root, comm, ierror MPLFINALIZE(ierror) integer ierror Tabelle 3.1: Fortran-Signaturen
für die ίτη pi-Programm
verwendeten
Routinen
Gibt der Anwender in der Eingabeaufforderung für die Anzahl der Rechtecke eine Null ein, wird die Schleife beendet und alle Prozesse führen die Routine call
MPI_FINALIZE(ierr)
aus. Dieser Aufruf muss durch jeden Prozess einer MPI-Berechnung erfolgen. Er schließt die MPI-„Umgebung"; von wenigen Ausnahmen abgesehen kann nun ein Prozess, nachdem er MPI_FINALIZE aufgerufen hat, keine weiteren MPI-Aufrufe mehr auslösen. Auch MPI.INIT kann nicht erneut aufgerufen werden. Die Signaturen (engl.: bindings) in Fortran für die in diesem Abschnitt verwendeten MPI-Routinen sind in Tabelle 3.1 zusammengefasst. In den Tabellen mit FortranSignaturen steht der Ausdruck für jeden beliebigen Datentyp in Fortran, ζ. B. INTEGER oder DOUBLE PRECISION.
3.2
Ausführung des ersten MPI-Programms
Wie man MPI-Programme auf einer speziellen Maschine oder in einem Rechnernetz startet, ist nicht Bestandteil des MPI-Standards, d. h. dies kann von einer Plattform zur anderen variieren. Mehrere existierende MPI-Implementierungen benutzen mpirun -np 4 p i (oder eine ähnliche Syntax) zum Programmstart. Inzwischen einigte sich das MPI-
3.3 Ein erstes MPI-Programm in C
29
Forum auf einen Standard, der die Syntax mpiexec - n 4 p i empfiehlt, aber nicht vorschreibt (siehe [GLS99a] für eine umfassende Diskussion der Optionen für mpiexec). In MPICH ζ. B. kann man mit mpiexec arbeiten. Manche MPI-Implementierungen können andere Kommandos zum Start von MPI-Programmen erfordern; oft erhält man mit dem Kommando man mpi Hilfen, wie Programme gestartet werden können. Der MPI-2 Standard ermuntert die Implementierer mit Nachdruck, das Kommando mpiexec als einheitliche Schnittstelle zum Programmstart bereit zu stellen.
3.3
Ein erstes MPI-Programm in C
In diesem Abschnitt betrachten wir erneut das Programm zur Berechnung von π, aber nun in C anstelle von Fortran. Generell wurde versucht, die Fortran- und C-Fassung soweit wie möglich ähnlich zu halten. Der wesentliche Unterschied besteht in der Rückgabe der Fehlercodes: in C erfolgt dies als Wert der Funktion, in Fortran als ein gesondertes Argument. Zusätzlich sind die Argumente der meisten C-Funktionen strenger typisiert als die in Fortran, d. h. sie haben spezifische Typen wie ζ. B. MPI_Comm und MPI-Datatype, wo in Fortran Integer verwendet werden. Natürlich gibt es eine andere Headerdatei: ' m p i . h ' anstelle des MPI-Moduls mpi (oder ' m p i f . h ' in Fortran 77). Schließlich sind die Argumente von MPI_Init derart, dass Parameter von der Kommandozeile eingelesen werden können. Von einer MPI-Implementierung wird erwartet, dass sie alle Kommandozeilenargumente, die von der Implementierung zu verarbeiten sind, aus dem argv-Feld löscht, bevor die Steuerung zum Anwenderprogramm zurück kehrt und a r g e entsprechend dekrementiert wird. Es ist zu beachten, dass die Argumente von MPI_Init in C die Adressen der üblichen main-Argumente a r g e und argv sind. Neu in Implementierungen von MPI-1.2 ist die Möglichkeit, beide Adressen auf NULL setzen zu können. Das Äquivalent in C++ besteht in zwei Methoden für MPI_Ini"t, eine mit und eine ohne die Argumente a r g e und argv. Es kann jedoch vorkommen, dass manche MPI-Implementierungen dies (noch) nicht unterstützen. Das Programm ist in Abbildung 3.3 dargestellt. Die Definitionen der C-Versionen der benutzten MPI-Routinen sind in Tabelle 3.2 zusammen gestellt.
3.4
Ein erstes MPI-Programm in C + +
Wir betrachten unser Programm zur π-Berechnung erneut, diesmal in C++, um die neuen C++-Signaturen (engl.: bindings), die in den MPI-Standard als Teil der Weiterentwicklung von MPI-2 aufgenommen wurden, zu illustrieren. Das MPI-Forum hatte drei grundlegende Alternativen bei der Entscheidung, wie Anwender MPI in C++-Programmen nutzen könnten.
30
3 MPI in einfachen Programmen
# i n c l u d e "mpi.h" # i n c l u d e i n t main( i n t a r g e , char * a r g v [ ] ) { i n t n , myid, numprocs, i ; double PI25DT = 3.141592653589793238462643; double mypi, p i , h , sum, x; MPI.Init(feargc,feargv); MPI_Comm_size(MPI_COMM_WORLD,&numprocs); MPI_Comm_rank(MPI_C0MM_W0RLD,icmyid); w h i l e (1) { i f (myid == 0) { p r i n t f ( " E n t e r t h e number of i n t e r v a l s : scanf ("°/0d" ,&n);
(0 q u i t s )
");
> MPI_Bcast(&n, 1, MPI_INT, 0, MPI_C0MM_W0RLD); i f (n == 0) break; else { h = 1.0 / (double) n; sum = 0 . 0 ; f o r ( i = myid + 1 ; i mypi = h * sum; MPI_Reduce(femypi, &pi, 1, MPI_D0UBLE, MPI_SUM, 0, MPI_C0MM_W0RLD); i f (myid == 0) p r i n t f ( " p i i s a p p r o x i m a t e l y 7, . 1 6 f , E r r o r i s °/ 0 .16f\n", p i , f a b s ( p i - PI25DT));
>
>
MPI_Finalize(); r e t u r n 0;
> Abbildung
3.3: C-Programm
zur Berechnung
von π
• Die (aus Sicht des Forums) einfachste Möglichkeit wäre die, die C-Signaturen unverändert zu nutzen. In C geschriebene Funktionen können von C++ her aufgerufen werden, indem sie als „externe C"-Funktionen definiert werden. Man befürchtete jedoch, dass dieser Zugang für C++-Programmierer, die die Konventionen von C++ gewohnt sind, zu Verdruss führen würde. Außerdem besitzt MPI selbst ein objektorientiertes Design, und so wäre es beschämend, dieses Design in einer objektorientierten Sprache wie C++ nicht explizit auszudrücken.
3.4 Ein erstes MPI-Programm in C + +
31
int MPI_lnit(int *argc, char ***argv)
int MPI_Comm_size(MPLComm comm, int *size) int MPI_Comm_rank(MPI_Comm comm, int *rank) int MPI_Bcast(void *buf, int count, M P L D a t a t y p e datatype, int root, M P L C o m m comm) int MPI_Reduce(void *sendbuf, void *recvbuf, int count, MPIJDatatype datatype, MPI.Op op, int root, M P L C o m m comm) int MPLFinalizeQ
Tabelle 3.2: C-Signaturen für die im pi-Programm
verwendeten
Funktionen
• Ein anderer Zugang wäre die Definition einer vollständigen Klassenbibliothek, die die Leistungsstärke von C + + nutzt, um die C++-Signaturen von der Struktur der C- und Fortran-Signaturen zu lösen. Das Forum entschied, dass vor einer Standardisierung einer solchen Bibliothek weitere Untersuchungen erfolgen müssten. Ein Beispiel einer derartigen Bibliothek ist O O M P I [SML96]. • Eine Kompromisslösung würde die objektorientierte Struktur von MPI nutzen, um Klassen und Methoden zu definieren, die sich eng an der Struktur der C- und Fortran-Signaturen orientierten. Die C++-Signaturen für MPI folgen dem dritten Zugang. Die meisten C-Funktionen wurden Methoden der C++-Klassen, sodass man sie quasi als Objekte ansehen kann. So ist z.B. MPI: :C0MM_W0RLD ein Element der Kommunikatorklasse, Get_rank und G e t . s i z e sind zwei ihrer Methoden. Alle Klassen sowie Methoden, auf die ohne eine Klasse zugegriffen werden kann, gehören dem Namensraum von MPI an. Die C++-Fassung unseres Programms zur Berechnung einer Näherung für π zeigt Abbildung 3.4. Es sieht, bis auf die im Folgenden erläuterten Unterschiede, der C-Version in Abbildung 3.3 sehr ähnlich. Der Aufruf von MPI_Init wurde zu MPI: : I n i t mit fast den gleichen Argumenten (da die C++-Fassung Referenzparameter benutzt, ist es nicht erforderlich bzw. korrekt, die Adressen der Argumente zu übergeben). Statt die Adresse einer Variablen, die mit der Anzahl der Prozesse in MPI_C0MM_W0RLD belegbar ist, zu übergeben, wenden wir die Methode G e t . s i z e auf das Objekt MPI: :C0MM_W0RLD an, die die Anzahl der Prozesse des Kommunikators MPI: :C0MM_W0RLD als Wert zurückgibt. Hier sehen wir einen der wesentlichen Unterschiede zwischen C und C++. In C ist der Rückgabewert jeder Funktion für den Fehlercode reserviert; standardmäßig brechen alle Prozesse im Fehlerfall (außer bei Fehlercode MPI_SUCCESS) ab. Eine Alternative besteht in der Rückgabe der Fehlercodes. Der Umgang mit den Möglichkeiten zur Fehlerbehandlung wird in Kapitel 7 beschrieben. In C + + ist der Standard für die Fehlerbehandlung der gleiche wie in C (alle Prozesse brechen ab); die Alternative besteht jedoch darin, den Fehlercode nicht
32
3 MPI in einfachen Programmen
#include #include "mpi.h" i n t m a i n ( i n t a r g e , char * a r g v [ ] ) { i n t n, rank, s i z e , i ; double PI25DT = 3.141592653589793238462643; double mypi, p i , h, sum, x ; M P I : : I n i t ( a r g e , argv); s i z e = MPI::COMM_WORLD.Get_size(); rank = MPI::COMM_WORLD.Get_rank(); while (1) { i f (rank == 0) { cout « "Enter t h e number of i n t e r v a l s : « endl; e i n >> η;
(0 q u i t s ) "
>
MPI::C0MM_W0RLD.Beast(&n, 1, MPI::INT, 0 ) ; i f (n==0) break; else { h = 1 . 0 / (double) n; sum = 0 . 0 ; f o r ( i = rank + 1 ; i
mypi = h * sum;
>
}
MPI::C0MM_W0RLD.Reduce(ftmypi, &pi, 1, MPI::DOUBLE, MPI::SUM, 0 ) ; i f (rank == 0) cout « "pi i s approximately " recv Issend Β Probe •SSM Recv lllllllllin "educe \m , Ssend 8 Test • 1 ^ 1 Wait |MBMS| Wallall i •· "U
J—
68.0
ΓΤ
•
ι 1 H ι "ί ι IΊ
68.2
1 1
"" .
IH IM Γ
•133SSSSI
}-f.
HBS-U_
I
Send
Β-Γ Ί
68.4
Η
Τ
l-l J » ,, ,
1 Ί
68.6
H
... , .
••;••·;• :l i
1
1
__ _
1 68.9
-
1
69.1
-
—
Γ
69.3
Abbildung 4-13: Kommunikation in einem Iterationsschritt mit paarweise geordnetem Senden und Empfangen subroutine exchngl( a, nx, s, e, commld, nbrbottom, nbrtop ) use mpi integer nx, s, e double precision a(0:nx+l,s-l:e+l) integer commld, nbrbottom, nbrtop integer status(MPI_STATUS_SIZE), ierr ι call MPI_SENDRECV( & a(l,e), nx, MPI_D0UBLE_PRECISI0N, nbrtop, 0, & a(l.s-l), nx, MPI_D0UBLE_PRECISI0N, nbrbottom, 0, & commld, status, ierr ) call MPI_SENDRECV( & a(l,s), nx, MPI_D0UBLE_PRECISI0N, nbrbottom, 1, & a(l,e+l), nx, MPI_D0UBLE_PRECISI0N, nbrtop, 1, & commld, status, ierr ) return end Abbildung
4·14: Routine zum Datenaustausch mit send-receive
Senden mit Pufferung. Als Alternative zur Ausarbeitung einer sicheren Reihenfolge von Sende- und Empfangsoperationen bietet M P I dem Programmierer die Möglichkeit, Puffer zu verwenden, in die die Daten geschrieben werden können, bis sie zugestellt werden (oder den Puffer zumindest verlassen haben). Die Änderung an der AustauschRoutine ist einfach: man ersetzt einfach die Aufrufe von MPI_Send durch MPI_Bsend. Die resultierende Routine ist in Abbildung 4.15 zu sehen.
87
4.3 Ein Programm für das Poisson-Problem subroutine exchngl( a, nx, s, e, commld, nbrbottom, nbrtop ) use mpi integer nx, s, e double precision a(0:nx+l,s-1:e+1) integer commld, nbrbottom, nbrtop integer status(MPI_STATUS_SIZE), ierr call MPI_BSEND( a(l,e), nx, MPI_DOUBLE_PRECISION, nbrtop, & 0, commld, ierr ) call MPI_RECV( a(l,s-l), nx, MPI_D0UBLE_PRECISI0N, nbrbottom, & 0, commld, status, ierr ) call MPI_BSEND( a(l,s), nx, MPI_D0UBLE_PRECISI0N, nbrbottom, & 1, commld, ierr ) call MPI_RECV( a(l,e+l), nx, MPI_D0UBLE_PRECISI0N, nbrtop, & 1, commld, status, ierr ) return end Abbildung ^.15; Routine zum Datenaustausch
für das Senden mit Pufferung
Zusätzlich zu diesen Änderungen in der Routine für den Datenaustausch verlangt MPI, dass der Programmierer den Speicherplatz für die Nachricht unter Verwendung der Routine MPI_Buffer_attach bereit stellt. Dieser Puffer muss groß genug sein, um alle Nachrichten aufnehmen zu können, die vor dem Aufruf der zugehörigen Empfangsoperationen gesendet werden. In unserem Beispiel brauchen wir Puffer für 2*nx Werte doppelter Genauigkeit. Das können wir mit Anweisungen wie double p r e c i s i o n
buffer(2*MAXNX+2*MPI_BSEND_0VERHEAD)
c a l l MPI_BUFFER_ATTACH( b u f f e r , & 2*MAXNX*8+2*MPI_BSEND_0VERHEAD*8, i e r r ) erreichen. Die „8" steht hier für die Anzahl der Bytes eines Wertes mit doppelter Genauigkeit. Dieser Wert könnte auch mit der Funktion MPI_Type-extent, die in Kapitel 5 beschrieben wird, oder mit MPI-SIZEOF aus MPI-2 (nur für Fortran) ermittelt werden. Zu beachten ist, dass im Puffer zusätzlicher Speicherplatz bereit gestellt wird. Für jede Nachricht, die mit MPI_Bsend gesendet wird, muss separater Speicherplatz von MPI_BSEND.OVERHEAD Bytes bereit gestellt werden. Eine MPI-Implementierung benutzt diesen in der Routine MPI_Bsend, um den Pufferspeicherplatz zu verwalten und die Kommunikation zu bewerkstelligen (sie kann ζ. B. ein MPI_Request innerhalb des Pufferbereichs zuweisen). Unser Quellcode reserviert sogar acht mal mehr Speicherplatz für diesen Overhead als benötigt wird, was der Vereinfachung der Deklaration dient. Ansonsten müsste man etwa double p r e c i s i o n
buffer(2*MAXNX+2*((MPI_BSEND_0VERHEAD+7)/8))
88
4 MPI für Fortgeschrittene
schreiben. Wenn das Programm einen Puffer nicht mehr benötigt (oder diesen für andere Zwecke braucht), sollte die Routine MPI_Buffer_detach aufgerufen werden. Die Signaturen für diese Routinen sind in den Tabellen 4.7, 4.8 und 4.9 zusammengestellt. (Der Grund für die Parameter buffer und size in MPI_Buffer_detach besteht darin, dass C-Programmierer diese benutzen können, um Platzierung und Größe eines existierenden Puffers ermitteln zu können.) Es ist wichtig, sich zu vergegenwärtigen, dass die Arbeit mit Puffern einen Leistungsverlust (zusätzlicher Aufwand zum Kopieren der Daten in den und aus dem Puffer) verursachen sowie zu Programmfehlern (fehlerhafte Berechnung des erforderlichen Pufferplatzes) führen kann. MPLBSEND(buf, count, datatype, dest, tag, comm, ierror) buf(*) integer count, datatype, dest, tag, comm, ierror MPLBUFFER_ATTACH(buffer, size, ierror) buffer(*) integer size, ierror MPLBUFFER_DETACH(buffer, size, ierror) buffer(*) integer size, ierror Tabelle 4.7: Fortran-Signaturen für Puffer-Operationen
int MPI_Bsend(void* buf, int count, MPLDatatype datatype, int dest, int tag, MPIXomm comm) int MPLBufFer_attach(void* buffer, int size) int MPI_Buffer_detach(void* buffer, int* size) Tabelle 4-8: C-Signaturen für die Funktionen mit Pufferung. Man beachte, dass das PufferArgument in MPI_Buffer_detach, obwohl es vom Typ void * ist, tatsächlich ein Zeiger auf einen Zeiger ist, aber wegen einer bequemeren Benutzung als Typ void * vereinbart ist
void MPI::Comm::Bsend(const void* buf, int count, const Datatype^ datatype, int dest, int tag) const void MPI::Attach_buffer(void* buffer, int size) void MPI::Detach_buffer(void*& buffer) Tabelle 4-9: C++-Signaturen für die Methoden mit Pufferung
4.4 Anwendung nichtblockierender Kommunikationen
89
Auf eine letzte Gefahr bei der Anwendung von MPI_Bsend sei noch hingewiesen. Man könnte annehmen, dass eine Schleife wie die folgende zum Senden von 100 Bytes in jedem Schleifendurchlauf geeignet sei: size = 100 + MPI_BSEND_OVERHEAD call MPI_BUFFER_ATTACH( buf, size, ierr ) do 10 i=l, η call MPI_BSEND( sbuf, 100, MPI_BYTE, 0, dest, & MPI_C0MM_W0RLD, ierr ) ... other work 10 continue call MPI_BUFFER_DETACH( buf, size, ierr ) Das Problem dieses Programmstückes liegt darin, dass die im i-ten Schritt der Schleife gesendete Nachricht noch nicht zugestellt sein muss, wenn der nächste Aufruf von MPI_Bsend im ( i + l ) - t e n Schritt erfolgt. Um MPI_Bsend in diesem Fall korrekt anzuwenden, muss entweder der mit MPI_Buf f er_attach reservierte Speicher groß genug für alle zu sendenden Daten sein (hier also n * 100+MPIJ3SEND.OVERHEAD) oder das Reservieren und Freigeben von Speicher in die Schleife gezogen werden. Eine Alternative zur Verwendung des Sendens mit Pufferung besteht in Kommunikationsoperationen, die nicht blockieren, d. h. das Programm kann weiter ausgeführt werden, ohne auf die Beendigung der Kommunikationen zu warten. Mit dieser Strategie kann das Programm Berechnungen ausführen, während es auf das Ende von Kommunikationsoperationen wartet. Die Möglichkeit, Berechnungen mit der Kommunikation zu überlappen, ist aber nur ein Grund, nichtblockierende Kommunikationsoperationen in M P I zu betrachten. Ebenso bedeutend ist die Möglichkeit, eine Kommunikationsoperation daran zu hindern, andere in ihrer Beendigung zu stören.
4.4
Anwendung nichtblockierender Kommunikationen
Bei den meisten Parallelrechnern braucht das Ubertragen von Daten von einem Prozess zu einem anderen mehr Zeit als das Bearbeiten von Daten in einem Prozess. Die Prozesse in einem modernen Parallelrechner können zum Beispiel bis zu 500 Millionen Gleitkommaoperationen pro Sekunde ausführen, aber es können nur etwa zehn Millionen Wörter pro Sekunde zwischen Prozessen übertragen werden. 3 Um zu vermeiden, dass ein Programm ausgebremst wird (auch als „an Daten verhungern" bezeichnet), bieten viele Parallelrechner den Nutzern die Möglichkeit, das Senden (und Empfangen) mehrerer Nachrichten zu starten und mit anderen Operationen weiterzuarbeiten. Programmierer, die mit „asynchroner Ein-/Ausgabe" vertraut sind, schätzen diese Methode als ein Mittel, die relativ geringe Geschwindigkeit des Zugriffs auf externe Informationen 3
Diese offensichtliche Diskrepanz reflektiert die physikalischen Realitäten und ist ein Hauptgrund dafür, dass sich die Methode des Message-Passing, die den Programmierer die Kommunikationskosten nicht vergessen lässt, als ein erfolgreicher Weg zur Programmierung von Parallelrechnern erwies.
90
4 MPI für Fortgeschrittene
(Platten im Fall von E / A , ein anderer Prozess beim Message-Passing) zu kompensieren. MPI unterstützt diese Herangehensweise durch die Bereitstellung von nichtblockierenden Sende- und Empfangsroutinen. Nichtblockierende Routinen lösen auch das Problem der Pufferung, indem sie das Ende der Ausführung der Übertragung solange verzögern, bis der Nutzer vermittels einer MPI-Empfangsroutine Platz zur Abspeicherung der Nachricht bereit stellt. Viele Konzepte von MPI besitzen diese Eigenschaft, gleich zwei Probleme zu lösen. Die Routine MPI.Isend startet die nichtblockierende Sendeoperation. Die Parameter sind die gleichen wie bei MPI_Send, nur dass ein weiterer Parameter als vorletzter (in C ist er der letzte) hinzukommt: ein so genanntes Handle (engl.: handle). Beide Routinen verhalten sich auch ähnlich, nur mit der Ausnahme, dass bei MPI_Isend der Nachrichtenpuffer nicht verändert werden darf, solange die Nachricht noch nicht zugestellt wurde (genauer gesagt, bis die Operation abgeschlossen wurde, was mit einer der Routinen MPI_Wait oder MPI_Test feststellbar ist). In C ist MPI-Request der Typ des neuen Parameters handle. Uber das Handle kann festgestellt werden, ob eine Operation abgeschlossen wurde. Am einfachsten kann dieser Test mit MPI_Test erfolgen:
10
call MPI_ISEND( buffer, count, datatype, dest, tag, & comm, request, ierr ) < do other work > call MPI_TEST( request, flag, status, ierr ) if ( .not. flag) goto 10
Oft ist es erwünscht, auf den Abschluss des Sendens zu warten. Statt hierfür wie im Beispiel von eben eine Schleife zu programmieren, ist es geschickter, mit MPI_Wait zu arbeiten: call MPI_WAIT( request, status, ierr )
Wenn eine nichtblockierende Operation abgeschlossen wurde (ζ. B. MPI_Wait terminiert oder MPI_Test gibt flag = .true, zurück), wird die Variable request auf den Wert MP I .REQUEST JJULL gesetzt.
Die Routine MPI_Irecv startet die nichtblockierende Empfangsoperation. Sie hat, genau wie MPI_Isend, das Handle als zusätzlichen Parameter. Dagegen fehlt der Parameter status, der benutzt wurde, um Informationen zur abgeschlossenen Empfangsoperation bereit zu stellen. Genau wie bei MPI.Isend kann MPI_Test aufgerufen werden, um festzustellen, ob ein mit MPI_Irecv gestartetes Empfangen abgeschlossen ist. MPI.Wait kann zum Warten auf das Ende einer solchen Empfangsroutine genutzt werden. Die status-Argumente dieser beiden Routinen geben die Informationen zu den abgeschlossenen Empfangsoperationen in der gleichen Form wie beim blockierenden Empfangen zurück. In vielen Fällen besteht der Wunsch, auf mehrere nichtblockierende Operationen gleichzeitig zu warten oder abzufragen, ob sie beendet wurden. Man kann zwar dazu eine
4.5 Synchrones Senden und „sichere" Programme
91
Schleife für die entsprechenden Operationen verwenden, doch ist dieses Vorgehen nicht effizient, da das Anwenderprogramm so ständig Anweisungen ausführt. Besser ist es, auf die „nächste" Nachricht zu warten (das ist möglich, ohne CPU-Zeit zu verschwenden). M P I ermöglicht es (mit d e n R o u t i n e n MPI.Waitall u n d MPIJiaitany bzw. MPIJTestall
und MPI_Testany), auf alle Operationen oder eine beliebige Operation zu warten bzw. zu testen, ob diese abgeschlossen sind bzw. ist. So kann man zum Beispiel mit call MPI_IRECV( ..., requests(l), ierr ) call MPI_IRECV( ..., requests(2), ierr ) call MPI_WAITALL( 2, requests, status, ierr )
zwei nichtblockierende Empfangsoperationen starten und auf deren Ausführungsende warten. Die Variable status muss ein Feld mit zwei Objekten vom Typ MPI_status sein, das mit der Anweisung integer status(MPI_STATUS_SIZE,2)
deklariert werden kann. Mit diesen Routinen können wir unsere Datenaustausch-Routine exchngl unter Benutzung nichtblockierender Operationen umschreiben, so wie es in der Abbildung 4.16 zu sehen ist. Dieses Herangehen gestattet die gleichzeitige Ausführung beider Sende- und beider Empfangsoperationen. Prinzipiell kann man damit nahezu doppelt so schnell sein wie in der in Abbildung 4.12 dargestellten Version, obgleich nur wenige existierende Systeme dies unterstützen (wir dürfen nicht vergessen, dass MPI für aktuelle und zukünftige Message-Passing-Systeme entworfen wurde). Im nächsten Abschnitt werden wir den neuen Code ausprobieren und sehen, was geschieht. Wir wollen noch darauf hinweisen, dass wir weitere Änderungen an unserem Programm vornehmen müssen, um Berechnung und Kommunikation zu überlagern. Dazu ist das sweep-Programm so anzupassen, dass wir in der Zeit, in der wir auf neue, ankommende Daten warten, andere Arbeiten erledigen können. Im Abschnitt 4.9 werden wir auf die nichtblockierende Kommunikation zurück kommen, wo wir das Uberlappen von Kommunikation und Berechnung behandeln.
4.5
Synchrones Senden und „sichere" Programme
Wie können wir sichern, dass ein Programm unabhängig von Puffermechanismen ist? Im Allgemeinen ist diese Frage äquivalent zu Turings Halteproblem und damit nicht beantwortbar. In vielen Spezialfällen ist es aber möglich zu zeigen, dass ein Programm mit einer beliebigen Puffergröße korrekt arbeitet, wenn es ohne Pufferung erfolgreich läuft. MPI stellt eine Methode bereit, mit der eine Nachricht so gesendet werden kann, dass die Sendeoperation nicht terminiert, bevor der Adressat das Empfangen der Nachricht beginnt (also so wie bei MPI_Send, das nicht beendet wird, bevor der Sendepuffer vom Programmierer wieder neu verwendet werden kann). Diese Routine heißt MPI_Ssend,
92
4 M P I für Fortgeschrittene subroutine exchngl( a, nx, s, e, commld, & nbrbottom, nbrtop ) use mpi integer nx, s, e double precision a(0:nx+l,s-l:e+l) integer commld, nbrbottom, nbrtop integer status.array(MPI_STATUS_SIZE,4), ierr, req(4)
! call MPI_IRECV ( & a(l,s-1) , nx, MPI_DOUBLE_PRECISION, nbrbottom, 0, & commld, req(l), ierr ) call MPI_IRECV ( & a(l,e+l), nx, MPI_DOUBLE_PRECISION, nbrtop, 1, & commld, req(2), ierr ) call MPI_ISEND ( & a(l,e) , nx, MPI_DOUBLE_PRECISION, nbrtop, 0, & commld, req(3), ierr ) call MPI.ISEND ( & a(l,s), nx, MPI_DOUBLE_PRECISION, nbrbottom, 1, & commld, req(4), ierr ) call MPI_WAITALL ( 4, req, status_array, ierr ) return end
Abbildung 4·Η>: Routine für den zeilenweisen Austausch von Daten mit nichtblockierenden Operationen
ihre Parameter sind identisch zu denen von MPI_Send. Es ist zu beachten, dass es für eine MPI-Operation zulässig ist, MPI_Send mit MPI_Ssend zu implementieren. Deshalb sollte man mit Blick auf maximale Portierbarkeit sicherstellen, dass jedes MPI_Send durch ein MPI_Ssend ersetzt werden kann. Programme, die ohne Pufferung (oder solche mit MPI_Bsend, bei denen die Puffergröße vorgegeben wird) korrekt arbeiten, werden mitunter auch sicher genannt.
4.6
Mehr zur Skalierbarkeit
Da wir die Routinen zum Datenaustausch zwischen den Prozessen gekapselt haben, ist es nun recht einfach, das Programm mit verschiedenen Methoden zu binden und sie zu vergleichen. Tabelle 4.10 zeigt die Ergebnisse für einen speziellen Parallelrechner und eine bestimmte MPI-Implementierung. Zunächst sei darauf hingewiesen, dass in jedem Schleifendurchlauf MPI_Allreduce aufgerufen wird. Diese Routine kann einen beträchtlichen Zeitaufwand verursachen, der ζ. B. in Workstation-Netzwerken viele Millisekunden betragen kann. In vielen Anwendungen werden die tatsächlichen Kosten der MPI_Allreduce-Operation dadurch redu-
93
4.6 Mehr zur Skalierbarkeit Ρ 1 2 4 8
Blockierend Blocl· Send 5.38 2.77 1.58 1.15
16
1.18
32 64
1.94 3.73
Tabelle 4-10: Zeitmessungen
Geordnet Send 5.54 2.88
1.56 0.947 0.574 0.443 0.447
Sendrecv 5.54 2.91 1.57 0.931 0.534 0.451 0.391
Gepuffert Bsend 5.38 2.75 1.50 0.854 0.521 0.452 0.362
für Varianten der lD-Dekomposition
Nichtblock. Isend 5.40 2.77 1.51 0.849 0.545 0.397 0.391 des
Ρoisson-Problems
ziert, dass für eine gewisse Anzahl von Iterationsschritten auf die Berechnung der Differenz von aufeinander folgenden Iterationen verzichtet wird. Diese Arbeitsweise funktioniert, weil die Jacobi-Methode sehr langsam konvergiert. Eine kurze Betrachtung der Tabelle 4.10 offenbart eine Reihe interessanter Besonderheiten. Als erstes sieht man, dass der Zeitbedarf der Methoden, bis auf die mit blockierendem Senden, etwa gleich ist. Im Fall des blockierenden Sendens wird die fehlende Parallelität in der Kommunikation sichtbar. So dauert die Berechnung mit 32 Prozessen hier sogar länger als mit vier Prozessen. Ernster zu nehmen ist die schwache Leistung der anderen Methoden. Mit 64 Prozessen läuft das Programm nur etwa 14 mal schneller als mit einem Prozess, d. h. mit einer Effizienz von rund 20%. Um die Leistungsfähigkeit dieser Methoden zu verstehen, führen wir eine einfache Skalier bar keitsanalyse durch, ähnlich der im Kapitel 3. Wir brauchen ein etwas verfeinertes Modell für die Kommunikationskosten gegenüber dem im Kapitel 3 verwendeten. Dort setzten wir Tcomm als Zeit für das Senden eines Wortes an. Wir werden Tcomm durch s + rn als Zeit für das Senden von η Bytes ersetzen; für große η gilt dann T c o m m « r*n. Hierbei steht s für die Latenz oder Startup-Zeit, die man sich als die Zeit zum Senden einer Nachricht ohne Daten, nur mit Etikett und Quelle, vorstellen kann, r bezeichnet die Zeit für das Senden eines Bytes und ist gegeben als der reziproke Wert der Bandbreite (bzw. Ubertragungsgeschwindigkeit). Für eine Bandbreite einer Verbindung von zum Beispiel 100 M B / s ist r = l/(100MB/sec) = 10" 8 s/Byte. Anhand dieser Formel können wir leicht sehen, dass die Routine exchngld in Abbildung 4.12 eine Zeit von annähernd 2(s + rn) benötigt, wobei η = 8 nx gilt (für Werte doppelter Genauigkeit mit 8 Byte Länge). Angenommen, in einer zweidimensionalen Dekomposition seien px Prozesse in x-Richtung und py Prozesse in y-Richtung vereinbart, wobei jedem Prozess die gleiche Anzahl an Gitterpunkten zugeordnet sei. Dann gilt für die Kommunikationszeit Tc mit Ausnahme der Prozesse an den Rändern des Gitters
94
4 MPI für Fortgeschrittene
Für den Fall px = py = y/p führt dies zu der vereinfachten Form
Tc = 4
s +
^
η 1 r —
jVj
Abbildung 4.17 zeigt die geschätzte Leistung der 1D- und 2D-Zerlegung des Gebietes unter diesen Voraussetzungen. Die Situation ist noch extremer im 3D-Fall, wie in Abbildung 4.18 zu sehen ist. Wir weisen darauf hin, dass dieses Modell für eine kleine Prozessanzahl den Eindruck erweckt, dass die lD-Dekomposition besser als die 2DDekomposition ist. Hier muss man Sorgfalt walten lassen, da unsere Analyse im 2DFall nur für ρ > 9 gut ist, denn für ρ < 9 gibt es keine Prozesse mit vier Nachbarn. Weitere Details zur Kommunikationsanalyse in parallelen Programmen für partielle Differentialgleichungen findet man in [GS90, Gro87], ein Beispiel zur Anwendung dieser Analysetechnik bei einer großen Anwendung ist in [FGS92] zu finden.
Processors
Abbildung J^.ll: Geschätzte Zeit für eine 1D (durchgehende Dekomposition des 2D Poisson-Problems
Linie) und SD (gestrichelte
Linie)
4.7 Jacobi-Verfahren mit 2D-Dekomposition
95
Processors
Abbildung 4-18: Geschätzte Zeit für eine 1D (gestrichelte Linie) und 3D (durchgehende Linie) Dekomposition des 3D Poisson-Problems
4.7
Jacobi-Verfahren mit 2D-Dekomposition
Die Abbildungen 4.17 und 4.18 und unsere Skalierbarkeitsanalyse legen uns nahe, unser Programm so umzuschreiben, dass höherdimensionale Zerlegungen verwendet werden. Zum Glück läßt M P I das in sehr einfacher Weise zu. Wir werden zeigen, wie einige wenige Veränderungen am Programm von einer eindimensionalen zu einer zweidimensionalen Dekomposition führen. Zuerst lassen wir M P I die Zerlegung des Gebietes mit MPI_Cart_create berechnen:
isperiodic(l) = .false. isperiodic(2) = .false, reorder = .true. call MPI_CART_CREATE( MPI_COMM_WORLD, 2, dims, isperiodic, & reorder, comm2d, ierr )
Man vergleiche diese Anweisungen mit denen der 1D-Version in Abschnitt 4.2.
96
4 M P I für Fortgeschrittene
Als nächstes bestimmen wir die linken und rechten sowie die oberen und unteren Nachbarn: call MPI_CART_SHIFT( comm2d, 0, call MPI_CART_SHIFT( comm2d, 1,
1, nbrleft, nbrright, ierr ) 1, nbrbottom, nbrtop, ierr )
Den Code der Sweep-Routine ändern wir zu integer i, j, η double precision u(sx-l:ex+l,sy-l:ey+l), & unew(sx-l:ex+1,sy-1:ey+l) do 10 j=sy+l, ey-1 do 10 i=sx+l, ex-1 unew(i,j) = & 0.25*(u(i-l,j)+u(i,j+l)+u(i,j-l)+u(i+l,j) - & h * h * f(i,j)) 10 continue Die letzte anzupassende Routine ist die zum Datenaustausch (exchngld in den 1DBeispielen). Das ist etwas schwieriger, weil die nach oben und unten zu sendenden Daten zusammenhängend im Speicher liegen, die nach links und rechts zu sendenden hingegen nicht.
4.8
Ein erweiterter Μ PI-Datentyp
Eines der neuen Merkmale von M P I ist die Benutzung eines zu jeder Nachricht gehörenden Datentyps. Die Festlegung der Länge einer Nachricht über die Anzahl der Elemente eines bestimmten Datentyps und nicht über die Anzahl der Bytes ermöglicht eine bessere Portierbarkeit der Programme, da die Länge der Datentypen von System zu System variieren kann. So kann M P I auch zwischen Maschinenformaten übersetzen. Bisher bestanden unsere Nachrichten jeweils aus einem zusammenhängenden Speicherbereich. Deshalb waren solche einfachen Datentypen wie MPI_D0UBLE_PRECISI0N und MPI-INTEGER in Verbindung mit der Anzahl der Datenelemente ausreichend, um unsere Nachrichten zu beschreiben. In diesem Abschnitt führen wir die abgeleiteten Datentypen (oder erweiterten Datentypen) von M P I ein, die uns die Spezifikation nicht zusammenhängender Speicherbereiche gestatten, so z.B. die Zeile eines spaltenweise abgespeicherten Felds (oder, wie in unserem Fall, eine Spalte eines zeilenweise abgelegten Felds). Dies ist eine häufig auftretende Situation. M P I stellt einen Mechanismus bereit, mit dem Datenanordnungen dieser Art beschreibbar sind. Wir beginnen mit der Definition eines neuen Datentyps, der eine Gruppe von Elementen beschreibt, die im Speicher mit einem konstanten Abstand (ein konstanter Schritt, engl.: stride) voneinander getrennt
97
4.8 Ein erweiterter MPI-Datentyp sind. Wir realisieren dies mit MPI_Type_vector: call MPI_TYPE_VECTOR( ey - sy + 1, 1, ex - sx + 3, & MPI_DOUBLE_PRECISION, stridetype, ierr ) call MPI_TYPE_COMMIT( stridetype, ierr )
Die Argumente von MPI_Type_vector beschreiben einen Block, der aus einer Anzahl von (zusammenhängenden) Kopien des Eingabedatentyps (angegeben durch das vierte Argument) besteht. Das erste Argument gibt die Anzahl der Blöcke an, das zweite die Anzahl der Elemente (ist oft gleich eins) des alten Datentyps in jedem Block. Im dritten Parameter wird der Schritt übergeben, der den Abstand zwischen aufeinander folgenden Elementen bezüglich des Eingabedatentyps angibt. Der alte Datentyp ist als viertes Argument angegeben. Im fünften Parameter steht der neue, abgeleitete Datentyp. Im oben angegebenen Beispiel gibt es ein Element doppelter Genauigkeit je Block; die Werte liegen jeweils ex + 1 - (sx - 1) + l = e x - s x + 3 voneinander entfernt, ihre Anzahl ist ey - sy + 1, da nur die inneren Werte benötigt werden. Abbildung 4.19 veranschaulicht einen MPI-Vektordatentyp.
29
30
31
32
33
34
35
22
23
24
25
26
27
28
15
16
17
18
19
20
21
8
9
10
11
12
13
14
1
2
3
4
5
6
7
MP I _TYPE_VECTOR ( 5, 1, 7, MPI_DDUBLE_PRECISION, newtype, ierr ) Abbildung 4-19: Ein in konstanten Schritten nicht zusammenhängender („strided") Datenbereich (schattiert) und seine Beschreibung in MPI. Die Zahlen bezeichnen aufeinander folgende Speicherplätze Zu beachten ist, dass wir dem System den neuen Datentyp, den wir mit MPI_Type_vector erzeugt haben, danach mit MPI_Type-commit übergeben. Diese Routine übernimmt den neu konstruierten Datentyp und gibt dem System die Gelegenheit, Optimierungen jeglicher Art vorzunehmen. Alle vom Anwender erzeugten Datentypen müssen dem System übergeben werden, bevor sie benutzt werden können. Mit dieser Definition eines neuen Datentyps unterscheiden sich die Anweisungen zum Senden einer Zeile und einer Spalte nur in dem Argument für den Datentyp voneinander. Abbildung 4.20 zeigt die endgültige Fassung der Routine exchng2d.
98
j !
4 M P I für Fortgeschrittene subroutine exchng2( a, sx, ex, sy, ey, & comm2d, stridetype, & nbrleft, nbrright, nbrtop, nbrbottom use mpi integer sx, ex, sy, ey, stridetype double precision a(sx-l:ex+l, sy-l:ey+l) integer nbrleft, nbrright, nbrtop, nbrbottom, comm2d integer status(MPI_STATUS_SIZE), ierr, nx
)
nx = ex - sx + 1 These are just like the 1-d versions, except for less data call MPI_SENDRECV( a(sx,ey), nx, MPI_DOUBLE_PRECISION, & nbrtop, 0, & a(sx,sy-1), nx, MPI_D0UBLE_PRECISI0N, & nbrbottom, 0, comm2d, status, ierr ) call MPI_SENDRECV( a(sx,sy), nx, MPI_D0UBLE_PRECISI0N, & nbrbottom, 1, & a(sx,ey+l), nx, MPI_D0UBLE_PRECISI0N, & nbrtop, 1, comm2d, status, ierr )
! This uses the vector datatype stridetype call MPI_SENDRECV( a(ex,sy), 1, stridetype, nbrright, 0, & a(sx-l,sy), 1, stridetype, nbrleft, 0, & comm2d, status, ierr ) call MPI_SENDRECV( a(sx,sy), 1, stridetype, nbrleft, 1, & a(ex+l,sy), 1, stridetype, nbrright, 1, & comm2d, status, ierr ) return end Abbildung
4-20: Zweidimensionaler
Datenaustausch
mit
send-receive
M P I _ T Y P E _ V E C T O R ( c o u n t , blocklength, stride, oldtype, newtype, ierror) integer count, blocklength, stride, oldtype, newtype, ierror M P I _ T Y P E _ C O M M I T ( d a t a t y p e , ierror) integer datatype, ierror M P I _ T Y P E _ F R E E ( d a t a t y p e , ierror) integer datatype, ierror
Tabelle 4.11: Fortran-Signaturen
für elementare Μ PI-Datentyproutinen
Sobald ein D a t e n t y p nicht m e h r benötigt wird, sollte er mit MPI.Type J r e e frei gegeb e n werden. Die Variable des D a t e n t y p s wird d u r c h MPI.Typejfree auf MPI_TYPE_NULL gesetzt. Die S i g n a t u r e n f ü r die e b e n beschriebenen R o u t i n e n sind in den Tabellen 4.11, 4.12 u n d 4.13 zusammengestellt.
4.9 Überlagern von Kommunikation und Berechnung
99
int MPI_Type_vector(int count, int blocklength, int stride, MPLDatatype oldtype, MPLDatatype *newtype) int MPI_Type_commit(MPLDatatype ^datatype) int Μ Ρ l_Type_free( MPLDatatype *datatype)
Tabelle
4-12:
C-Signaturen
für elementare
MPI-Datentypfunktionen
Datatype MPI::Datatype::Create_vector (int count, int blocklength, int stride) const void MPI::Datatype::Commit() void Μ PI:: Datatype:: FreeQ
Tabelle
4-13:
C++-Signaturen
für elementare
MPI-Datentypmethoden
Eine alternative Definition für den „strided" Typ, die in Abschnitt 5.4 zu finden ist, erlaubt es dem Programmierer, eine beliebige Anzahl von Elementen desselben Datentyps zu senden.
4.9
Uberlagern von Kommunikation und Berechnung
Wegen der oft erheblichen Zeit für die Übertragung von Daten zwischen Prozessen kann es mitunter vorteilhaft sein, das Programm so zu gestalten, dass es sinnvolle Arbeit ausführt, während die Nachrichten „unterwegs" sind. Bisher haben wir nichtblockierende Operationen angewendet, um Deadlocks bei der Kommunikation zu vermeiden. Nun beschreiben wir einige Details zu einem Programmaufbau, mit dem Berechnungen und Kommunikationen simultan ablaufen können. In der Jacobi-Methode können die Werte von unew an den Punkten des Gitters, die bezüglich eines Prozesses innere Punkte sind, ohne Daten von anderen Prozessen berechnet werden — Abbildung 4.21 illustriert dies. Wir können unsere Berechnungsaufgabe wie folgt anordnen: (1) Feststellen, wo Daten anderer Prozesse zu empfangen sind; (2) Beginnen mit dem Senden von Daten an andere Prozesse; (3) Ausführen der Berechnungen, die nur von lokalen Daten abhängen; (4) Empfangen der Daten von anderen Prozessen und Ausführen der Berechnungen, die von diesen abhängen. Das Aufteilen des Programms in diese vier Schritte vergrößert zwar den Quell text, aber alles kann leicht aus unserem bisherigen Quellcode abgeleitet werden. Die Anweisungen für Schritt 3 zum Beispiel sind in Abbildung 4.22 zu sehen.
100
4 MPI für Fortgeschrittene
Abbildung 4·%1·' Die grauen Punkte stellen die Gitterpunkte dar, an denen die Berechnung unabhängig von Daten anderer Prozesse ist. Das lokale Gebiet ist mit der durchgezogenen Linie gekennzeichnet, das Gebiet mit den zugehörigen virtuellen Punkten (ghost points) mit der unterbrochenen Linie
integer i, j , η double p r e c i s i o n u ( s x - l : e x + 1 , s y - 1 : e y + 1 ) , & unew(sx-l:ex+1,sy-1:ey+l) do 10 j = s y + l , e y - 1 do 10 i = s x + l , e x - 1 unew(i,j) = & 0.25*(u(i-l,j)+u(i,j+l)+u(i,j-l)+u(i+l,j) - & h * h * f(i,j)) 10 c o n t i n u e Abbildung
J^.22: Code für die Berechnungen
der Jacobi-Iteration,
die nur lokale Daten
benöti-
Hierbei ist lediglich der Teil des Gebietes auszuwählen, der mindestens einen Punkt von den Rändern des lokalen Gebietes entfernt ist, um sicher zu stellen, dass alle Daten lokal verfügbar sind. Die Kommunikationsroutinen ändern sich in ähnlicher Weise. Die Aufrufe werden in zwei Teile getrennt, zwischen denen der Hauptteil der Berechnungen ausgeführt wird. Der erste Teil beginnt sowohl mit den nichtblockierenden Sende- als auch den nicht-
4.10 Mehr zur Laufzeitmessung in Programmen
101
blockierenden Empfangsoperationen: call MPI_IRECV( ..., requests(l), ierr ) call MPI_ISEND( ..., requests(5), ierr )
Danach folgt dann 4 do 100 k=l,8 call MPI_WAITANY( 8, requests, idx, status, ierr ) ! Use tag to determine which edge goto (1,2,3,4,100,100,100,100), status(MPI_TAG) I do 11 j=sy,ey II unew(si,j) = ... goto 100 2 do 21 j=sy,ey unew(ei,j) 21 goto 100 3 do 31, i=sx,ex 31 unew(i,ej) goto 100 4 do 41 i=sx,ex 41 unew(i,sj) goto 100 100 continue
Hierbei sind requests (1) bis requests (4) Handles in Bezug auf Empfangsoperationen und requests (5) bis requests (8) Handles in Bezug auf Sendeoperationen.
4.10
Mehr zur Laufzeitmessung in Programmen
Unser einfacher Programmtext ist auch für zeitabhängige partielle Differentialgleichungen geeignet. Viele Diskretisierungen für diese erfordern lediglich Daten, die auch unsere Jacobi-Iteration benötigt. Der Unterschied besteht nur darin, dass sich die sweepRoutinen ändern und die Operation MPI_Allreduce für den Konvergenztest nicht mehr gebraucht wird. Wenn für dieses Programm Zeitmessungen vorzunehmen sind, bringt diese Anwendung jedoch einen neuen Aspekt hervor: Wie können wir wissen, dass jeder Prozess seine Arbeit beendet hat, wenn wir MPI_Wtime aufrufen? Wir können die Routine MPI_Barrier aufrufen, um sicher zu sein, dass jeder Prozess seine Berechnungen beendet hat. Eine Barrier-Operation ist eine spezielle kollektive Operation, die einen Prozess erst weiter arbeiten lässt, wenn alle Prozesse des Kommunikators die Routine MPI_Barrier aufgerufen haben.
4
Für C-Programmierer: Die Konstruktion goto ( . . . ) , v a r i a b l e ist ähnlich zu s v i t c h ( v a r i a b l e ) in C mit dem Unterschied, dass die Werte Marken sind und die Variable im Bereich von 1 bis Anzahl der Marken liegt.
102
4 M P I für Fortgeschrittene call MPI_Barrier( MPI_CGMM_WORLD, ierr ) tl = MPI.WtimeO
call MPI_Barrier( MPI_C0MM_W0RLD, ierr ) total_time = MPI_Wtime() - tl
Diese Barrier-Operation stellt sicher, dass alle Prozesse die gleiche Stelle im Programm erreicht haben und bereit sind, ihre Arbeit fortzusetzen. Viele der kollektiven Operationen (z.B. MPI_Allreduce) besitzen die gleiche Eigenschaft, d . h . kein Prozess kann die Operation verlassen, bevor alle Prozesse die Operation gestartet haben. Das ist allerdings für Operationen wie MPI_Reduce nicht zutreffend, bei denen nur der Wurzelprozess auf alle anderen Prozesse warten muss, um in den Aufruf von MPI_Reduce einzutreten. Die Signaturen für MPI_Barrier werden in den Tabellen 4.14, 4.15 und 4.16 vorgestellt. MPI_BARRIER(comm, ierr) integer comm, ierr Tabelle 4·14: Fortran-Signatur der Barrier-Routine int MPI_Barrier(MPI_Comm comm)
Tabelle 4-15: C-Signatur der Barrier-Funktion void MPI::lntracomm::Barrier(() const
Tabelle 4-16: C++-Signatur der Barrier-Methode Die obige einfache Anweisungsfolge zur Zeitmessung ist in Wirklichkeit jedoch oft zu einfach. Es gibt eine Menge an Einflüssen, durch die mit diesem Code irreführende Ergebnisse entstehen können. Ein wohl bekanntes Problem ist das des Cache-Speichers: wenn für die Arbeit erforderliche Daten in den Cache geschrieben werden, weil zum ersten Mal auf sie zugegriffen wird, wird die Zeit oft nicht von der eigentlichen Arbeit mit den Daten, sondern vom Laden der Daten in den Cache dominiert. Ein weniger gut bekanntes, aber ähnliches Problem ist das demand-loading von Code: in vielen Systemen werden die Maschineninstruktionen erst von der Festplatte in den Hauptspeicher geladen, wenn sie zum ersten Mal referenziert werden. Da dies den Zugriff auf eine Festplatte erfordert (oder sogar auf einen Datenserver), kann dafür eine signifikante Zeit gebraucht werden. Deswegen ist es besser, eine Schleife der folgenden Art einzusetzen:
4.11 Drei Dimensionen
10
103
do 10 i=l,2 call MPI_Barrier( MPI_C0MM_W0RLD, ierr ) tl = MPI_Wtime()
call MPI_Barrier( MPI_C0MM_W0RLD, ierr ) total_time = MPI_Wtime() - tl continue
Dieser einfache Code durchläuft die Zeitermittlung doppelt und verwirft die Ergebnisse der ersten Messung. Anspruchsvollere Untersuchungsmethoden werden in [GL99b] vorgestellt. Das Kapitel zur Leistungsmessung in [BGMS97] enthält ebenfalls wertvolle Anregungen für genaue Zeittests.
4.11
Drei Dimensionen
Bisher haben wir uns auf das zweidimensionale Problem beschränkt. Wir wollen jetzt den dreidimensionalen Fall betrachten und eine Implementierung in C vorstellen. Schon ein relativ kleines 3D-Problem mit 100 Gitterpunkten an jedem Rand hat insgesamt 10 6 Gitterpunkte; 3D-Simulationen mit ein oder zwei Größenordnungen mehr an Gitterpunkten sind durchaus typisch. Diese Probleme sind folglich ideale Kandidaten für eine parallele Verarbeitung. Die Flexibilität von M P I macht uns die Verallgemeinerung unserer bisherigen Lösung des 2D-Problems sehr leicht. Eine Schwierigkeit bei der Entwicklung eines Quellcodes für den dreidimensionalen Fall ist die Aufteilung des Gebietes auf die Prozesse. Aus den Ergebnissen der Skalierbarkeitsanalyse dieses Kapitels empfiehlt sich eine dreidimensionale virtuelle Topologie. M P I verfügt über die Routine MPI_Dims_create, die den Aufbau einer virtuellen kartesischen Topologie beliebiger Dimension unterstützt. Diese Routine erwartet als Eingabe die Gesamtanzahl der Prozesse und die Dimension des virtuellen Gitters und liefert ein Feld mit den Anzahlen der Prozesse für jede kartesische Richtung. Diese Werte können als Argumente für den Parameter dims der Routine MPI_Cart_create eingesetzt werden. Die Signaturen werden in den Tabellen 4.17, 4.18 und 4.19 gezeigt. M P L D I M S _ C R E A T E ( n n o d e s , ndims, dims, ierr) integer nnodes, ndims, dims(*), ierr
Tabelle 4-17: Fortran-Signatur von MPIJDIMS.CREATE
int MPI_Dims_create(int nnodes, int ndims, int *dims)
Tabelle 4.18: C-Signatur von MPI_DIMS_CREATE
104
4 MPI für Fortgeschrittene
void MPI::Compute_dims(int nnodes, int ndims, int dimsfl)
Tabelle 4.19: C++-Signatur von MPI_DIMS-CREATE
4.12
Typische Fehler und Missverständnisse
Wie im vorigen Kapitel wollen wir einige typische „Fallen", die im Zusammenhang mit dem in diesem Kapitel vorgestellten Stoff stehen, aufzeigen. Nicht sicher geschriebene P r o g r a m m e . Im Abschnitt 4.5 haben wir das Konzept der „sicheren" Programme vorgestellt, das dann funktioniert, wenn alle blockierenden Sendeoperationen durch synchrone ersetzt werden. Ein Programm der Gestalt Prozess 0 MPI_Send zu Prozess 1 MPI J l e c v von Prozess 1
Prozess 1 MPI_Send zu Prozess 0 MPI_Recv von Prozess 0
ist schon an sich unsicher: Es wird gewiss für irgendeinen Wert der Nachrichtenlänge versagen, wenn es nicht genügend Systempuffer gibt, um beide Nachrichten aus den Sendepuffern zu kopieren, bevor sie in die Empfangspuffer kopiert werden. Viele Implementierungen verfügen über eine großzügige Pufferung, durch die sich der Programmierer fälschlicherweise in Sicherheit wähnt. Wenn die Nachrichten größer werden, kann die Puffergrenze erreicht werden und das Programm fehl schlagen. Wenn Nachrichten zwischen Prozessen auszutauschen sind, sollte man lieber ein sicheres Programm schreiben, indem man eine der in diesem Kapitel erläuterten Techniken anwendet: • Uberlagern von Senden und Empfangen, sodass ein Prozess sendet während der andere empfängt. • Anwendung von MPI_Sendrecv. • Anlegen eigener Puffer mit MPI_Buf f er_attach. • Anwendung der nichtblockierenden Operationen MPIJsend und MPI_Irecv. Die letzte Methode ist die allgemeinste und ist am weitesten verbreitet. Sich auf das Uberlagern von Kommunikation und Berechnung verlassen. Die nichtblockierenden Operationen sind für die Entwicklung sicherer Programme bedeutender als für die Verbesserung der Leistung durch Überlagerung von Kommunikation und Berechnung. Wir haben zwar im Abschnitt 4.9 beschrieben, wie wir unser Programm strukturieren müssen, um solche Überlappungen zu ermöglichen, doch können viele Implementierungen dies ohne spezielle Hardware, z.B. Kommunikationsprozessoren, nicht ausnutzen. Der Einsatz nichtblockierender Operationen erlaubt einer Implementierung eine Leistungsverbesserung durch gleichzeitiges Kommunizieren und Rechnen, aber es ist für die Implementierung nicht zwingend, dies so zu realisieren. Ob dieses Überlagern möglich ist, wird von der Hardwareumgebung bestimmt. Wenn also der Übergang zu nichtblockierenden Operationen die Leistung nicht verbessert, muss das nicht der Fehler des Programmierers sein.
4.13 Simulation der Wirbelevolutionin supraleitenden Materialien
4.13
105
Simulation der Wirbelevolution in supraleitenden Materialien
Als ein Beispiel einer Anwendung, bei der die in diesem Kapitel beschriebenen Techniken eingesetzt werden, wollen wir kurz auf ein Modell für Supraleitung eingehen. Die zeitabhängige Ginzburg-Landau-Gleichung (TDGL, time-dependent Ginzburg-Landau) kann für eine numerische Simulation von Wirbeldynamik und Phasenübergang in Typ-2-Supraleitern dienen. Die TDGL-Gleichung gibt eine phänomenologische Beschreibung der makroskopischen Eigenschaften von Hochtemperatursupraleitern und wurde außerordentlich erfolgreich zur Deutung experimenteller Ergebnisse zum Phasenübergang eingesetzt. Sie ist eine partielle Differentialgleichung für komplexwertige Ordnungsparameter mit stochastischen Koeffizienten. Dieses Modell nutzt eine Felddarstellung von Wirbeln, beschrieben durch eine dreidimensionale zeitabhängige GinzburgLandau-Gleichung. Eine Gruppe am Argonne National Laboratory [GGG + 93] hat ein dreidimensionales TDGL-Programm parallelisiert, indem die Strukturdaten des Supraleiters auf die Prozesse verteilt wurden. Der Speicher jedes Prozesses enthält ein „Cubelet" (ein Volumenanteil) der originalen globalen Datenstruktur. Jeder Prozess ist dann für die Zeitintegration über seinen Anteil des Supraleiters zuständig. Die Integration über die Zeit wird mit dem Euler Vorwärtsverfahren gelöst. Zur Approximation der Ableitungen werden finite Differenzen unter Verwendung einer 27-Punkte-Gitterschablone (engl.: box (27point)
stencil)
benutzt.
Mit diesem Programm konnten die Wissenschaftler verschiedene Fragen zum Verhalten von Hochtemperatursupraleitern beantworten. Die Berechnungen erforderten eine sehr hohe Genauigkeit. Der Aktualisierungsschritt für jede Zelle benötigt Werte von Nachbarzellen. Wegen der Verteilung werden von einigen Zellen eines Prozesses Werte von Zellen benötigt, die im Speicher eines anderen Prozesses liegen. Zur Übertragung dieser Werte werden Routinen benutzt, die den Datenaustauschroutinen für das Poisson-Problem ähnlich sind.
5
Weitergehende Details zum Message-Passing mit MPI
Dieses Kapitel behandelt einige der anspruchsvolleren Eigenschaften des MPI-Standards, die in unseren bisherigen Betrachtungen noch nicht auftauchten. Wir geben auch eine vollständigere Beschreibung einiger Techniken, die bereits kurz angesprochen wurden. Gleichzeitig wollen wir auch einige interessante Beispielprogramme vorstellen.
5.1
MPI-Datentypen
Eine der weniger üblichen Eigenschaften in MPI ist die Einführung eines Datentypparameters für alle zu sendenden und zu empfangenen Nachrichten. In den vorangegangenen Kapiteln haben wir uns hauptsächlich auf die elementaren Datentypen gestützt, die denen der verwendeten Programmiersprache entsprechen — Integer, Gleitkommazahlen usw., sowie Felder von diesen. In diesem Abschnitt besprechen wir den kompletten Satz an Datentypen und beschreiben hierbei die Leistungsfähigkeit der abgeleiteten Datentypen in MPI vollständig. Die verwendeten Beispiele, das N-Körper-Problem und Mandelbrotberechnungen, bieten uns ferner die Gelegenheit, weitere Routinen aus dem Graphikteil der MPE-Bibliothek anzuwenden.
5.1.1
Basisdatentypen und Konzepte
MPI verfügt über eine umfangreiche Menge an vordefinierten Datentypen. Sie umfassen alle grundlegenden Datentypen von C (Tabelle 5.1) und Fortran (Tabelle 5.2). In beiden Listen sind zwei in MPI spezifische Datentypen enthalten: MPIJ3YTE und MPI_PACKED. MPI_BYTE bezeichnet ein Byte, das als aus acht Binärzeichen (binary digit) bestehend definiert ist. Viele C- und Fortran-Programmierer mögen sich wundern, warum dies gebraucht wird, wenn doch MPI.CHAR bzw. MP I _CH ARACTER zur Verfügung stehen. Hierfür gibt es zwei Gründe. Einerseits müssen char und character nicht durch jeweils ein Byte realisiert werden, auch wenn dies in vielen Implementierungen so erfolgt. So könnte zum Beispiel eine japanische Version von C den Datentyp char mit 16 Bits realisieren. Der zweite Grund besteht darin, dass Maschinen in einem heterogenen Netzwerk über verschiedene Zeichensätze verfügen können. So benutzt ein System mit ASCII-Darstellung zum Beispiel andere Bits für das Zeichen Α als ein System mit EBCDIC-Darstellung. Die Routine MPI_PACKED wird in Abschnitt 5.2.3 beschrieben. In den verschiedenen Sprachen gibt es Datentypen, die nicht zum Standard der Sprache gehören, aber gebräuchliche Erweiterungen sind. MPI-Datentypen, die solchen Typen entsprechen, sind in den Tabellen 5.1 und 5.2 nicht enthalten. Herstellerspezifische Im-
108
5 Weitergehende Details zum Message-Passing mit MPI MPI-Datentyp MPIJ3YTE MPI_CHAR MPI-DOUBLE MPI_FL0AT MPI-INT MPI_L0NG MPI_L0NG_L0NG_INT MPI_L0NG_D0UBLE MPI-PACKED MPI.SHORT MP I _UNS IGNED _CH AR MPI-UNSIGNED MPI_UNSIGNED_LONG MP I _UNS I GNED-SHORT
Tabelle
5.1:
(Vordefinierte)
Basisdatentypen
C-Datentyp signed char double float int long long long long double short unsigned unsigned unsigned unsigned
char int long short
in MPI für C
plementierungen von Fortran zum Beispiel verfügen oft über zusätzliche Datentypen. Der gebräuchlichste ist DOUBLE COMPLEX, der einem COMPLEX doppelter Genauigkeit entspricht, aber eigentlich nicht in Fortran 77 enthalten ist. Der MPI-Standard könnte Datentypen, die nicht zum Standard der Sprache gehören, kaum durchsetzen. Aus diesem Grund sind solche Datentypen auch nicht für MPI-Implementierungen vorgeschrieben. Viele Implementierungen von Fortran unterstützen allerdings DOUBLE COMPLEX zusammen mit Typen wie REAL*8 (8-Byte Gleitkommazahlen) und INTEGER*2 (2-Byte Integer). MPI definiert unter anderem die entsprechenden Datentypen MPI_D0UBLE_C0MPLEX, MPI_REAL8 und MPI_INTEGER2 als optionale Datentypen — eine MPI-Implementierung muss sie nicht definieren, sollte aber, falls sie die Typen doch vorsieht, diese Namen benutzen. Zusätzlich führt MPI-2 einige neue grundlegende vordefinierte Datentypen ein. Diese sind in Tabelle 5.3 zusammengestellt. So können in MPI-2 die Typen MP I _S I GNED _CH AR und MPI_UNSIGNED_CHAR in Reduktionsoperationen (z.B. in MPI_Allreduce) benutzt werden, in denen sie als ein Integer (mit oder ohne Vorzeichen) behandelt werden. Wie wir bereits in Abschnitt 4.8 gesehen haben, ist es häufig von Vorteil, zusätzliche Datentypen zu definieren. MPI sieht beliebige Datentypen vor — der verbleibende Teil dieses Abschnitts zeigt, wie man in MPI einen allgemeinen Datentyp beschreibt. Ein Datentyp in MPI ist ein Objekt, bestehend aus einer Folge von Basisdatentypen (Tabellen 5.1 und 5.2) und jeweils einer Verschiebung (engl.: displacement), angegeben in Byte, für jeden dieser Datentypen. Diese Verschiebungen werden relativ zu dem Puffer angegeben, den der Datentyp beschreibt (siehe Abschnitt 3.6). Wir wollen einen Datentyp als eine Folge von Paaren aus Basisdatentypen und Verschiebungen darstellen, so wie es in Gleichung (5.1) gezeigt wird; in MPI wird diese Folge Typtabelle (engl.:
109
5.1 MPI-Datentypen MPI-Datentyp MPI J3YTE MPI-CHARACTER MPI_C0MPLEX MPI-DOUBLE-PRECISION MPI-INTEGER MPI_L0GICAL MPI.PACKED MPI-REAL
Fortran-Datentyp CHARACTER COMPLEX DOUBLE PRECISION INTEGER LOGICAL REAL
Tabelle 5.2: (Vordefinierte) Basisdatentypen in MPI für Fortran MPI-Datentyp MPI-WCHAR MPI_SIGNED_CHAR MPI-UNSIGNED _L0NG_L0NG
C-Datentyp wchar_t signed char unsigned long long
Tabelle 5.3: Neue Datentypen für C
typemap) genannt. Typtabelle = { ( t y p e 0 , disp0),...,
(typen-i,dispn-i)}
(5.1)
Der Typ MPI-INT kann zum Beispiel als Typtabelle ( i n t , 0 ) repräsentiert werden. Die Typsignatur
eines Datentyps ist eine Liste der Basisdatentypen dieses Datentyps:
Typsignatur = {type0,...,
typen-1}
Mit der Typsignatur wird gesteuert, wie Datenelemente zu interpretieren sind, wenn Daten gesendet oder empfangen werden. Aus der Typsignatur erhält MPI die Information, wie die Bits eines Datenpuffers zu interpretieren sind. Die Verschiebungen geben M P I die Stellen an, wo die Bits zu finden sind (für das Senden) oder wohin sie gespeichert werden sollen (beim Empfangen). Um zu veranschaulichen, wie M P I benutzerdefinierte Datentypen konstruiert, müssen wir einige Bezeichnungen einführen. Ein MPI-Datentyp habe eine nach Gleichung (5.1) gegebene Typtabelle „Typemap". Damit können wir Z6(Typemap) = min(dispj) j u6(Typemap) = max(dispj + s i z e o f (typej)) + pad j ea;ieni(Typemap) = ub(Typemap) — /6(Typemap)
(5-2) (5.3) (5.4)
definieren. Ib ist die kleinste Verschiebung der Komponenten des Datentyps. Diese untere Schranke kann als Position des ersten durch den Datentyp beschriebenen Bytes
110
5 Weitergehende Details zum Message-Passing mit MPI
angesehen werden, ub ist die obere Schranke des Datentyps, die als die Stelle des letzten durch den Datentyp beschriebenen Bytes zu betrachten ist. Die Länge extent ist die Differenz zwischen beiden Schranken, die möglicherweise vergrößert werden muss, um die Ausrichtung an den Speicherzellen einzuhalten. Der Operator s i z e o f in Gleichung (5.3) ermittelt die Größe des Granddatentyps in Byte. Um die Rolle der Variable „päd" zu erläutern, müssen wir zunächst den Begriff der Daten-Ausrichtung (engl.: alignment) besprechen. Sowohl C als auch Fortran setzen voraus, dass die Grunddatentypen richtig ausgerichtet sind, das heißt, dass zum Beispiel ein Integer oder ein Wert doppelter Genauigkeit nur dort gespeichert wird, wo dies zulässig ist. Jede Implementierung dieser Sprachen definiert, was zulässig ist (es gibt natürlich bestimmte Beschränkungen). Eine der allgemein gültigsten Anforderungen einer Implementierung dieser Sprachen ist, dass die Adresse eines Elements ein Vielfaches der Länge dieses Elements, jeweils angegeben in Byte, ist. Ist zum Beispiel ein i n t vier Bytes lang, muss die Adresse eines i n t durch vier teilbar sein. Diese Bedingung schlägt sich in der Definition der Länge (extent) eines MPI-Datentyps nieder. Zur Erläuterung betrachten wir die Typtabelle {(int, 0), (char, 4)}
(5.5)
für einen Rechner, bei dem die Adressen von i n t ' s durch vier teilbar sein müssen. In dieser Typtabelle gilt lb = min(0,4) = 0 und ub = max(0 + 4 , 4 + 1 ) = 5. Der nächste int-Wert kann mit einer Verschiebung von acht hinter dem i n t der Typtabelle platziert werden. Deshalb ist die Länge dieser Typtabelle für diesen konkreten Rechner acht. Um die Länge eines Datentyps zu ermitteln, stellt MPI die Routine MPI_Type_extent bereit. Der erste Parameter ist der MPI-Datentyp, die Länge wird im zweiten Parameter zurückgegeben. In C ist der Typ des zweiten Parameters MPI J l i n t ; das ist ein IntegerTyp, der eine beliebige Adresse aufnehmen kann (in vielen, aber nicht allen Systemen ist dies ein i n t ) . Die Längen der Basisdatentypen (in den Tabellen 5.1 und 5.2) sind gleich der Anzahl ihrer Bytes. Die Größe eines Datentyps gibt die Anzahl der Bytes an, die die Daten beanspruchen. Sie ist durch MPI_Type_size bestimmbar. Der erste Parameter dieser Routine gibt den Datentyp an, die Größe wird im zweiten Parameter zurückgegeben. Der Unterschied zwischen Länge und Größe eines Datentyps kann mit der Typtabelle in Gleichung (5.5) veranschaulicht werden: die Größe beträgt fünf Bytes, die Länge dagegen (für einen Rechner mit einer Speicherausrichtung von vier Bytes für i n t ' s ) acht Bytes. Die Routinen MPI_Type_ub und MPI_Type_Ib zur Bestimmung der kleinsten bzw. größten Verschiebung komplettieren die Routinen zur Ermittlung der Eigenschaften eines MPIDatentyps. Die Signaturen der hier vorgestellten Datentyproutinen sind in den Tabellen 5.4, 5.5 und 5.6 zu finden. Die Routine MPI_Type_count war in MPI-1 definiert, wurde aber in MPI-1.1 wieder entfernt, da ihre Definition unklar war. Diese Funktion gab Informationen über einen abgeleiteten Datentyp; MPI-2 enthält leistungsstärkere und nützlichere Routinen, um Inhalt und Struktur eines Datentyps zu ermitteln.
5.1
111
MPI-Datentypen
int MPLType_contiguous(int count, MPLDatatype oldtype, MPLDatatype *newtype) int MPLType_extent(MPLDatatype datatype, MPLAint *extent) int Μ Ρ LTy pe_size( Μ PLDataty pe datatype, MPLAint *size) int MPLType_lb(MPLDatatype datatype, MPLAint *displacement) int MPI_Type_ub(MPLDatatype datatype, MPLAint ^displacement)
Tabelle 5.4'· C-Signaturen
der Funktionen zu
MPI-Datentypen
Datatype MPI::Datatype::Create_contiguous(int count) const Aint MPI::Datatype::Get_extent() const int MPI::Datatype::Get_size() const Aint MPI::Datatype::Get_lb() const Aint MPI::Datatype::Get_ub() const
Tabelle 5.5: C++-Signaturen
der Methoden zu
MPI-Datentypen
M P L T Y P E _ C O N T I G U O U S ( c o u n t , oldtype, newtype, ierror) integer count, oldtype, newtype, ierror MPLTYPE_EXTENT(datatype, extent, ierror) integer datatype, extent, ierror MPLTYPE_SIZE(datatype, size, ierror) integer datatype, size, ierror MPLTYPE_LB(datatype, displacement, ierror) integer datatype, displacement, ierror MPLTYPE_UB(datatype, displacement, ierror) integer datatype, displacement, ierror
Tabelle 5.6: Fortran-Signaturen
der Routinen zu
MPI-Datentypen
5 Weitergehende Details zum Message-Passing mit MPI
112
5.1.2
Abgeleitete Datentypen
Die Typtabelle ist eine vollständige und allgemeine Technik zur Beschreibung eines beliebigen Datentyps. Mit ihr zu arbeiten kann jedoch recht unbequem werden, vor allem wenn die Typtabelle eine große Anzahl an Einträgen enthält. MPI bietet aber eine Vielzahl an Möglichkeiten, Datentypen ohne die explizite Konstruktion einer Typtabelle zu erzeugen. Contiguous: Das ist der einfachste Konstruktionsweg. Hier wird ein neuer Datentyp aus count Kopien eines existierenden erzeugt. Die Verschiebungen werden aus der Länge des Ausgangstyps oldtype ermittelt. Vector: Diese leichte Verallgemeinerung des Typs contiguous gestattet gleichmäßige Lücken zwischen den Komponenten. Die Elemente werden durch Vielfache der Länge des Eingabedatentyps voneinander getrennt (siehe Abschnitt 4.8). Hvector: Dieser Typ ist ähnlich zu vector, nur sind die Elemente durch eine spezifische Anzahl Bytes voneinander getrennt. Indexed: In diesem Datentyp werden die Verschiebungen des Eingabedatentyps in einem Feld bereit gestellt; die Verschiebungen werden in Abhängigkeit von der Länge des Eingabedatentyps angegeben (siehe Abschnitt 5.2.3). Hindexed: Dieser Typ ist wie indexed, aber die Verschiebungen werden in Byte gemessen (siehe Abschnitt 5.2.4). Struct: Hiermit wird eine vollwertige, allgemeine Beschreibung ermöglicht. Wenn die Eingabeargumente aus den MPI-Basisdatentypen bestehen, ist die Eingabe gerade die Typtabelle (siehe Abschnitt 5.3). Wir werden die MPI-Funktionen zur Erzeugung dieser Datentypen erst später beschreiben, wenn wir sie benötigen. Den Datentyp contiguous wollen wir bereits jetzt vorstellen, da sich an diesem gut zeigen lässt, wie der Parameter count in MPI-Routinen bezüglich dieser abgeleiteten Datentypen angewendet wird. Die Routine MPI_Type_contiguous erstellt einen neuen Datentyp, indem sie count Kopien eines vorhandenen anlegt. Die Verschiebungen werden durch die Länge des Ausgangstyps oldtype festgelegt. Wenn zum Beispiel der ursprüngliche Datentyp (oldtype) die Typtabelle {(int, 0), (double, 8)}, hat, dann erzeugt MPI_Type_contiguous( 2, oldtype, fenewtype );
einen Datentyp newtype mit der Typtabelle {(int, 0), (double, 8), (int, 16), (double, 24)}.
5.2 Das N-Körper-Problem
113
Wird in einer MPI-Operation ein count-Argument benutzt, so ist es das Gleiche wie die Erzeugung eines zusammenhängenden Typs dieser Größe. Das bedeutet, dass MPI_Send( buffer, count, datatype, dest, tag, comm );
genau das Gleiche wie MPI_Type_contiguous( count, datatype, ftnewtype ); MPI_Type_commit( fenewtype ); MPI_Send( buffer, 1, newtype, dest, tag, comm ); MPI_Type_free( fcnewtype );
bedeutet bzw. bewirkt.
5.1.3
Was genau ist die Länge?
Die Länge eines Datentyps ist wahrscheinlich das Konzept von MPI, das am häufigsten missverstanden wird. Diese Länge wird in MPI-Kommunikationsroutinen benutzt, um festzustellen, wo das „nächste" zu sendende Datenelement zu finden oder das „nächste" zu empfangende abzuspeichern ist. So werden zum Beispiel mit den Anweisungen char *buffer; MPI_Send( buffer, η, datatype, ... );
dieselben Daten (aber in einer einzelnen Nachricht anstelle von η Nachrichten) wie durch char *buffer; MPI_Type_extent( datatype, &extent ); for (i=0; i MPI_Send( buffer + (i * extent), 1, datatype, ... );
gesendet. Um das Beispiel sehr einfach zu halten, haben wir buffer vom Typ char deklariert und vorausgesetzt, dass sizeof (char) gleich ein Byte ist. Hieran können wir erkennen, dass die Länge eines Datentyps nicht seine Größe ist, sondern vielmehr der Schritt (engl.: stride) eines Datentyps: die Distanz (in Byte), um vom Anfang einer Instanz des Datentyps zum Anfang einer anderen Instanz eines Datentyps zu springen (bzw. zu schreiten). Wir werden im Abschnitt 5.4 auf diesen Punkt zurück kommen und uns ansehen, wie die Länge zum Senden von Daten, die durch regelmäßige Lücken getrennt sind, benutzt wird.
5.2
Das N-Körper-Problem
In vielen Simulationen werden die Wechselwirkungen zwischen sehr vielen Teilchen oder Objekten berechnet. Wenn die auf ein Teilchen wirkende Kraft vollständig durch Addition der Kräfte zwischen diesem und jedem anderen Teilchen beschrieben wird und
114
5 Weitergehende Details zum Message-Passing mit MPI
die Kraft zwischen je zwei Teilchen entlang der Strecke zwischen ihnen wirkt, wird dies als ein N-Körper-Zentralkraftproblem (oft auch als N-Körper-Problem) bezeichnet. Ein derartiges Problem eignet sich gut für eine Parallelisierung, da es mit Ν Elementen (den Teilchen) beschrieben werden kann, jedoch einen Berechnungsaufwand von Ö(N2) (alle Teilchenpaare) besitzt. Das verspricht gute Speedups bei großen Eingaben, weil der Aufwand zur Kommunikation zwischen Prozessen klein gegenüber dem für die Berechnungen sein wird. In diesem Abschnitt wollen wir uns des N-Körper-Problems bedienen, um eine Reihe von Eigenschaften von MPI zu beschreiben, unter anderem neue kollektive Operationen, langlebige (persistente) Kommunikationsanforderungen und neue abgeleitete Datentypen. Vor der Implementierung eines N-Körper-Programms müssen wir zunächst entscheiden, wie die Teilchen auf die Prozesse aufzuteilen sind. Eine einfache Möglichkeit ist die gleichmäßige Verteilung der Teilchen auf die Prozesse. Haben wir zum Beispiel 1000 Teilchen und 10 Prozesse, geben wir die ersten 100 Teilchen an Prozess 0, die nächsten 100 Teilchen an Prozess 1 usw. Um die Krafteinwirkungen auf die Teilchen zu berechnen, muss jeder Prozess auf alle Teilchen der anderen Prozesse zugreifen. (Eine wesentliche Optimierung nutzt die Eigenschaft, dass die Kräfte gleich stark und entgegengesetzt wirken; das kann den Berechnungsaufwand um einen Faktor von zwei reduzieren. Wir werden diese Eigenschaft nicht einbeziehen, um die Lösung einfacher zu halten.) Zuerst definieren wir einen Datentyp für die Teilchen. Wir wollen annehmen, dass ein Teilchen durch die Struktur typedef struct { double x, y, z; double mass; ]- Particle;
definiert ist und die Teilchen in einem Feld gespeichert werden: Particle particles[MAX.PARTICLES];
Um diese Daten zu verschicken, könnten wir für jedes Teilchen einfach nur vier doubleWerte senden. Im Sinne von MPI ist es aber zweckmäßiger, einen Datentyp für ein Teilchen, bestehend aus vier double-Werten, zu erzeugen: MPI_Type_contiguous( 4, MPI_D0UBLE, &particletype ); MPI_Type_commit( ftparticletype );
(Eigentlich sollten wir diese Struktur besser mit MPI_Type_struct aufbauen, aber MPI_Type_contiguous funktioniert für diesen speziellen Fall in fast jedem System. Die Anwendung von MPI_Type_struct werden wir im Abschnitt 5.3 behandeln.)
115
5.2 Das Ν-Körper-Problem
5.2.1
Aufsammeln von Informationen
Am einfachsten wäre es, wenn alle Prozesse alle Teilchen untereinander austauschten und dann mit diesen rechneten. Bei einer derartigen Arbeitsweise hätten alle Prozesse Kopien aller Teilchen, würden aber nur die auf die lokalen Teilchen wirkenden Kräfte berechnen. 1 Jeder Prozess könnte zum Beispiel die Anweisungen Particle
*(particleloc[]);
MPI_Comm_size( MPI_C0MM_W0RLD, fesize ) ; f o r ( i = 0 ; i < s i z e ; i++) { MPI_Send( p a r t i c l e s , c o u n t , p a r t i c l e t y p e , i , MPI_C0MM_W0RLD ) ;
0,
> f o r ( i = 0 ; i < s i z e ; i++) { MPI_Recv( p a r t i c l e l o c [ i ] , MAX.PARTICLES, p a r t i c l e t y p e , i , 0, MPI_C0MM_W0RLD, fcstatus ) ;
} ausführen. (Aus Gründen, die erst später verständlich werden, ist das Senden und Empfangen von einem Prozess zu sich selbst absichtlich in diesem Programmtext enthalten.) In diesem Codestück stecken einige Probleme: er skaliert nicht (die Laufzeit des Codes ist proportional zur Anzahl der Prozesse), er kann verklemmen (da er erwartet, dass MPI_Send mit Pufferung arbeitet) und er erfordert die Berechnung von p a r t i c l e l o c [ i ] , bevor der Code genutzt werden kann. Wir könnten einfach die Techniken aus Kapitel 4 anwenden, um mit dem Deadlock- bzw. Pufferproblem fertig zu werden, doch die Behandlung der anderen Schwierigkeiten erfordert größere Sorgfalt. Zum Glück besitzt MPI Routinen, um diesen häufig vorkommenden Fall zu lösen. Wir werden zeigen, wie mit Hilfe der Routinen MPI_Allgather und MPI_Allgatherv Daten effizient zwischen Prozessen ausgetauscht werden können. Zuerst wollen wir uns mit dem Problem befassen, die Anzahl der Teilchen zu bestimmen, mit denen jeder Prozess arbeitet. Dazu speichert jeder Prozess in count die Anzahl seiner Teilchen. Wir wollen ein Feld c o u n t s anlegen, das in c o u n t s [ i ] die Anzahl der Teilchen des i-ten Prozesses enthält. Ein Weg hierfür ist das Aufsammeln (engl.: gather) aller dieser Werte in einem einzelnen Prozess, um sie anschließend mit MPI_Bcast an alle Prozesse zu verteilen. Die passende Routine hierfür ist MPI_Gather. i n t count,
counts[];
r o o t = 0; MPI_Gather(
ftcount, 1, MPI_INT, c o u n t s , 1, MPI_INT, r o o t , MPI_C0MM_W0RLD ) ; MPI_Comm_size( MPI_C0MM_W0RLD, fesize ) ; MPI_Bcast( c o u n t s , s i z e , MPI_INT, r o o t , MPI_C0MM_WORLD ) : 1
Diese Herangehensweise ist nur für eine relativ kleine Teilchenanzahl geeignet; sie ist anwendbar, wenn die Berechnung der Kräfte besonders kompliziert ist bzw. viel Zeit zu deren Berechnung gebraucht wird.
116
5 Weitergehende Details zum Message-Passing mit M P I
PO
A
Β
C
D
P1
P2
P3
Abbildung
5.1: Datenfluss in einer Gather-Operation mit Prozess pO als Wurzel
Beim Aufsammeln wird die vom i - t e n Prozess gesendete Nachricht an die i - t e Stelle im Empfangspuffer des Wurzelprozesses (root process) gespeichert. Nur der als Wurzelprozess bzw. root ausgewiesene Prozess empfängt die Daten. In den Tabellen 5.7, 5.8 und 5.9 findet man die Signaturen der Gather-Operationen. Die ersten drei Parameter beschreiben die zu sendenden Daten, der vierte bis sechste Parameter die zu empfangenden. Im siebenten Parameter von MPI-Gather wird angegeben, welcher Prozess die Daten empfangen soll. MPI_Gather verlangt, dass alle Prozesse, auch der Wurzelprozess, die gleiche Datenmenge senden und dass die Typsignatur von sendtype zu der von recvtype passt. Der Wert von recvcount ist die Anzahl der von einem beliebigen Prozess gesendeten Datenelemente — normalerweise gilt sendtype = recvtype und sendcount = recvcount. Wenn alle Daten bei einem Prozess aufgesammelt worden sind, können sie per Broadcast mit MPI_Bcast an alle Prozesse verteilt werden. Zu beachten ist auch, dass recvbuf länger als sendbuf ist, außer für den Trivialfall sendcount = 0. So wie bei MPI-Reduce und MPI_Allreduce kann es bequemer und effizienter sein, die Gather- und Broadcast-Operationen zu einer Operation zu kombinieren. Das leistet MPI_Allgather; mit den folgenden Anweisungen können die einzelnen Werte für die Teilchenzahlen für alle Prozesse gesammelt werden: int counts[MAX_PR0CESSES]; MPI_Allgather( &count, 1, MPI_INT, counts, 1, MPI_INT, MPI_C0MM_WORLD ); Man beachte, dass der Parameter recvcount eine skalare Größe ist. Sie gibt die Anzahl der Elemente an, die von jedem Prozess empfangen werden, nicht die Summe der von allen Prozessen empfangenen Elemente.
5.2 Das N-Körper-Problem
117
int MPLGather(void *sendbuf, int sendcount, MPLDatatype sendtype, void *recvbuf, int recvcount, MPLDatatype recvtype, int root, MPLComm comm) int MPI_Allgather(void *sendbuf, int sendcount, MPLDatatype sendtype, void *recvbuf, int recvcount, MPLDatatype recvtype, MPLComm comm) int MPI_Allgatherv(void *sendbuf, int sendcount, MPLDatatype sendtype, void *recvbuf, int *recvcounts, int *displs, MPLDatatype recvtype, MPLComm comm)
Tabelle 5.7: C-Signaturen für die Gather-Funktionen im N-Körper-Programm MPI_GATHER(sendbuf, sendcount, sendtype, recvbuf, recvcount, recvtype, root, comm, ierror) sendbuf(*), recvbuf(*) integer sendcount, sendtype, recvcount, recvtype, root, comm, ierror MPI_ALLGATHER(sendbuf, sendcount, sendtype, recvbuf, recvcount, recvtype, comm, ierror) sendbuf(*), recvbuf(*) integer sendcount, sendtype, recvcount, recvtype, comm, ierror MPI_ALLGATHERV(sendbuf, sendcount, sendtype, recvbuf, recvcounts, rdispls, recvtype, comm, ierror) sendbuf(*), recvbuf(*) integer sendcount, sendtype, recvcounts(*), displs(*), recvtype, comm, ierror
Tabelle 5.8: Fortran-Signaturen für die Gather-Funktionen im N-Körper-Programm Wenn alle Prozesse die gleiche Anzahl von Teilchen hätten, könnten wir MPI_Allgather verwenden, um die Teilchen zu erhalten: MPI_Allgather( myparticles, count, particletype, allparticles, totalcount, particletype, MPI_COMM_WORLD ); In den meisten Fällen jedoch wird die Anzahl der Teilchen in jedem Prozess unterschiedlich sein. Hier können wir eine Variante von MPIJVllgather anwenden, die unterschiedliche Längen der von jedem Prozess zu sendenden Datenpakete zulässt. Diese Routine MPI_Allgatherv benötigt die Längen der einzelnen zu empfangenden Datenelemente und die relative Verschiebung im Empfangspuffer (als Vielfaches der Länge des Empfangsdatentyps), in dem das empfangene Datenelement abgespeichert werden soll.
5 Weitergehende Details zum Message-Passing mit MPI
118
void MPI::lntracomm::Gather(const void* sendbuf, int sendcount, const Datatype^ sendtype, void* recvbuf, int recvcount, const Datatype^ recvtype, int root) const void MPI::lntracomm::Allgather(const void* sendbuf, int sendcount, const Datatype^ sendtype, void* recvbuf, int recvcount, const Datatype^ recvtype) const void MPI::lntracomm::Allgatherv(const void* sendbuf, int sendcount, const Datatype^ sendtype, void* recvbuf, const int recvcounts[], const int displs[], const Datatype^ recvtype) const
Tabelle
5.9: C++-Signaturen
für die Gather-Funktionen
im
N-Körper-Programm
Das heißt, im i-ten Prozess werden recvcount [i] Elemente empfangen und im Empfangspuffer ab Position recvbuf + displs[i] abgelegt (der Wert von displs[i] ist relativ zum Datentyp des Empfangspuffers zu verstehen). In unserem Fall möchten wir die empfangenen Teilchen in ein einzelnes Feld, allparticles, schreiben. Die Verschiebung für den i-ten Prozess ist einfach die Summe der Teilchenanzahlen der Prozesse 0 bis i — 1. Das Aufsammeln aller Teilchen kann mit den Anweisungen displacements[0] = 0; for (i=l; i Wenn genügend Daten gesendet werden und die MPI-Implementierung und die Hardware Berechnungen und Kommunikation effektiv überlagern können, ist mit dieser Herangehensweise eine schnellere Abarbeitung erreichbar als bei der Verwendung von MPI-Allgatherv. Viele Simulationen können aus Tausenden bis Millionen von Schritten dieser Art bestehen. Wie oben dargestellt, erfordert jeder Schritt das Aufsetzen eines Sende- und eines Empfangsvorgangs, wobei in jedem Zyklus dieselben Parameter benutzt werden. Eine durchdachte MPI-Implementierung kann eine solche Situation, in der die gleiche Operation sehr oft ausgeführt wird, gut ausnutzen. Um dies umzusetzen, verfügt MPI über Routinen zur Erzeugung „persistenter" Sende- und Empfangsobjekte, die zur mehrfachen Ausführung der gleichen Operation eingesetzt werden können. Die Form der Aufruferzeugung ist der einer nichtblockierenden Sende- bzw. Empfangsoperation sehr ähnlich. Der einzige Unterschied besteht darin, dass keine Kommunikation ausgeführt wird. Genau wie die nichtblockierenden Operationen MPI.Isend und MPI_Irecv geben diese Routinen einen Wert vom Typ MPI_Request zurück. Um eine Kommunikation zu dieser Anforderung zu starten, muss zuerst die Routine MPI_Start mit diesem Wert aufgerufen werden. Für das Beenden der Kommunikation muss eine der Routinen zum Warten, z.B. MPI-Wait, aufgerufen werden. Wenn eine Warteoperation bezüglich r e q u e s t beendet wurde, kann MPI-Start erneut aufgerufen werden. Mehrfache persistente Kommunikationen können mit M P I _ S t a r t a l l initiiert werden. Die Anweisung MPI_Irecv( . . . ,
ferequest
);
ist zu MPI_Recv_init( . . . , &request ) ; MPI_Start( &request ) ; äquivalent. Ein MPI.Wait zu einem zugehörigen MPI.Irecv ist zu MPI_Wait( ftrequest, festatus ) ; MPI_Request_free( ferequest ) ; äquivalent. Im N-Körper-Problem sind die Anweisungen dann recht kompliziert, wenn die Prozesse nicht die gleiche Anzahl von Teilchen halten. In diesem Fall muss es zu jeder Anzahl von Teilchen (oder vereinfacht, für jeden Prozess) einen anderen Wert für die MPI_RequestVariable geben. Der Quelltext ist in Abbildung 5.2 dargestellt. 2 2
F ü r Fortran-Programmierer: der Ausdruck a '/, b in C entspricht mod(a,b) in Fortran.
5 Weitergehende Details zum Message-Passing mit MPI
120
/* Setup */ for (i=0; i
/* run pipeline */ while (Idone) {
for (i=0; i
> /* Free requests */ for (i=0; i
Abbildung 5.2: Nichtblockierende Pipeline, implementiert kationsoperationen
mit persistenten
MPI-Kommuni-
int MPI_Send_init(void* buf, int count, MPLDststype datstype, int dest, int tag, MPLComm comm, MPLRequest *request) int MPI_Recv_init(void* buf, int count, MPLDatatype datatype, int source, int tag, MPLComm comm, MPLRequest *request) int MPI_Start(MPLRequest *request) int MPLStartall(int count, MPLRequest *array_oLrequests) int MPI_Request_free(MPLRequest *request)
Tabelle 5.10: C-Signaturen der Funktionen zur persistenten Kommunikation dung in einer nichtblockierenden Pipeline)
(für die Anwen-
121
5.2 Das N-Körper-Problem M P L S E N D J N I T ( b u f , count, datatype, dest, tag, comm, request, ierror) buf(*) integer request, count, datatype, dest, tag, comm, request, ierror MPI_RECV_INIT(buf, count, datatype, source, tag, comm, request, ierror) buf(*) integer count, datatype, source, tag, comm, request, ierror MPLSTART(request, ierror) integer request, ierror MPI_STARTALL(count, array_of_requests, ierror) integer count, array_of_requests(*), ierror MPI_REQUEST_FREE(request, ierror) integer request, ierror Tabelle 5.11: Fortran-Signaturen der Routinen wendung in einer nichtblockierenden Pipeline)
zur persistenten
Kommunikation
(für die An-
Prequest MPI::Comm::Send_init(const void* buf, int count, const Datatype^ datatype, int dest, int tag) const Prequest MPI::Comm::Recv_init(void* buf, int count, const Datatype^ datatype, int source, int tag) const void MPI::Prequest::Start() void MPI::Prequest::Startall(int count, Prequest array_of_requests[]) void MPI::Request::Free() Tabelle 5.12: C++-Signaturen der Methoden zur persistenten Kommunikation wendung in einer nichtblockierenden Pipeline). Man beachte: die C++-Klasse stentes Reguest ist Prequest
(für die Anfür ein persi-
Wir machen darauf aufmerksam, dass wir die Kommunikationsroutinen nur size-1 mal aufrufen; wir müssen die Teilchen auch nicht zu ihrem Originalprozess zurück senden. Ein vollständiges N-Körper-Programm, das diesen Ansatz verfolgt, findet man in ' advmsg/nbodypipe. c ' . Die Signaturen für die hier verwendeten Routinen sind in den Tabellen 5.10, 5.11 und 5.12 aufgelistet.
5.2.3
Verschieben von Teilchen zwischen Prozessen
In vielen N-Körper-Problemen verringern sich die Kräfte mit wachsendem Abstand sehr stark. Ist der Abstand groß genug, wird der Einfluss auf ein individuelles Teilchen unbedeutend. Es wurden zahlreiche Algorithmen entwickelt, die diese Tatsache ausnutzen.
122
5 Weitergehende Details zum Message-Passing mit M P I
Sie können den Berechnungsaufwand von 0(N2) auf 0(N log Ν) [App85, BH86, HE88] oder sogar auf Ö(N) [Gre87] reduzieren. Alle diese Algorithmen fassen die Teilchen entsprechend ihrer Position in Gruppen zusammen. Das Gebiet kann ζ. B. in Zellen unterteilt werden, die den Prozessen zugeordnet werden, so wie es in Abbildung 5.3 dargestellt ist. Ein wichtiger Schritt bei der Implementierung dieser Algorithmen ist der des Transfers von Teilchen von einem zu einem anderen Prozess, wenn sich diese bewegen. Wir werden einige Wege, dies mit M P I zu erreichen, besprechen. ••
•
P12
P13
P14
•
P15
·
•
• P8
P9 •
P4
P5
P10 •
P11
P6
P7
• PO
• P1
P2
• » P3
* •
Abbildung 5.3: Beispiel einer Aufteilung eines Gebietes in Zellen. Die Zellen sind mit den Prozessnummern bezeichnet, die Punkte sind die zu diesem Prozess gehörenden Teilchen Wenn alle Teilchen in einem Feld — sagen wir in Particles myparticles [] — gespeichert sind, dann können die Teilchen, die einem anderen Prozess übergeben werden sollen, durch ihren jeweiligen Index in diesem Feld identifiziert werden. M P I bietet eine Möglichkeit, diese zu versendenden Daten direkt anhand dieser Indizes mit MPI_Type_indexed auszuwählen. Die Eingabe für diese Routine ist die Anzahl der Elemente als erstes Argument, ein Feld mit Blocklängen (so wie bei MPI_Type_vector, diese sind meist gleich eins) als zweites Argument, das Feld der Indexwerte als drittes und der Typ der zu verschiebenden Daten als viertes Argument. Im fünften Parameter wird der neue Datentyp angegeben. Abbildung 5.4 zeigt, wie diese Routine benutzt werden kann; Abbildung 5.5 veranschaulicht den Zusammenhang zwischen den Index-Werten und den zu verschiebenden Daten. Zu beachten ist, dass die Routine auch korrekt arbeitet, wenn keine Teilchen die Zelle verlassen sollen (n_to_move=0). Für das Empfangen können wir mit MPI_Recv( newparticles, MAX_PARTICLES, particletype, source, tag, comm, ftstatus ); MPI_Get_count( ftstatus, particletype, &number ); arbeiten, wobei number die Anzahl der zu empfangenden Teilchen ist. Wir verwenden hier die Routine MPI_Get_count, um festzustellen, wieviele Teilchen geschickt wurden. MPI_Get_count wertet die Informationen der Variable status, die von einer MPIEmpfangsoperation (receive, probe, wait oder test) zurückgegeben wird, zusammen mit
123
5.2 Das N-Körper-Problem n_to_move = 0; for (i=0; i >
MPI_Type_indexed( n_to_move, elmsize, elmoffset, particletype, ftsendtype ); MPI_Type_commit( fcsendtype ); MPI_Send( myparticles, 1, sendtype, dest, tag, comm ); MPI_Type_free( &sendtype ); Abbildung anderen
5.4·' Programmskizze zum Verschieben von Teilchen von einem Prozess zu einem
myparticles 142 Nur diese Teilchen sind zu senden elmoffset Abbildung
9 27 39 142
5.5: Illustration zum Argument array^of-displacements
in Abbildung 5.4
dem Datentyp, der als zweiter Parameter gegeben ist, aus, um zu ermitteln, worauf sich der Ausgabewert bezieht. Wichtig ist, dass der in MPI_Get_count angegebene MPIDatentyp derselbe sein muss wie der in dem MPI-Aufruf, der den status-Wert erzeugt. In unserem Beispiel, in dem MPI_Recv den Typ particletype verwendet, müssen wir particletype auch in MPI_Get_count angeben. In den Tabellen 5.13, 5.14 und 5.15 sind die Signaturen für diese Routinen zu finden. Diese Herangehensweise setzt voraus, dass ausreichend Speicherplatz reserviert wird, um jede beliebige Anzahl von Teilchen empfangen zu können. Das ist nicht immer möglich. Wir benötigen ein Mittel, um Informationen über eine Nachricht zu erhalten, bevor wir diese empfangen. Mit der Routine MPI-Probe können wir das erreichen. MPI_Probe bildet aus Quelle, Etikett und Kommunikator die Rückgabe von MPIJStatus. Aus MPlJStatus sind, so als wäre die Nachricht empfangen worden, die Angaben zu Quelle und Etikett sowie durch Aufruf von MPI_Get_count die Länge der Nachricht ermittelbar. Wenn nach MPI JProbe ein Aufruf von MPI-Recv mit denselben Werten für Quelle, Etikett und Kommunikator erfolgt, empfängt MPI_Recv genau die Nachricht, über die wir uns zuvor mit MPI_Probe informiert hatten. Wir können somit MPI_Get_count zusammen mit MPI_Probe nutzen, um den benötigten Speicherplatz für eine Nachricht zu ermitteln, bevor wir MPIJlecv für deren Empfang starten. Da mit MPIJProbe und MPI_Iprobe
124
5 Weitergehende Details zum Message-Passing mit M P I
int MPI_Type_indexed(int count, int *array_of_blocklengths, int *array_of_displacements, MPLDatatype oldtype, MPLDatatype *newtype) int MPLGet_count(MPI_Status *status, MPLDatatype datatype, int *count) int MPLProbe(int source, int tag, MPLComm comm, MPLStatus *status)
Tabelle 5.13: C-Signaturen der Funktionen, die für das Verschieben von Teilchen angewendet werden MPI_TYPE_IIMDEXED(count, array.oLblocklengths, array_of_displacements, oldtype, newtype, ierror) integer count, array_oLblocklengths(*), array_of-displacements(*), oldtype, newtype, ierror MPLGET_COUIMT(status, datatype, count, ierror) integer status(*), datatype, count, ierror MPLPROBE(source, tag, comm, status, ierror) integer source, tag, comm, status(MPLSTATUS-SIZE), ierror
Tabelle 5.14: Fortran-Signaturen der Routinen, die für das Verschieben von Teilchen angewendet werden der Status einer Nachricht feststellbar ist, auch wenn diese noch gar nicht empfangen wurde, kann man mit diesen Routinen die Länge einer noch ausstehenden Nachricht ermitteln und so Puffer entsprechender Größe vereinbaren. Das ist eine der wichtigsten Situationen, in denen MPI_Probe gebraucht wird. Die Programmanweisungen hierfür sind MPI_Probe( source, tag, comm, ftstatus ); MPI_Get_count( ftstatus, particletype, &number ); MPI_Type_extent( particletype, ftextent ); newparticles = (Particle *)malloc( number * extent ); MPI_Recv( newparticles, number, particletype, source, tag, comm, ftstatus ); Mit MPI_Irecv ist dieses Vorgehen nicht möglich, weil die Routine die Zuweisung von Puffer für die zu empfangende Nachricht vorher verlangt. E i n e B e m e r k u n g zur T h r e a d s i c h e r h e i t . Dieser Quellcode zeigt eine der wenigen Stellen, an denen das MPI-Design selbst ein Problem mit der Threadsicherheit hat. Viele MPI-Programme laufen in einer Umgebung, in der es nur einen Thread pro Prozess gibt oder wo nur ein Thread MPI-Routinen aufruft — in diesen Fällen gibt es keine Schwierigkeit mit der obigen Anweisungsfolge.
125
5.2 Das N-Körper-Problem Datatype MPI::Datatype::CreateJndexed(int count, const int array.of-blocklengths[], const int array_of_displacements[]) const int MPI::Status::Get_count(const Datatype& datatype) const void MPI::Comm::Probe(int source, int tag, Status& status) const void MPI::Comm::Probe(int source, int tag) const Tabelle 5.15: det werden
C++-Signaturen
der Methoden,
die für das Verschieben
von Teilchen
angewen-
Wenn jedoch mehrere Threads MPI-Aufrufe ausführen, kann es sein, dass diese Anweisungsfolge nicht korrekt abläuft. Man betrachte hierzu das Szenario in Abbildung 5.6. In diesem Beispiel versuchen beide Threads, eine Nachricht von dem Prozess mit dem Rang s im Kommunikator comm mit dem Datentyp d zu empfangen. Wir wollen weiter annehmen, dass der Prozess mit Rang s zwei Nachrichten gesendet hat, eine mit einer Länge von 1, die andere mit Länge 10. Die möglichen „Ergebnisse" sind in Abbildung 5.7 zu sehen. Nur der erste und der vierte Fall liefern, was der Programmierer beabsichtigte. In den anderen zwei Fällen „sehen" (und verarbeiten) die Aufrufe von MPI_Probe und MPI_Recv verschiedene Nachrichten. Um zu erkennen warum, wollen wir betrachten, was im zweiten Fall ablaufen könnte. Thread 1 MPI_Probe( s, t, comm, fcstl ) MPI_Get_count( &stl, d, &nl ) MPI_Recv( a, nl, d, s, t, comm, fest ) Abbildung empfangen
Thread 2 MPI_Probe( s, t, comm, &st2 ) MPI_Get_count( &st2, d, &n2 ) MPI_Recv( b, n2, d, s, t, comm, &st )
5.6: Zwei Threads in einem MPI-Prozess,
Fall 1 2 3 4
Thread 1 Probe Recv 1 1 1 10 1 1 10 10
die innerhalb
desselben
Kommunikators
Thread 2 Probe Recv 10 10 1 1 1 10 1 1
Abbildung 5.7: Mögliche Ergebnisse des Codes aus Abbildung richtenlänge durch MPI-Probe und MPIJiecv
5.6 für die Rückgabe
der Nach-
126
5 Weitergehende Details zum Message-Passing mit M P I
Die Abbildung 5.8 zeigt eine mögliche Ausführungsfolge. Beide Threads rufen MPI_Probe auf, bevor sie MPI_Recv aufrufen. Deshalb erhalten sie die Informationen zur ersten Nachricht (sie hat die Länge 1), die vom Prozess mit Rang s gesendet wurde. Anschließend empfängt der zweite Thread diese Nachricht mit MPI-Recv. Schließlich erwartet der erste Thread den Empfang einer Nachricht der Länge 1 (nl = l ) . Da die erste Nachricht nun unglücklicherweise bereits empfangen wurde, „sieht" der erste Thread die zweite Nachricht, also die mit der Länge 10. Das verursacht einen Fehler der Klasse MPI_ERR_TRUNCATE. Es gibt verschiedene Techniken, um dieses Problem zu vermeiden. Der klassische Zugang in der Multithread-Programmierung ist die Anwendung eines Mechanismus zum gegenseitigen Ausschluss (engl.: mutual exclusion), ζ. Β. der Einsatz von Sperren (engl.: locks), um zu verhindern, dass ein anderer Thread die Ausführung zusammengehöriger MPI_Probe- und MPIJtecv-Anweisungen stört. M P I bietet hier einen anderen Ansatz: da die Kommunikationen bezüglich verschiedener Kommunikatoren unabhängig voneinander sind, kann man, solange kein Kommunikator von mehr als einem Thread benutzt wird, auf Ausschlussmechanismen verzichten. Aspekte zur Threadsicherheit werden in Using MPI-2 [GLT99] genauer besprochen. Eine Alternative für MPI_Type_indexed in M P I - 2 . In vielen Anwendungen, so wie in unserem Beispiel, sind die Blocklängen für alle Blöcke gleich. In diesen Fällen erweist sich die Anwendung von MPI_Type_indexed als unnötig mühsam. MPI-2 verfügt über eine neue Routine, die hierauf abzielt: MPI_Type_create_indexed_block. Diese Routine wird auf die gleiche Weise benutzt wie MPI_Type_indexed, nur dass der zweite Parameter von MPI_Type_indexed, array_of_blocklengths, durch den skalaren Parameter blocklength ersetzt wurde. Die Signaturen für diese Routinen sind in den Tabellen 5.16, 5.17 und 5.18 angegeben.
Thread 1 MPI_Probe( s, t, comm, testatus1 ) MPI_Get_count( ifcstatusl, d, &nl )
Thread 2
MPI_Probe( s, t, comm, &status2 ) Beide MPI _Probe-Aufrufe erkennen die gleiche Nachricht MPI_Get_count( &status2, d, &n2 ) MPI_Recv( b, n2, d, s, t, comm, status ) MPI_Recv( a, nl, d, s, t, comm, status ) Abbildung 5.8: Ein möglicher Ausführungsablauf für die zwei Threads in Abbildung 5.6
int MPLType_create_indexed_block(int count, int blocklength, int *array_of_displacements, MPLDatatype oldtype, MPLDatatype *newtype) Tabelle 5.16: C-Signatur der Μ Ρ1-2-Funktion für geblockte indizierte Datentypen
127
5.2 Das N-Körper-Problem MPLTYPE_CREATE_INDEXED_BLOCK(count, blocklength, array.of-displacements, oldtype, newtype, ierror) integer count, blocklength, array_of_displacement(*), oldtype, newtype, ierror Tabelle
5.17: Fortran-Signatur
der ΜΡ1-2-Funktion
für geblockte indizierte
Datentypen
Datatype MPI::Datatype::Create_indexed_block(int count, int blocklength, const int array-of_displacements[]) const
Tabelle 5.18:
C++-Signatur
der MPI-2-Methode
für geblockte indizierte
Datentypen
Zu bemerken ist, dass wir die Daten, die nicht zusammenhängend im Speicher lagen, mittels eines abgeleiteten Datentyps versendet haben. Beim Empfangen wurden sie in einen zusammenhängenden Speicherbereich geschrieben. MPI verlangt nur, dass die Typsignaturen passen, das heißt die Grundarten der Daten (ζ. B. passt Integer zu Integer, Real zu Real).
5.2.4
Senden dynamisch erzeugter Daten
In einigen Implementierungen von N-Körper-Algorithmen können die Teilchen in dynamisch zugewiesenem Speicher abgelegt werden. In diesem Fall gibt es keinen einzelnen Puffer, den wir für die entsprechenden Verschiebungen nutzen können, so wie es für MPI_Type.indexed erforderlich ist. Wir können stattdessen mit einer speziellen Position, MPI-BOTTOM, und den absoluten Adressen der Elemente (bestimmt von MPI_Address) arbeiten. Da wir die Verschiebungen als Adressen in Bytes statt als Feldindex berechnen, verwenden wir MPI_Type_hindexed. Diese Routine ist identisch zu MPI „Type _indexed bis auf die Tatsache, dass die Angabe des dritten Arguments in Bytes und nicht als Anzahl der Elemente erfolgt. Wenn zum Beispiel die Teilchen in einer verketteten Liste mit dynamisch erzeugten Elementen gespeichert werden, können wir den Quellcode aus Abbildung 5.9 verwenden. Die Routine MPI_Address gibt im zweiten Parameter die Adresse, die (in C) den Typ MPI_Aint hat, des im ersten Parameter angegebenen Datenworts zurück. Fortran-Programmierer werden diese Funktion begrüßen, wohingegen C-Programmierer ein wenig verblüfft über deren Anwendung sein werden, speziell in einem C-Programm. Der Grund hierfür liegt darin, dass in vielen Implementierungen von C der Wert eines Zeigers (ein Integer) als die Speicheradresse des Datenworts, auf das er zeigt, implementiert ist. Das ist jedoch in C nicht zwingend. Es gibt nennenswerte Maschinen (zum Beispiel Supercomputer mit Wort- statt Byteadressierung sowie praktisch jeder PC auf unserem Planeten), bei denen Zeiger keine Adressen sind. Die Benutzung von MPI-Address in C-Programmen hilft, die Portierbarkeit zu erhalten. Die Signaturen dieser Routinen sind in den Tabellen 5.19, 5.20 und 5.21 zusammengefasst.
5 Weitergehende Details zum Message-Passing mit MPI
128
MPI_Aint elmoffset[MAX_PARTICLES]; n_to_move = 0; while (particle) { if (particle exited cell) { MPI_Address( particle, feelmoffset[n_to_move] ); elmsize[n_to_move] = 1; n_to_move++;
} else { particle = particle->next;
>
>
MPI_Type_hindexed( n_to_move, elmsize, elmoffset, particletype, feparticlemsg ); MPI_Type_commit( feparticlemsg ); MPI_Send( MPI_B0TT0M, 1, particlemsg, dest, tag, comm ); MPI_Type_free( feparticlemsg );
Abbildung 5.9: Skizze eines Programmstücks Teilchen zu anderen Prozessen
zum Verschieben von dynamisch
zugewiesenen
int MPI_Address(void* location, MPI_Aint *address) int MPI_Type_hindexed(int count, int *array_of.blocklengths, MPI-Aint *array_of_displacements, MPLDatatype oldtype, MPLDatatype *newtype)
Tabelle 5.19: C-Signaturen für das Senden dynamisch erzeugter
Daten
MPLADDRESS(location, address, ierror) location integer address, ierror M P L T Y P E _ H I N D E X E D ( c o u n t , array_of_blocklengths, array_of-displacements, oldtype, newtype, ierror) integer count, array_of_blocklengths(*), array_of_displacements(*), oldtype, newtype, ierror
Tabelle 5.20: Fortran-Signaturen
für das Senden dynamisch
erzeugter
Daten
129
5.2 Das N-Körper-Problem Aint MPI::Get_address(const void* location) Datatype MPI::Datatype::Create_hindexed (int count, const int array-of_blocklengths[], const Aint array.of_displacements[]) const
Tabelle 5.21:
5.2.5
C++-Signaturen
für das Senden dynamisch
erzeugter
Daten
Nutzergesteuertes Packen von Daten
In manchen Fällen ist es einfacher, einen zusammenhängenden Puffer zu bilden, als einen speziellen Datentyp zu erzeugen. In MPI gibt es Routinen, mit denen man Daten beliebiger MPI-Datentypen in einen nutzerdefinierten Puffer packen bzw. sie aus einem solchen Puffer entpacken kann. Mit der Routine MPI_Pack kann der Anwender Daten inkrementell zu einem nutzerdefinierten Puffer hinzufügen. Die gepackten Daten können dann mit dem Datentyp MPI-PACKED gesendet und empfangen werden. Die Eingabe für MPI J a c k sind die zu packenden Daten, die Anzahl und der Datentyp der Elemente, der Ausgabepuffer und die Größe des Ausgabepuffers in Bytes, der aktuelle Wert von p o s i t i o n und der Kommunikator. Gleichzeitig ist p o s i t i o n auch Ausgabeparameter; er ist einer der wenigen Parameter in MPI, der sowohl der Ein- als auch der Ausgabe dient. Der Wert von p o s i t i o n muss vor dem ersten Aufruf von MPI_Pack auf Null gesetzt werden, und zwar jedesmal, wenn ein Puffer mit Daten gefüllt wird. MPI_Pack benutzt p o s i t i o n , um festzuhalten, wie weit der Ausgabepuffer aktuell gefüllt ist. Der Wert von p o s i t i o n muss als Wert für count eingesetzt werden, wenn ein mit MPI_Pack gefüllter Puffer gesendet wird. Die exakte Bedeutung von p o s i t i o n ist abhängig von der Implementierung; es muss zum Beispiel nicht die Anzahl der gepackten Bytes sein. Eine Frage ist noch zu beantworten: Wie groß muss der Puffer sein, um die Daten aufzunehmen? Die Routine MPI_Pack_size beantwortet diese Frage. Sie liest die Anzahl und den MPI-Datentyp der Elemente sowie den Kommunikator, innerhalb dessen sie verschickt werden sollen, und gibt die maximale Anzahl der Bytes zurück, die für MPI_Pack zum Aufnehmen der Daten erforderlich sind. Der Kommunikator ist ein notwendiges Argument, da in einer heterogenen Umgebung, das heißt, in einem MPI-Programm, in dem zu MPI_C0MM_W0RLD Prozessoren mit unterschiedlichen Datendarstellungen gehören, die Wahl der Darstellung der innerhalb eines Kommunikators zu sendenden Daten davon abhängig sein kann, welche Prozesse zu diesem gehören. Als Beispiel sei der Fall betrachtet, dass zu MPI_C0MM_W0RLD 64 Prozessoren identischen Typs sowie ein Prozessor, der schnelle Visualisierungen erlaubt, aber ein anderes Datenformat benutzt, gehören. Das Programm erzeugt mit MPI_Comm_split einen Kommunikator compute, der die 64 identischen Prozessoren enthält, und einen Kommunikator g r a p h i c s . Für den compute-Kommunikator kann mit MPI_Pack die originale Datenrepräsentation gewählt werden, um höhere Leistung und Effizienz zu erzielen. Für MPI-C0MM3/0RLD könnte MPI_Pack mit einem anderen Format arbeiten. Der Quellcode aus Abschnitt 5.2.4, nun mit Benutzung von MPI_Pack, ist in Abbildung 5.10 dargestellt. Das Empfangen der Teilchen erfolgt mit MPI .Unpack, wie in Abbil-
130
5 Weitergehende Details z u m Message-Passing mit M P I Particle particles[MAX.PARTICLES]; position = 0; particle = teparticles [0]; MPI_Pack_size( MAX_SEND, particletype, comm, tebufsize ); buffer = malloc( (unsigned)bufsize ); while (particle) ί if (particle exited cell) -[ MPI_Pack( particle, 1, particletype, buffer, bufsize, teposition, comm );
} else { particle = particle->next; }
> MPI_Send( buffer, position, MPI.PACKED, dest, tag, comm );
Abbildung
5.10: Skizze der Anweisungen,
um Teilchen in einen Ausgabepuffer zu packen
MPI_Recv( buffer, maxcount, MPI_PACKED, source, tag, comm, testatus ); MPI_Get_count( testatus, MPI.PACKED, telength ); position = 0; while (position < length) { MPI_Unpack( buffer, length, teposition, tenewparticle, 1, particletype, comm );
>
Abbildung
5.11: Skizze der Anweisungen
zum Entpacken der Teilchen aus einem
Puffer
d u n g 5.11 zu sehen ist. Besonders hingewiesen sei auf die A n w e n d u n g von MPI_Get_count zur E r m i t t l u n g der L ä n g e des gepackten P u f f e r s u n d die A b f r a g e position < length, u m festzustellen, ob alle Teilchen e n t p a c k t worden sind. Die S i g n a t u r e n dieser R o u t i n e n sind in den Tabellen 5.22, 5.23 u n d 5.24 a u f g e f ü h r t . M a n k a n n auch eine Nachricht, die mit MPI_Pack zusammengestellt u n d mit d e m D a t e n t y p MPI_PACKED gesendet wurde, mit einem passenden D a t e n t y p e m p f a n g e n . In unserem Fall k ö n n t e n wir den D a t e n t y p particletype b e n u t z e n : MPI_Recv( buffer, maxparticle, particletype, source, tag, comm, testatus ); MPI_Get_count( testatus, particletype, tenewparticles );
5.3 Visualisierung der Mandelbrotmenge
131
int MPI_Pack(void* inbuf, int incount, MPLDatatype datatype, void *outbuf, int outsize, int *position, MPLComm comm) int MPLUnpack(void* inbuf, int insize, int *position, void *outbuf, int outcount, MPLDatatype datatype, MPLComm comm) int MPI_Pack_size(i nt incount, MPLDatatype datatype, MPLComm comm, int *size)
Tabelle 5.22: C-Signaturen der Funktionen für das Packen und Entpacken von Puffern MPI_PACK(inbuf, incount, datatype, outbuf, outcount, position, comm, ierror) inbuf(*), outbuf(*) integer incount, datatype, outcount, position, comm, ierror MPI_UNPACK(inbuf, insize, position, outbuf, outcount, datatype, comm, ierror) inbuf(*), outbuf(*) integer insize, position, outcount, datatype, comm, ierror MPI_PACK_SIZE(incount, datatype, comm, size, ierror) integer incount, datatype, comm, size, ierror Tabelle 5.23: Fortran-Signaturen der Routinen für das Packen und Entpacken von Puffern void MPI::Datatype::Pack(const void* inbuf, int incount, void *outbuf, int outsize, int& position, const Comm &comm) const void MPI::Datatype::llnpack(const void* inbuf, int insize, void *outbuf, int outcount, int& position, const Comm& comm) const int MPI::Datatype::Pack_size(int incount, const Comm& comm) const
Tabelle 5.24: C++-Signaturen der Methoden für das Packen und Entpacken von Puffern
Natürlich kann auch mit mehreren Puffern gleichzeitig gearbeitet werden. So könnten wir zum Beispiel für die Datenübergabe die für jeden Nachbarprozess bestimmten Daten in einem separaten Puffer halten, jeden mit einer eigenen Positionsvariablen.
5.3
Visualisierung der Mandelbrotmenge
Ein Buch zur Parallelverarbeitung ohne ein Beispiel zur Mandelbrotberechnung wäre wohl sicher unvollständig. Wir wollen dieses Beispiel hier nutzen, um die Anwendung der abgeleiteten Datentypen von MPI zusammen mit der Echtzeit-Graphikbibliothek
132
5 Weitergehende Details zum Message-Passing mit M P I
von M P E zu illustrieren. In unserem Beispiel arbeiten wir mit einer der vielen Optimierungen auf der Algorithmusebene, die in solchen Programmen, ob parallel oder nicht, anwendbar sind, um graphische Repräsentationen der Mandelbrotmenge zu berechnen. Die Mandelbrotmenge ist ein populäres Beispiel für parallele Berechnungen, weil sie eine so offensichtliche parallele Anwendung ist, sie ein Lastverteilungsproblem enthält und die Ergebnisse sehr eindrucksvoll dargestellt werden können. Um auch etwas originell zu sein, zeigen wir dieses Beispiel hier mit einem nicht ganz üblichen Algorithmus. Wir würden gern hervorheben, diesen Trick gefunden zu haben, aber wir sind uns hier nicht sicher — andere hatten wohl auch schon diese Idee. Es sei ζ eine komplexe Variable. Das heißt, wir stellen uns ζ als einen P u n k t (χ, y) in der Ebene vor, Punkte dieser Ebene können nach der Gleichung (xi,yi)
• 02,2/2) = (X1X2 ~ 2/12/2, XlV2 + X2V\)
miteinander multipliziert werden. Die Mandelbrotmenge A4 ist auf folgende Weise definiert. Wir betrachten die Punktion fc(z) = z2+c für eine gegebene komplexe Zahl c. Mit dieser Funktion können wir eine Folge von Punkten der komplexen Ebene berechnen: zq = 0, z\ = fc(0), Z2 = fc{z 1) = fc (0) usw. Diese Folge bleibt entweder beschränkt, in diesem Fall ist c in A4, oder sie entfernt sich immer weiter von 0, dann ist c nicht in A4. Es kann gezeigt werden, dass die Folge unbeschränkt ist, wenn ein solcher P u n k t weiter als mit einem Abstand von 2 vom Ursprung entfernt liegt. Ein „Bild" der Mandelbrotmenge kann man durch Ausgabe der Punkte auf den Bildschirm erhalten. Jedes Pixel entspricht einem P u n k t c der komplexen Ebene und kann durch wiederholte Anwendung der Funktion fc an der Stelle 0 auf Zugehörigkeit zu A4 getestet werden. Entweder gilt |/ c ™(0)| > 2 für bestimmte η oder eine vorgegebene Anzahl Iterationen wurde ausgeführt, worauf wir „aufgeben" und die Iteration abbrechen. In diesem Fall erklären wir den P u n k t c als zu A4 gehörend und färben ihn, ζ. B. schwarz. Details des sehr komplizierten Rands von A4 können durch „Vergrößerung" sichtbar gemacht werden, indem nur ein kleiner Bereich der komplexen Ebene auf der gesamten Bildschirmfläche dargestellt wird. Mehr zur Mandelbrotmenge und zu verwandten Themen findet man in [Man83] oder [PS88]. Die Region nahe des Rands von A4 ist sehr interessant. Von diesem Gebiet können eindrucksvolle Bilder erzeugt werden, wenn man dem Pixel, der den P u n k t c darstellt, eine Farbe in Abhängigkeit von dem ersten Wert für n, für den | / c n ( 0 ) | > 2 gilt, zuweist. Wenn wir den Rand von A4 mit immer größer werdender Auflösung untersuchen, erhalten wir den Eindruck eines riesigen Universums großer Vielfalt. Nach ein paar zufälligen Vergrößerungen des Rands sehen wir vielleicht einen Teil der Ebene, den niemand vorher gesehen hat. Die Berechnung des Farbwertes jedes Pixels kann unabhängig von jedem anderen Pixel erfolgen, wodurch das Programm zur Berechnung solcher Bilder leicht zu parallelisieren ist. Man teilt die Bildfläche einfach in Gebiete auf, eins für jeden Prozess. Leider arbeitet dieser naive „vorprogrammierte" Zugang wegen unausgeglichener Last nicht zufriedenstellend. Einige Punkte verlassen den Kreis mit Radius 2 schon nach wenigen Iterationen, bei anderen dauert dies länger. Um die P u n k t e aus A4 zu bestimmen, schöpfen wir natürlich die maximale Anzahl an Iterationen aus, bevor wir „aufgeben".
5.3 Visualisierung der Mandelbrotmenge
133
MPLTYPE_HVECTOR(count, blocklength, stride, oldtype, newtype, ierror) integer count, blocklength, stride, oldtype, newtype, ierror MPI_TYPE_STRUCT(count, array_of-blocklengths, array_of_displacements, array_of_types, newtype, ierror) integer count, array_of_blocklengths(*), array_of_displacements(*), array_of_types(*), newtype, ierror
Tabelle
5.25:
Fortran-Signaturen
für Μ PI-Datentyproutinen
Wenn wir vergrößern, benötigen wir mehr Iterationen, um Details zum Vorschein zu bringen. So können für einige Pixel mehrere Tausend mal mehr Iterationen als bei anderen erforderlich sein, bevor eine Farbe zugewiesen werden kann. Der naheliegendste Weg, diesem Lastverteilungsproblem beizukommen, ist die autonome Verteilung (engl.: self-scheduling). Wir unterteilen den Bildschirm in einige Quadrate moderater Größe und bestimmen einen Prozess (den Master), um diese an die anderen Prozesse (die Slaves) zur Bearbeitung zu senden. Genau wie im Programm zur MatrixVektor-Multiplikation im Kapitel 3 bedeutet die Fertigstellung einer Aufgabe auch die Anforderung einer neuen. Einige Aufgaben werden sehr viel länger dauern als andere (besonders in Bereichen, in denen schwarz überwiegt), doch wenn wir nicht allzu viel Pech haben, können wir so alle Prozesse über die gesamte Zeit beschäftigt halten. Unter den diesem Buch beigefügten Beispielen ist auch ein paralleles Mandelbrotprogramm. Es ist zu lang, um es hier vorzustellen, aber es hat drei interessante Aspekte, die wir ausführen wollen. Genauer gesagt, veranschaulicht es • den Einsatz abgeleiteter Datentypen in MPI für das Senden von verstreuten Strukturen, • eine interessante Technik zur Beschleunigung der Berechnung und • einige weitere Funktionen aus der MPE-Graphikbibliothek. Während der Initialisierungsphase der Berechnung sendet der Master eine sehr heterogene Sammlung von Daten an die Slaves. Wir können uns diese als C-Struktur vorstellen, int MPI_Type_hvector(int count, int blocklength, MPI_Aint stride, MPLDatatype oldtype, MPLDatatype *newtype) int MPI_Type_struct(int count, int *array_of_blocklengths, MPI_Aint *array_of_displacements, MPLDatatype *array_of_types, MPLDatatype *newtype) Tabelle
5.26:
C-Signaturen
für
MPI-Datentypfunktionen
134
5 Weitergehende Details zum Message-Passing mit MPI
Datatype MPI::Datatype::Create_hvector (int count, int blocklength, Aint stride) const Datatype MPI::Datatype::Create.struct (int count, const int array_of_blocklengths[], const Aint array_of_displacements[], const Datatype array.of_types[])
Tabelle
5.27:
C++-Signaturen
für
MPI-Datentypmethoden
obwohl nicht alle Daten als eine einfache C-Struktur gespeichert sind. Wir könnten jeden dieser Parameter einzeln per Broadcast versenden, was aber ein verschwenderischer Umgang mit den Nachrichten wäre (und wegen der hohen Startup-Zeiten, die dann sehr oft anfielen, sehr teuer würde). Wir könnten die Daten auch in Felder von i n t s , doubles usw. umordnen. Der „MPI-Weg" besteht jedoch darin, einen Datentyp zu erzeugen, der diese Struktur abbildet, und diesen dann geschlossen zu senden. Der Algorithmus, mit dem wir arbeiten, wird in den Abbildungen 5.12, 5.13 und 5.14 graphisch veranschaulicht. Das Beispielprogramm implementiert eine Methode zur Beschleunigung der Berechnung. Der Kunstgriff basiert auf der folgenden Eigenschaft, die in allen Vergrößerungen, mit Ausnahme der kleinsten Auflösung, gilt: Wenn der Rand eines Quadrats aus Pixeln gleicher Farbe besteht, dann müssen auch alle Pixel im Inneren diese Farbe haben. Die Ausnutzung dieser Eigenschaft kann große Beschleunigungen bewirken, vor allem bei großen Bereichen von M. selbst. Um die Größe der Quadrate dynamisch anzupassen, verfahren wir wie folgt. Die Liste der zu bearbeitenden Aufgaben wird vom Master verwaltet und besteht aus Quadraten, deren Farben zu berechnen sind. Am Anfang gibt es nur eine Aufgabe in der Liste, die komplette zur Anzeige ausgewählte Region. Für ein gegebenes Quadrat beginnen wir mit der Ermittlung der Farben der Pixel auf dem Rand. Wenn wir auf dem Weg entlang des Rands stets die gleiche Farbe erhalten, färben wir alle inneren Punkte auch mit dieser Farbe. Ergibt sich eine neue Farbe, teilen wir dieses Quadrat in vier Quadrate und senden sie als neue Aufgaben zurück an den Master, während wir die Berechnungen auf dem Rand fortführen. Wir merken uns sorgfältig die Schachtelung der Quadrate, sodass wir die Farbe eines Pixels niemals öfter als einmal berechnen. Es gibt eine Begrenzung für die Teilungen, sodass die Quadrate, wenn sie klein genug sind, nicht weiter unterteilt werden. Während des Programmlaufs beobachten wir die Entwicklung des Bildes mit Hilfe der MPE-Graphikbibliothek, um zu sehen, ob das Programm so arbeitet, wie wir es uns vorstellen. Zusätzlich zum Zeichnen der einzelnen Pixel, so wie wir es in Kapitel 3 gesehen haben, zeichnen wir Linien entsprechend der Berechnung der Quadratränder. Auch wenn nicht die ganze Umrandung eines Quadrats die gleiche Farbe hat, gibt es Abschnitte, deren Punkte alle von der gleichen Farbe sind. Diese Strecken werden mit MPE_Draw_line( h a n d l e , x l , y l , x2, y2, c o l o r ) ;
135
5.3 Visualisierung der Mandelbrotmenge
Abbildung
5.12:
Box-Algorithmus
für die Mandelbrotmenge
— Beginn
gezeichnet. Hier ist handle ein Zeiger auf eine mit MPE_Open_graphics initialisierte MPE_XGraph-Struktur; xl,yl und x2,y2 sind die Endpunkte der zu färbenden Strecke und color eine F a r b e vom T y p MPE_Color. Wenn wir „gewinnen" und ein ganzes Quadrat füllen können, dann rufen wir MPE_Fill_rectangle( handle, x, y, w, h, color ); auf, wobei χ , y die Position der oberen linken Ecke des Rechtecks angibt und w und h die Breite und Höhe, jeweils in Pixeln angegeben, darstellen. Wenn wir nur einen einzelnen P u n k t zeichnen, benutzen wir MPE_Draw_point( handle, x, y, color ); mit χ und y als Punktkoordinaten in Pixeln.
136
Abbildung
5 Weitergehende Details zum Message-Passing mit MPI
5.13: Box-Algorithmus
für die Mandelbrotmenge
— etwas
später
Diese separaten Aufrufe selbst werden „schubweise" verarbeitet, d. h. wir rufen MPE_Update am Ende einer Aufgabe (ein vollständiger Rand, ein komplettes Quadrat in derselben Farbe oder ein kleines Quadrat mit verschiedenen Farben) auf. Die Abbildung 5.14 zeigt das fertige Bild. Dieses Beispiel bietet uns auch die Gelegenheit zur Einführung des allgemeinsten der abgeleiteten MPI-Datentypen, mit dem man C-Strukturen oder Teile von solchen versenden kann. Die Datenstrukturen des Mandelbrotprogramms enthalten eine C-Struktur für Kommandozeilenargumente, die eine Vielzahl von Optionen für das Programm angeben. Es ist zweckmäßig, sie in einer Struktur zu vereinen, sodass sie leicht an verschiedene Routinen übergeben werden können. In MPI ist nicht festgelegt, dass alle Prozesse über MPI_Init Zugriff auf Kommandozeilenargumente haben. Deshalb versenden wir diese per Broadcast, wobei wir annehmen, dass zumindest der Prozess mit dem Rang 0 aus MPI_C0MM_W0RLD die Werte beim Starten des Programms erhält. Nachdem Prozess 0 diese Daten in seine Kopie der Struktur gespeichert hat, sendet er die Struktur per Broadcast an die anderen Prozesse. Die Struktur hat den folgenden Aufbau:
5.3 Visualisierung der Mandelbrotmenge
Abbildung
137
5.14'· Mandelbrotmenge — am Ende der Berechnung
struct { char int double double int int } cmdline;
display[50]; maxiter; xmin, ymin; xmax, ymax; width; height;
/ * Name of display */ / * max # of iterations */ / * lower left corner of rectangle */ / * upper right corner */ / * of display in pixels */ / * of display in pixels •/
Wir würden diese Struktur gern mit einer einzelnen MPI_Bcast-Operation verteilen, wobei wir die Möglichkeiten von M P I beim Umgang mit Speicherausrichtung, gemischten Typen und heterogener Kommunikation ausnutzen wollen. Wir zeigen hierfür zwei Herangehensweisen. MPI_Type_struct ist sehr allgemein und ermöglicht uns, eine Kollektion von Datenelementen aus verschiedenen elementaren und abgeleiteten Datentypen als einen einzelnen
138
5 Weitergehende Details zum Message-Passing mit MPI
Datentyp zu beschreiben. Dabei werden die Daten als aus einer Menge von „Datenblöcken" zusammengesetzt betrachtet. Jedem dieser Blöcke ist eine Anzahl und ein Datentyp sowie eine Position, die als Verschiebung zu verstehen ist, zugeordnet. Die Anweisungen für die Vorbereitung des Versendens der obigen Struktur würden wie folgt aussehen: /* set up 4 blocks */ int blockcounts [4] = -[50,1,4,2}; MPI_Datatype types [4]; MPI_Aint displs [4]; MPI_Datatype cmdtype; /* initialize types and displs MPI_Address( &cmdline.display, MPI_Address( fecmdline.maxiter, MPI_Address( &cmdline.xmin, MPI_Address( fecmdline.width, types [0] = MPI_CHAR; types [1] = MPI_INT; types[2] = MPI_D0UBLE; types[3] = MPI_INT;
with addresses of items */ &displs[0] ); &displs[l] ); &displs[2] ); &displs[3] );
Das Feld b l o c k c o u n t s gibt an, wieviele Elemente es für jeden entsprechenden Typ gibt. In unserem Beispiel gibt es das Feld char d i s p l a y [50], also erhält b l o c k c o u n t s [0] den Wert 50. Der Wert von b l o c k c o u n t s [1] ist 1, denn er gehört zu dem allein stehenden i n t , dem nächsten Element (maxiter) in der Struktur. Nach m a x i t e r folgen vier double-Werte, beginnend mit xmin, somit ist b l o c k c o u n t s [2] gleich 4. Die letzten Elemente sind zwei int-Werte, folglich setzen wir b l o c k c o u n t s [3] auf 2. Jetzt können wir die Broadcast-Operation vorbereiten. Zuerst passen wir das Feld mit den Verschiebungen so an, dass die Verschiebungen die Abstände ( o f f s e t s ) vom Anfang der Struktur darstellen: for (i = 3; i >= 0; i — ) displs [i] - = displs [0];
Dann bilden wir den neuen Typ MPI_Type_struct( 4, blockcounts, displs, types, fccmdtype ); MPI_Type_conunit( fccmdtype );
und senden die Daten unter Benutzung dieses neuen Typs von Prozess 0 an alle anderen: MPI_Bcast( ftcmdline, 1, cmdtype, 0, MPI_C0MM_W0RLD );
Es gibt eine Alternative zu der Art, wie die Verschiebungen dargestellt werden. Die Verschiebungen müssen nicht relativ zum Anfang einer bestimmten Struktur angegeben
139
5.3 Visualisierung der Mandelbrotmenge
werden, sondern können ebenso „absolute" Adressen sein. In diesem Fall sehen wir sie als relativ zur Anfangsadresse im Speicher an, die mit MPI_B0TT0M ermittelbar ist. Wenn wir diese Technik anwenden, können wir die Schleife zur Anpassung der Verschiebungen einsparen und sie so belassen, wie sie durch die Aufrufe von MPI_Address gegeben wurden. Der Aufruf von MPI_Bcast wird in diesem Fall zu MPI_Bcast( MPI.BOTTOM, 1, cmdtype, 0, MPI_C0MM_W0RLD );
geändert.
5.3.1
Senden von Feldern von Strukturen
Wenn mehr als ein struct zu senden ist, wenn also ζ. B. das Argument count größer als eins ist, könnte das einen zusätzlichen Schritt bei der Erzeugung des Datentyps erfordern. Wir wollen dazu den Datentyp struct { int a; char b; } my_struct, struct_array[10];
betrachten. Für diese Struktur könnte man den in der Abbildung 5.15 gezeigten MPIQuelltext benutzen, um einen entsprechenden Datentyp zu erzeugen. Das Programmstück in dieser Abbildung könnte funktionieren. Um die Problematik besser zu verstehen, wollen wir uns Abbildung 5.16 ansehen. Die Abbildung zeigt mögliche Anordnungen für das Feld struct_array im Speicher. Wie man sehen kann, muss der int MPI.Datatype MPI.Aint MPI_Datatype
blockcounts[2] = {1,1}; types[2]; displs[2]; structtype;
/* initialize types and displs with addresses of items */ MPI_Address( &my_struct.a, &displs[0] ); MPI_Address( &my_struct.b, &displs[l] ); types [0] = MPI.INT; types[1] = MPI.CHAR; /* Make displs relative */ displs [1] - = displs[0]; displs [0] = 0; MPI_Type_struct( 2, blockcounts, displs, types, fcstructtype ); MPI_Type_commit( ftstmcttype );
Abbildung 5.15: schnitt 5.6
Code zur Erzeugung
eines Datentyps
für eine Struktur,
siehe auch Ab-
140
5 Weitergehende Details zum Message-Passing mit M P I
(a)
(b)
a
b
a
b
a
b
(c) Abbildung 5.16: Drei mögliche Anordnungen der Struktur struct.array natürlich (a), gepackt (b) und gepackt mit 2-Byte-Speicherausrichtung
im Speicher:
Anfang des ersten Elements von struct_array [i+1] nicht unmittelbar auf das Ende des letzten Elements von struct_array [i] folgen. Nun kann die MPI-Bibliothek nicht wissen, wie der Ubersetzer das Ablegen von Strukturen im Speicher gewählt hat. Für einige Ubersetzer gibt es Optionen, mit denen zwischen verschiedenen Möglichkeiten gewählt werden kann (ζ. B. natürliche Anordnung, gepackt, gepackt unter Verwendung einer 2-Byte-Ausrichtung usw.). In Abbildung 5.16 sind drei mögliche Anordnungen skizziert. Somit muss die MPI-Bibliothek unter Umständen feststellen, wo das „natürliche" Ende der Struktur ist. Der von uns benutzte Quellcode vertraut auf eine korrekte Wahl durch MPI. Für robuste und portierbare Programme ist es natürlich nicht erwünscht, sich auf Vermutungen zu verlassen. Wie können wir sichern, dass die Arbeit mit structtype korrekt abläuft? Wie das zu schaffen ist, werden wir im nächsten Abschnitt erfahren, doch zuvor wollen wir einen verwandten Fall, das Auftreten von Lücken zwischen den Elementen eines Datentyps, betrachten.
5.4
Lücken in Datentypen
Im Abschnitt 4.8 haben wir die Routine MPI_Type_vector eingeführt und sie zur Erzeugung eines Datentyps mit vordefinierter Anzahl von Elementen angewendet. Was aber kann man tun, wenn mit einer variablen Anzahl von Elementen gearbeitet werden soll? Wir wollen natürlich gern das count-Argument der Sende- und Empfangsoperationen verwenden. Deshalb müssen wir einen Datentyp angeben, der aus den Elementen, gefolgt von einem „Sprung" (engl.: skip) zum nächsten Element, besteht. Diesen können wir mit MPI_Type_struct und mit einem speziellen vordefinierten MPI-Datentyp, der mit MPIJJB bezeichnet wird, definieren. MPIJJB (UB steht für Upper Bound, obere Grenze) ist ein Datentyp, der keine Größe besitzt, aber dazu dient, die Länge eines Datentyps zu ändern. Platzieren wir MPIJJB mit einer Verschiebung, die dem Abstand in Bytes zwischen aufeinander folgenden Elementen entspricht, können wir über diese Bytes springen, wenn wir den Datentyp benutzen.
5.4 Lücken in Datentypen
141
int blens[2]; MPI.Aint displs[2]; MPI_Datatype t y p e s [ 2 ] , rowtype; b l e n s [0] = 1; b l e n s [1] = 1; d i s p l s [ 0 ] = 0; d i s p l s [1] = number_in_column * s i z e o f ( d o u b l e ) ; t y p e s [ 0 ] = MPI_D0UBLE; t y p e s [ 1 ] = MPI.UB; MPI_Type_struct( 2, b l e n s , d i s p l s , t y p e s , ftrowtype MPI_Type_commit( &rowtype ) ; Abbildung
5.17:
Code zum Aufbau eines allgemeinen
„strided"
);
Vektortyps
In der Abbildung 5.17 sind die Anweisungen zu sehen, mit denen man einen MPIDatentyp für ein Feld mit number_in_column vielen Gleitkommazahlen doppelter Genauigkeit bilden kann (hier wird die in Fortran übliche Speicherungsform für Matrizen angenommen). Wir haben hier eine gute Gelegenheit, die Bedeutung zusammenhängender Datentypen in MPI und ihrer Längen besser verstehen zu lernen. Wir betrachten hierzu folgenden Versuch zur Definition eines Datentyps, der den Zugriff auf die Zeilen einer spaltenweise gespeicherten Matrix erlaubt: MPI_Type_vector( 1, 1, number_in_column, MPI.DOUBLE, &rowtype2 ) ; MPI_Type_commit( &rowtype2 ) ; Unsere Absicht ist die Benutzung des count-Arguments, um eine gewünschte Anzahl von Elementen zu senden, so wie es bei dem weiter oben konstruierten Datentyp rowtype erfolgte. Jedoch ist die Länge von rowtype2 der Abstand vom ersten zum letzten Byte im Datentyp; und da rowtype2 nur einen einzelnen double-Wert enthält, ist die Länge gerade die Größe eines double-Werts. Deshalb wird eine Sende- oder Empfangsoperation bei Verwendung des Datentyps rowtype2 hier aufeinander folgende double-Werte, also nicht durch number_in_column getrennte double-Werte, benutzen. Der Datentyp MPIJ^B ist sehr ähnlich zu MPIJJB, nur dass er analog zum Setzen der unteren Grenze eines abgeleiteten Datentyps angewendet werden kann. Damit wird ebenso die Länge eines Datentyps geändert.
5.4.1
Funktionen in MPI-2 zur Manipulation der Länge eines Datentyps
Die Anwendung von MPIJJB und MPI_LB ist in MPI-2 unerwünscht, weil diese Werte kritisch sind: wenn ein Datentyp ein MPIJJB oder MPI-LB enthält, wird jeder aus diesem Typ konstruierte Datentyp diese Werte MPIJJB und MPIJJB auch enthalten. Damit wird die Länge aus diesen Werten ermittelt, anstatt aus den Verschiebungen der Datenelemente.
142
5 Weitergehende Details zum Message-Passing mit M P I
int MPLType_create_resized(MPLDatatype oldtype, MPLAint Ib, MPIJ\int extent, MPLDatatype *newtype) int MPI_Type_get_true_extent(MPI.Datatype datatype, MPLAint *true_lb, MPI-Aint *true_extent)
Tabelle 5.28: C-Signaturen der MPI-2-Funktionen zur Datentyplänge
Außerdem ist es unmöglich, die obere Grenze oder die untere Grenze eines Datentyps, der MPIJJB oder MPI_LB enthält, zu verkleinern bzw. zu vergrößern. Da das eigentliche Anliegen der Anwendung dieser speziellen Datentypen die Konstruktion neuer Datentypen mit unterschiedlicher Länge war, fügte das MPI-Forum zwei neue Routinen hinzu, um die Länge eines Datentyps festzustellen und ändern zu können. Die Routine MPI_Type_createjresized erzeugt aus einem MPI-Datentyp einen neuen Datentyp mit anderer oberer und unterer Grenze. Alle vorherigen oberen und unteren Grenzen werden verworfen. Das MPI-Forum regt nachdrücklich dazu an, die Benutzung von MPIJJB und MPI J^B mit MPI_Type_struct durch MPI_Type_create_resized zu ersetzen. Wenn die gerade verfügbare MPI-Implementierung diese Routine nicht enthält, muss natürlich mit der Verfahrensweise von MPI-1 weiter gearbeitet werden. Abbildung 5.18 zeigt den Ansatz von MPI-2 für den in Abbildung 5.17 gegebenen Code. Der neue Typ rowtype ist wegen der oben angeführten Gründe keine exakte Ersetzung für die Version in Abbildung 5.17. In den meisten Fällen jedoch erreicht er denselben Effekt und kann zudem effizienter implementiert werden. Gleichermaßen ist es mitunter erforderlich, die tatsächliche Länge eines Datentyps herauszufinden, also die unteren und oberen Grenzen (oder die minimalen und maximalen Verschiebungen) eines Datentyps. Das ist mit MPI-1 nicht möglich: die von den Routinen MPI_Type_ub und MPI_Type_lb gelieferten Werte der oberen bzw. unteren Grenze sind nur richtig, wenn kein MPIJJB oder MPIJLB bei der Konstruktion des Datentyps verwendet wurde. Das war für das MPI-Forum der Grund, auch diese zwei Routinen als „zu vermeiden" auszuweisen und dafür die Routine MPI_Type_get_true_extent einzuführen. Diese Routine gibt die untere Grenze und die Länge eines Datentyps zurück und ignoriert dabei jede Markierung einer oberen und unteren Grenze (die entweder MPI_Aint displs; MPI_Datatype rowtype; displs = number_in_column * sizeof(double); MPI_Type_create_resized( MPI_DOUBLE, (MPI_Aint)0, displs, ferowtype ); MPI_Type_commit( ferowtype ); Abbildung 5.18: Code zum Außau eines allgemeinen „strided" Vektortyps, vergleiche mit Abbildung 5.17
5.5 Neue Datentyp-Routinen in MPI-2
143
MPI_TYPE_CREATE_RESIZED(oldtype, lb, extent, newtype, ierror) integer oldtype, newtype, ierror integer(kind=MPI_ADDRESS_KIND) lb, extent MPI_TYPE_GET_TRUE_EXTENT(datatype, trueJb, true_extent, ierror) integer datatype, ierror integer(kind=MPI_ADDRESS_KIND) trueJb, true.extent
Tabelle 5.29: Fortran-Signaturen der MPI-2-Routinen zur Datentyplänge Datatype MPI::Datatype::Create_resized(const MPI::Aint Ib, const MPI::Aint extent) const void Μ ΡI:: Datatype: :Get_true_extent( Μ PI:: Ai nt& trueJb, MPI::Aint& true.extent) const
Tabelle 5.30: C++-Signaturen der MPI-2-Methoden zur Datentyplänge
explizit mit MPI_UB und MPI_LB oder mit MPI_Type_create_resized gesetzt wurde). Zu beachten ist hier, dass sich dieser Wert für die Länge von dem Wert unterscheiden kann, der von MPI_Type_size zurückgegeben wird: MPI_Type_size liefert die Anzahl der Bytes, die benötigt werden, um die Datenwerte im Datentyp darzustellen, ignoriert aber die Verschiebungen. Die Routine MP I _Type _ge t _t rue.ext ent ermittelt die wirkliche Anzahl der Bytes, um einen Datentyp aufnehmen zu können, eingerechnet jede Lücke zwischen den Datenwerten, die durch die Verschiebungen verursacht werden. So hat zum Beispiel der durch
call MPI_TYPE_VECTOR( 10, 1, 20, MPI_D0UBLE_PRECISI0N, & vectype, ierr )
konstruierte Vektortyp, der zehn Gleitkommazahlen doppelter Genauigkeit enthält, die durch Schritte der Länge 20 voneinander getrennt sind, eine Größe von 80 Bytes (ermittelt mit der Routine MPI_Type_size bei einer Länge von 8 Bytes für DOUBLE PRECISION) und eine wirkliche Länge von 1448 ( = (10 - 1) χ 20 x 8 + 8 ) Bytes.
5.5
Neue Datentyp-Routinen in MPI-2
Neben den im vorherigen Abschnitt besprochenen Routinen hat das MPI-Forum weitere Routinen ersetzt. Die Notwendigkeit dieser neuen Routinen kann man erkennen, wenn man sich die Anwendung von MPI_Address in einem Fortran-Programm verdeutlicht.
144
5 Weitergehende Details zum Message-Passing mit M P I
M P I _ G E T _ A D D R E S S ( l o c a t i o n , address, ierror) location(*) integer ierror integer(kind=MPI_ADDRESS-KIND) address M P I _ T Y P E _ C R E A T E _ H V E C T O R ( c o u n t , blocklength, stride, oldtype, newtype, ierror) integer count, blocklength, oldtype, newtype, ierror integer(kind=MPI_ADDRESS_KIND) stride
MPI_TYPE_CREATE_HINDEXED(count, array.of.blocklengths, array_of.displacements, oldtype, newtype, ierror) integer count, array_of_blocklengths(*), oldtype, newtype, ierror integer(kind=MPI_ADDRESS_KII\ID) array.of.displacements(*)
MPI_TYPE_CREATE_STRUCT(count, array_of_blocklengths, array_of_displacements, array_of_types, newtype, ierror) integer count, array _of_blocklengths(*), array_of_types(*), newtype, ierror integer(kind=MPI_ADDRESS_KIND) array_of_displacements(*) Tabelle
5.31:
Fortran-Signaturen
der neuen MPI-2
Datentyp-Routinen
Die Anweisungsfolge i n t e g e r iadd, i e r r o r double p r e c i s i o n a ( 1 0 0 0 0 ) c a l l MPI_Address( a , i a d d , i e r r o r ) sieht sehr einfach aus, beinhaltet jedoch ein Problem. Wir stellen uns vor, dass die Maschine, die mit diesen Anweisungen arbeitet, 64-Bit-Zeiger benutzt und dass Gleitkommazahlen doppelter Genauigkeit ebenfalls mit 64 Bits realisiert werden (ζ. B. durch Verwendung von 64-Bit I E E E Gleitkomma-Arithmetik). Das wird immer häufiger üblich, da man bei vielen Rechnersystemen zu 64-Bit-Adressen übergeht. Der Fortran-Standard schreibt jedoch vor, dass Datenelemente vom Typ i n t e g e r und r e a l dieselbe Größe haben und dass Werte des Typs double p r e c i s i o n doppelt so groß wie r e a l - W e r t e sind. Das bedingt 3 , dass sowohl i n t e g e r als auch r e a l 32 Bit lang sein müssen. Das Problem besteht nun darin, dass 64 Bits für die Adressendarstellung erforderlich sind, aber ein i n t e g e r in Fortran auf solchen Systemen nur 32 Bit lang ist. Damit kann, als eine Konsequenz, MPI-Address keinen verwendbaren Wert im Parameter iadd angeben. Das MPI-Forum beseitigte dieses Problem mit der Einführung neuer Routinen, die dort, wo Adressen bezogene (engl.: address-valued) Integer benutzt 3 Das gilt eigentlich nicht unbedingt, denn prinzipiell kann ein Übersetzer dies umgehen. In der Praxis wenden aber alle korrekten Übersetzer diese Regel an.
145
5.6 Weiteres zu Datentypen für Strukturen int MPLGet_address(void *location, MPLAint *address) int MPLType_create_hvector(int count, int blocklength, MPLAint stride, MPLDatatype oldtype, MPI.Datatype *newtype) int MPI_Type_create_hindexed(int count, int array_of_blocklengths[], MPI_Aint array_of.displacements[], MPI.Datatype oldtype, MPLDatatype *newtype) int MPI_Type_create_struct(int count, int array_of-blocklengths[], MPI_Aint array_of_displacements[], MPLDatatype array_of.types[], MPLDatatype *newtype)
Tabelle
5.32:
C-Signaturen
der neuen MPI-2
Datentyp-Funktionen
werden, eine andere Integer-Darstellung verwenden (dem wird in den MPI C- und C++Signaturen durch Verwendung des Datentyps MPI Ji.int anstelle von i n t Rechnung getragen). Dieser Integer-Typ wird in Fortran mit INTEGER (ΚIND=MPI_ADDRESS _KIND) bezeichnet. MPI_Address wird durch MPI_Get_address ersetzt. Drei weitere Routinen arbeiten mit Adressen bezogenen Argumenten: MPI_Type_h.vector, MPI_Type_hindexed und MPI_Type_struct — sie werden ersetzt durch MPI_Type_createJivector, MPI_Type_create Jiindexed bzw. MPI_Type_create_struct. Die vier neuen Routinen sind in der Tabelle 5.31 für Fortran und in Tabelle 5.32 für C zusammengestellt. Die CRoutinen sind die gleichen wie die entsprechenden Versionen in MPI-1; die FortranRoutinen sind gleich bis auf die Benutzung von MPI_ADDRESS_KIND als Integertyp für die Adressen bezogenen Argumente. Zu bemerken ist, dass es keine C++-Methoden bzw. -Signaturen für die MPI-1-Versionen dieser Routinen gibt. Das MPI-Forum empfiehlt, diese Routinen den entsprechenden Versionen aus MPI-1 vorzuziehen. Das sollte man auch tun, wenn sich Implementierungen dieser Routinen (entweder in vollständigen oder in partiellen MPI-2-Implementierungen) verbreiten.
5.6
Weiteres zu Datentypen für Strukturen
Nachdem wir nun wissen, wie eine Lücke am Ende eines kann, können wir uns ansehen, wie ein Datentyp für das bildung 5.15 zu erzeugen ist. Mit MPI-1 müssen wir ein turdefinition aufnehmen, um MPIJJB abzulegen, so wie wird.
Datentyps spezifiziert werden Beispiel s t r u c t t y p e aus Abdrittes Element in die Strukes in Abbildung 5.19 gezeigt
Wenn MPI_Type_createjresized verfügbar ist, sollte man diese Routine natürlich anstelle von MPIJJB einsetzen. Eine mögliche Verbesserung besteht darin, den Quellcode aus Abbildung 5.15 zu benutzen und die Länge dieses Typs mit der Größe des Datentyps zu vergleichen. Sind beide Werte nicht gleich, muss der Datentyp angepasst werden, wie es in der Abbildung 5.20 dargestellt wird.
146
5 Weitergehende Details zum Message-Passing mit MPI int MPI.Datatype MPI.Aint MPI_Datatype
blockcounts[3] = {1,1,1}; types[3]; displs [3]; structtype;
/* initialize types and displs with addresses of items */ MPI_Address( &struct_array[0].a, &displs[0] ) MPI_Address( &struct_array[0].b, &displs[l] ) MPI_Address( &struct_array[1].a, &displs[2] ) types [0] = MPI_INT; types[1] = MPI_CHAR; types[2] = MPI.UB; /* Make displs relative */ displs[1] - = displs[0]; displs[2] - = displs[0]; displs[0] = 0; MPI_Type_struct( 3, blockcounts, displs, types, fcstructtype ); MPI_Type_commit( festructtype );
Abbildung 5.19: Code zur Konstruktion eines Datentyps für eine Struktur, bei dem nicht darauf vertraut wird, dass MPI die gleichen Regeln für die Füllung der Struktur anwendet wie der C- Ubersetzer
Der MPI-Standard fordert, dass MPI die Daten exakt so übergibt, wie sie durch einen Datentyp spezifiziert sind. Das heisst in anderen Worten, falls es in einer Struktur Zwischenräume oder Auffüllungen gibt, darf MPI die „Lücken" nicht entfernen. Deshalb muss die MPI-Implementierung sorgfältig über Auffüllungen in einer Struktur springen. Diese Auffüllungen haben keine Bedeutung für die Anwendung, aber deren Uberspringen kann erhebliche Leistungseinbußen verursachen. Es gibt zwei Möglichkeiten, die Leistungsfähigkeit einer Anwendung, die Strukturen versendet, zu verbessern. Am besten ist es, den Aufbau der Struktur umzuordnen, um die Lücken zu entfernen. Das kann man häufig durch Ordnen der Elemente der Struktur nach deren Länge, beginnend mit den längsten Elementen, danach die nächst längsten usw., erreichen. So wird zum Beispiel die Umordnung der Struktur struct { int a; double b; int c; } struct_a;
zu
5.6 Weiteres zu Datentypen für Strukturen
147
struct { double b; int a; int c; } struct_a;
oft eine Struktur ohne Lücken ergeben. Um Auffüllungen am Ende einer Struktur zu eliminieren (siehe Abschnitt 5.3.1), ist das Hinzufügen einer Scheindeklaration eines Datenelements der erforderlichen Größe ein probates Mittel. int MPI_Datatype MPI_Aint MPI_Datatype
blockcounts [2] = {1,1}; types [2]; displs[2], s_extent; structtype;
/* initialize types and displs with addresses of items */ MPI_Get_address( &struct_array[0].a, &displs[0] ); MPI_Get_address( &struct_array[0].b, &displs[l] ); types[0] = MPI_INT; types [1] = MPI.CHAR; /* Make displs relative */ displs [1] - = displs[0]; displs [0] = 0; MPI_Type_create_struct( 2, blockcounts, displs, types, festructtype ); /* Check that the datatype has the correct extent */ MPI_Type_extent( structtype, &s_extent ); if (s_extent != sizeof(struct_array[0])) { MPI_Datatype sold = structtype; MPI_Type_create_resized( sold, 0, sizeof(my_struct), festructtype ); MPI_Type_free( fcsold ); > MPI_Type_commit( ftstructtype );
Abbildung 5.20: Code zur Konstruktion eines Datentyps für eine Struktur, bei dem nicht darauf vertraut wird, dass MPI die gleichen Regeln für die Füllung der Struktur anwendet wie der C-Ubersetzer, hier mit Anwendung von MPI-2-Routinen
Die zweite Technik besteht darin, die Portierbarkeit auf Systeme heterogener Rechner zugunsten der Leistung aufzugeben, indem die Arbeit mit MPI_Type_struct gänzlich vermieden wird. Die Daten werden stattdessen als Typ MPI_BYTE gesendet. Die Größe der Struktur wird hierbei mit dem C-Operator sizeof ermittelt (es wird wieder angenommen, dass ein char ein Byte lang ist). Während wir generell das Schreiben bestmöglichst portierbarer Programme bevorzugen, kann die Arbeit mit MPIJ3YTE eine vernünftige Alternative sein, sofern die Leistung im Vordergrund steht. Hierbei ist zu beachten, dass die Anwendung von MPIJ3YTE im Vergleich zu anderen vordefinierten
148
5 Weitergehende Details zum Message-Passing mit MPI
MPI-Datentypen, wie ζ. B. MPI_INT oder MPI_DOUBLE_PRECISION, keinen Leistungsvorteil bringt.
5.7
Veraltete Funktionen
Die Erfahrungen mit MPI führten zu einem besseren Verständnis für die Anwendungsanforderungen und schließlich zur Erarbeitung besserer Möglichkeiten für die Umsetzung der Anforderungen gegenüber denen der ursprünglichen (MPI-1) Spezifikation. Gleichzeitig wollte das MPI-Forum aber den Standard nicht in einer solchen Weise verändern, dass vorhandene MPI-Programme plötzlich ihre Portierbarkeit verlieren würden. Deshalb wurde beschlossen, bestimmte Funktionen als „veraltet" (engl.: deprecated) zu erklären. Das bedeutet, dass diese Funktionen zwar weiterhin Bestandteil des MPIStandards sind und sie von Implementierungen unterstützt werden müssen, aber dass man sie in den Anwendungen zugunsten der neuen (MPI-2) Möglichkeiten zum Erreichen der gleichen Ziele irgendwann aufgeben sollte. Man ist sich einig, dass diese veralteten Funktionen auf lange Sicht aus dem Standard verschwinden können. Wir veraltet in MPI-1 MPI_ADDRESS MPI_TYPE_HINDEXED MPI_TYPE_HVECTOR MPI_TYPE_STRUCT MPI_TYPE_EXTENT MPI_TYPE_UB MPI_TYPE_LB MPI_LB MPI_UB MPI_ERRHANDLER_CREATE MPI _ERRHANDLER_GET MP I _ERRHANDLER_SET MPI_Handler Junction MP I_KEYVAL-CREATE MPI_KEYVAL_FREE MPI_DUP_FN MPI_NULL_COPY_FN MPIJiULL_DELETE_FN MPI_Copy Junction COPY-FUNCTION MPI_Delete Junction DELETE_FUNCTION MPI_ATTR_DELETE MPI_ATTR_GET MPI_ATTRJ>UT
erneuert in MPI-2 MPI_GET_ADDRESS MP I _TYPE_CREATE_HINDEXED MPI_TYPE_CREATE_HVECTOR MPI_TYPE_CREATE_STRUCT MPI_TYPE_GET_EXTENT MP I _T YPE _GET -EXTENT MP I _TYPE_GET_EXTENT MP I _TYPE_CREATE_RES I ZED MP I _TYPE_CREATE_RES I ZED MPI_COMM_CREATE_ERRHANDLER MPI_COMM_GET_ERRHANDLER MPI_COMM_SET_ERRHANDLER MPI_Comm_errhandler J n MPI_COMM_CREATE_KEYVAL MPI_COMM_FREE_KEYVAL MPI_COMM_DUP_FN MPI_COMM_NULL_COPY_FN MPI_COMM_NULL_DELETE_FN MPI_Comm_copy_attr Junction COMM_COPY_ATTR_FN MPI_Comm_delete_attr Junction COMM_DELETE_ATTR_FN MPI_COMM_DELETE_ATTR MPI_COMM_GET_ATTR MPI_COMM_SET_ATTR
Tabelle 5.33: Veraltete Funktionen, Konstanten und Typdefinitionen
5.8 Typische Fehler und Missverständnisse
149
nutzen an dieser Stelle die Gelegenheit, alle veralteten Funktionen von MPI-1 und die entsprechenden neuen aus MPI-2 (in Tabelle 5.33) aufzulisten. Zu Details der MPI-2Funktionen kann man sich in [GHLL+98] informieren.
5.8
Typische Fehler und Missverständnisse
In diesem Kapitel wurde wenigstens eine Thematik eingeführt, die Anlass für Missverständnisse sein kann. Verwechslung der Spezifikation v o n M P I - D a t e n t y p e n mit ihrer I m p l e m e n tierung. Die Datentypen in MPI erlauben eine optimierte Leistung, indem sie der Implementierung exakte Informationen zur Datenanordnung für das Senden oder Empfangen geben. Mit Hardwareunterstützung kann die Verwendung der MPI-Datentypen zusätzliche Leistungsreserven freisetzen. Ohne Hardwareunterstützung jedoch kann die Implementierung von Datentypen entweder ausgefeilt und schnell oder einfach und langsam sein. Eine schlechte Implementierung von Datentypen kann den Anwender dazu veranlassen, zusammenhängende Puffer anzulegen und nicht zusammenhängende Daten lieber „per Hand" in diese zu kopieren. Unsere Untersuchungen [GLS99b] zeigen, dass die Hersteller sehr viel Raum für Verbesserungen bei der Implementierung von Datentypen haben. Und wir erwarten, dass diese Verbesserungen letztlich wirksam werden, wenn sich die Untersuchungen zu MPI-Implementierungen allmählich in der gesamten Gemeinschaft derer, die mit diesen Implementierungen befasst sind, durchsetzen. Auch wenn es bei einigen MPI-Implementierungen noch zu schnelleren Anwendungen führt, wenn man die Puffer selbst packt, sollte man nicht zu schnell Abstand von den MPIDatentypen nehmen!
6
Parallele Bibliotheken
Eines der wichtigsten Anliegen bei der Erarbeitung von MPI war es, die Entwicklung paralleler Bibliotheken zu ermöglichen. Bibliotheken haben sich als entscheidend bei der Entwicklung einer Softwaregrundlage für sequentielle Rechner erwiesen. Die fehlende Modularität in älteren Message-Passing-Systemen behinderte eine vergleichbare Entwicklung von zuverlässigen parallelen Bibliotheken, also solchen, die unabhängig voneinander und vom Anwenderprogramm geschrieben werden können und dennoch innerhalb einer Anwendung zusammen arbeiten. Wir beginnen dieses Kapitel mit der Diskussion einiger Probleme, die durch parallele Bibliotheken hervorgerufen werden. Wir beschreiben einige der Unzulänglichkeiten von bisherigen Message-Passing-Systemen aus Sicht des Bibliotheksentwicklers und zeigen die Besonderheiten der MPI-Definition auf, die zur Beseitigung dieser Schwächen eingeführt wurden. Danach erläutern wir ein Beispiel einer sehr einfachen Bibliothek, die nur zwei Funktionen enthält, aber eine Reihe von besonderen Fähigkeiten von MPI zur Unterstützung von Bibliotheken illustriert. Als Beispiel eines Gebietes, das typisch für die Arbeit mit parallelen Bibliotheken ist, gehen wir in einem der Abschnitte auf das Zusammenspiel zwischen linearer Algebra und partiellen Differentialgleichungen ein. Wir beschreiben auch kurz einige Aspekte der Anwendung von MPI bei der Lösung eines dicht besetzten Systems linearer Gleichungen. Beschließen werden wir das Kapitel mit einer Erörterung zu generellen Strategien für die Erstellung paralleler Bibliotheken.
6.1
Motivation
Dieser Abschnitt zeigt die Motivation für Bibliotheken auf und beschreibt die speziellen Entwurfsmerkmale, die bei der Erarbeitung paralleler Bibliotheken helfen.
6.1.1
Die Notwendigkeit paralleler Bibliotheken
Softwarebibliotheken haben mehrere Vorteile: • sie sichern die Konsistenz hinsichtlich der Korrektheit der Programme, • sie helfen, eine Implementierung von hoher Qualität zu garantieren, • sie verbergen ablenkende Details und die Komplexität von aktuellen Implementierungen und • sie minimieren monotone Arbeit.
152
6 Parallele Bibliotheken
Bereits in sequentiellen Bibliotheken bauen die Entwickler zahlreiche Heuristiken und spezifische „Tricks" ein. Durch das Einbringen von Parallelität auf Basis des MessagePassing wächst die „Detailarbeit" bei typischen numerischen Methoden signifikant. Diese Umstände motivieren nachdrücklich die Entwicklung von parallelen Bibliotheken, die diese Komplexität aufnehmen und vor dem Anwender verbergen. MPI ist die grundlegende Systembibliothek, die hilft, wissenschaftliche wie anwendungsorientierte parallele Bibliotheken sowohl zuverlässig als auch alltäglich zu machen.
6.1.2
Bekannte Schwächen älterer Message-Passing-Systeme
In den sequentiellen Fortran- und C-Umgebungen kann man Bibliotheken leicht erstellen, da das auf Stacks orientierte prozedurale Programmiermodell über wohldefinierte Bedingungen verfügt, die ein Programm als zuverlässig oder fehleranfällig gelten lässt. In Umgebungen mit verteiltem Speicher und Message-Passing jedoch sind Bibliotheken für Herstellersoftware oder portierbare Systeme schwer zu entwickeln. Hier gibt es ein Modularitätsproblem: Eine Bibliothek erfordert einen Kommunikationsraum, der vom Kommunikationsraum des Anwenders präzise abgegrenzt ist. Ihre Kommunikationsmuster werden unabhängig vom Anwenderprogramm, zu dem sie gebunden wird, entworfen. Das offenkundigste Problem besteht darin, dass eine durch die Bibliothek gesendete Nachricht versehentlich vom Anwenderprogramm empfangen wird oder umgekehrt. Man könnte argumentieren, dass Etiketten zur Festlegung von Beschränkungen bei der Nachrichtenzustellung wirkungsvoll einsetzbar seien. Solche Etiketten erweisen sich aber aus verschiedenen Gründen als unzureichend. So könnten mehr als eine Bibliothek (oder Einbindung derselben Bibliothek) die gleichen Etiketten verwenden. Zweitens zerstört die Empfangsauswahl über Platzhalter bei Etiketten (ζ. B. MPI_A.NY_TAG) jede Aussicht auf wirklichen Schutz, den Etiketten sonst gewähren könnten. Deshalb werden parallele Bibliotheken oft so geschrieben, dass die Ausführung von Anwenderund Bibliotheksanweisungen konsequent wechselt, wobei sorgfältig darauf geachtet wird, dass keine Nachrichten unterwegs sind, wenn die Kontrolle vom Anwender an die Bibliothek (und umgekehrt) gegeben wird (dieser Zustand wird meist mit Stille (engl.: quiescence) bezeichnet). Dieser vom sequentiellen Fall geerbte Entwurfstyp führt Synchronisationsbarrieren ein, die die Leistung einschränken und zudem andere Probleme ungelöst lassen. Diese Problematik kann mit einem einfachen Beispiel illustriert werden. Angenommen, ein Programm ruft die zwei in der Abbildung 6.1 gezeigten Routinen SendRight und SendEnd auf. In der Abbildung 6.2 sind zwei mögliche Ausführungsabläufe für diese Routinen dargestellt. Im Teil (b) dieser Abbildung werden die Nachrichten von der falschen Routine empfangen, da die Nachricht von Routine SendEnd im Prozess 0 bei Prozess 2 eintrifft, bevor die Nachricht von SendRight im Prozess 1 den Prozess 2 erreicht. Die Ursache hierfür ist die Verwendung von MPI_ANY_SOURCE in beiden Routinen dieses Beispiels. Während die Routinen hier stattdessen mit expliziter Angabe der Quelle geschrieben werden könnten, ist in anderen Fällen (speziell in Master/Worker-Ansätzen) die Arbeit mit MPI_ANY_SOURCE nicht zu vermeiden.
153
6.1 Motivation void SendRight( int *buf, int rank, int size ) { MPI_Status status; if (rank + 1 < size) MPI_Send( buf, 1, MPI.INT, rank+1, 0, MPI_C0MM_W0RLD ); if (rank > 0) MPI_Recv( buf, 1, MPI.INT, MPI_ANY_S0URCE, 0, MPI_C0MM_W0RLD, ftstatus );
}
void SendEnd( int *buf, int rank, int size ) { MPI_Status status; if (rank == 0) MPI_Send( buf, 1, MPI.INT, size-1, 0, MPI_C0MM_W0RLD ); if (rank == size - 1) MPI_Recv( buf, 1, MPI.INT, MPI_ANY_S0URCE, 0, MPI_C0MM_W0RLD, fcstatus );
> Abbildung werden
6.1: Die zwei Routinen,
deren mögliche Nachrichtenmuster
in Abbildung 6.2 gezeigt
Man könnte denken, dass dieses Problem nicht auftritt, wenn MPI_ANY_S0URCE nicht verwendet wird. Für die Anweisungen in Abbildung 6.3 sind zwei verschiedene Abarbeitungsfolgen möglich, die in Abbildung 6.4 gezeigt werden. In diesem Beispiel werden weder MPI_ANY_S0URCE noch MPI_ANY_TAG benutzt, die Nachrichten werden aber dennoch an die falschen Stellen übermittelt. Diese beiden Beispiele deuten die schlimmste Fehlerart an: ein Programm, das zwar läuft, aber mit falschen Daten. Es können ähnliche Fälle gezeigt werden, in denen ein Programm anhält, weil eine Nachricht nicht vom richtigen Prozess empfangen wurde. Zu beachten ist bei diesem Quellcode, dass Bibliothek 2 nichts tun kann, um zu garantieren, dass sie die in StartSend aus Bibliothek 1 gesendete Nachricht nicht abfängt, wenn beide Bibliotheken denselben Kommnunikator nutzen. MPI-Kommunikatoren lösen die in den Abbildungen 6.1 und 6.3 illustrierten Schwierigkeiten, indem sie verschiedene Kommunikationskontexte zur Verfügung stellen. Wenn die verschiedenen Routinen (z.B. SendRight und SendEnd in Abbildung 6.1) verschiedene Kommunikatoren benutzen, garantiert MPI den Empfang der Nachricht durch die tatsächlich gemeinte Routine. Kommunikatoren stellen auch eine elegante Vorgehensweise bereit, mit der andere Abstraktionen, die mit Prozessgruppen verwandt sind, ausgedrückt werden können. In MPI wurden verschiedene Arten von Abstraktionen aufgenommen: Prozessgruppen beschreiben Teilnehmer in kollektiven Operationen und die Rangbezeichnungen in der Punkt-zu-Punkt-Kommunikation, Kontexte trennen voneinander unabhängigen Nachrichtenverkehr, Topologien gestatten dem Nutzer die Beschreibung von Prozessbezie-
154
6 Parallele Bibliotheken Prozess 0
Prozess 1
Prozess 2
MPI_Send MPI_Recv
MPI Recv
SendRight MPI_Send
SendEnd MPI_Send
MPI_Recv (a) Beabsichtigter Nachrichtenweg
Prozess 0
Prozess 1
Prozess 2
SendRight MPI_Send MPI_Recv SendEnd MPI Send
MPI_Recv
(b) Möglicher, aber nicht beabsichtigter Nachrichtenweg Abbildung
6.2: Mögliche Abläufe der Nachrichtentransfers
für den Code in Abbildung
6.1
hungen nach seinen Vorstellungen und Kommunikatoren kapseln schließlich alle diese Informationen in einem geeigneten Objekt, das diese Details auch noch vor dem typischen Nutzer verbirgt.
6.1.3
Überblick zu MPI-Eigenschaften für die Bibliotheksunterstützung
Die Anforderungen an leistungsfähige Bibliotheken können wie folgt zusammengefasst werden: • Ein sicherer Kommunikationsraum garantiert, dass eine Bibliothek Punkt-zuPunkt-Nachrichten ohne Störung durch andere Punkt-zu-Punkt-Nachrichten senden und empfangen kann. • Kollektive Operationen interpretieren eine Prozessgruppe (eines Kommunikators) als die Menge der beteiligten Prozesse; nicht zugehörige Prozesse werden ungehindert weiter ausgeführt. • Abstrakte Namen für Prozesse basieren auf virtuellen Topologien oder wenigstens auf Namen von Prozessrängen in einer Gruppe, wobei Hardwareabhängigkeiten
6.1
155
Motivation
/* Code for library 1: Nonblocking send from 0 to 1 */ void StartSend( int *buf, int rank, int size, MPI_Request *req )
{ if (rank == 0) MPI_Isend( buf, 1, MPI_INT, 1, 0, MPI_C0MM_W0RLD, req );
> void EndSend( int *buf, int rank, MPI_Request *req )
{ MPI_Status status; if (rank == 0) MPI_Wait( req, &status ); else if (rank == 1) MPI_Recv( buf, 1, MPI.INT, 0, 0, MPI_C0MM_W0RLD, &status );
> /* Code for library 2: send from 0 to 1 */ void DoSomething( int *buf, int rank, int size )
{ MPI_Status status; if (rank == 0) MPI_Send( buf, 1, MPI_INT, 1, 0, MPI_C0MM_W0RLD ); else if (rank == 1) MPI_Recv( buf, 1, MPI_INT, 0, 0, MPI_C0MM_W0RLD, ftstatus );
> Abbildung
6.3: Code, dessen Kommunikationsmuster
in Abbildung
6.4 dargestellt ist
zu vermeiden sind; solche abstrakten Namen verleihen dem Anwendungscode im Idealfall eine bessere intuitive Verständlichkeit. M P I verfügt über mehrere Eigenschaften, die diese wichtigen Anforderungen erfüllen: • P r o z e s s g r u p p e n definieren eine Rangbezeichnung für Prozesse in Punkt-zuPunkt-Kommunikationen bezüglich dieser Gruppe. Zusätzlich definieren diese Gruppen den Geltungsbereich kollektiver Operationen. (Die internen Repräsentationen der Prozessnamen sind auf der Anwenderebene nicht sichtbar, ansonsten würde die Portierbarkeit gestört.) Mit diesem Geltungsbereich ist es möglich zu verhindern, dass sich sequentielle kollektive Operationen gegenseitig stören. • K o n t e x t e bieten die Möglichkeit separater sicherer „Welten" (Kontexte genannt) für das Message-Passing in M P I . Ein Kontext ist konzeptionell durch ein sekundäres oder „Hyper"-Etikett implementiert, das Nachrichten voneinander unterscheidet. Im Unterschied zu Etiketten, die der Anwender verwalten kann, verfügen diese Kontexte über total getrennte Kommunikationsräume, jeder mit einem vollständigen Satz an vom Anwender verwalteten Etiketten. Kontexte werden aus
156
6 Parallele Bibliotheken Prozess 0
Prozess 1
StartSend MPI_Isend DoSomething MPI_Send
MPI Recv
EndSend MPI Wait
MPI Recv
(a) Beabsichtigter Nachrichtenweg
Prozess 0
Prozess 1
StartSend MPI_Isend DoSomething MPI Send
MPI_Recv
EndSend MPI_Wait
MPI_Recv
(b) Nicht beabsichtigter Nach richten weg Abbildung
6.4'· Mögliche Kommunikationsabläufe
für den Code aus Abbildung
6.3
Sicherheitsgründen vom System zugewiesen, denn wenn die Anwender Kontexte definieren dürften, könnten zwei Bibliotheken versehentlich dieselben festlegen. Die Anwender arbeiten nicht direkt mit den Kontexten, sondern mit Prozessgruppen und Kommunikatoren. • K o m m u n i k a t o r e n kapseln Kontexte, Gruppen und virtuelle Topologien in einem Objekt, das den erforderlichen Gültigkeitsbereich für alle MPI-Kommunikationsoperationen bereit stellt. Kommunikatoren vereinen Prozessgruppen und Kontextinformationen zu einem sicheren Kommunikationsraum innerhalb der Gruppe. Der Kommunikator stellt auch ein Objekt bereit, um (bezüglich des Kommunikators) lokale Kopien von Daten zu halten, die die Bibliothek zwischen zwei Aufrufen aufbewahren muss. Die MPI-Bezeichnung hierfür ist „Attribut-Caching". Die Benutzung separater Kommunikationskontexte durch unterschiedliche Bibliotheken (oder unterschiedliche Bibliothekseinbindungen) schirmt die interne Kom-
6.2 Eine erste MPI-Bibliothek
157
munikation der Bibliotheksausführung von der externen Kommunikation ab (Gruppensicherheit). Dieses Herangehen erlaubt die Einbindung der Bibliothek sogar, wenn noch Kommunikationen anstehen. Darüber hinaus ist es nicht erforderlich, jeden Eintritt in den bzw. jede Rückkehr aus dem Bibliothekscode zu synchronisieren. Die meisten derartigen Synchronisationen sind unnötig, reduzieren die Leistungsfähigkeit und können mit diesem Kontextmodell vermieden werden.
6.2
Eine erste MPI-Bibliothek
In diesem Abschnitt beschreiben wir eine einfache Bibliothek mit zwei Funktionen. Sie verfügt über ein Leistungsmerkmal, das in der MPI-Definition nicht enthalten ist: eine nichtblockierende Broadcast-Operation. Wir erinnern uns, dass MPI_Bcast für alle Prozesse, außer dem ausgewählten Wurzelprozess, eine Empfangsoperation bedeutet. Deshalb ist es sinnvoll, dieses „Empfangen" und mögliche andere anzustoßen, dann abzuarbeiten, was bearbeitet werden kann, und auf das Ende des Empfangens nur dann zu warten, wenn es erforderlich ist. (Dieses Analogon zum Empfangen ist nicht ganz richtig, weil Zwischenprozesse im Broadcast-Baum sowohl empfangen als auch senden müssen. Ein nicht blockierender Broadcast kann dennoch nützlich sein, speziell in MultithreadSystemen.) Die Anwendung dieser Bibliothek wird in der folgenden Anweisungsfolge veranschaulicht: #include "ibcast.h" Ibcast.handle *request; Ibcast( buf, count, datatype, root, comin, ftrequest ); Ibcast_wait( ftrequest );
Wir skizzieren zunächst den Algorithmus, den die Routine Ibcast benutzt. Der Aufruf von Ibcast startet (unter Anwendung nichtblockierender Kommunikationsroutinen) die Sendeoperationen beim Wurzelprozess. Alle anderen Prozesse starten nichtblockierende Empfangsoperationen. Beim Aufruf von Ibcast.wait warten wir, bis die Sendeund Empfangsoperationen abgeschlossen sind. Da hier einen Baum von Prozessen zur Ausführung des Broadcasts benutzt wird, müssen folglich alle Prozesse, mit Ausnahme des Wurzelprozesses, während der Ausführung von Ibcast_wait Daten an andere Prozesse senden (es sei denn, sie sind Blätter in diesem Baum). Das wirft unmittelbar eine Frage auf: Wie können wir dies mit der entsprechenden Sicherheit erreichen? Der beschriebene Algorithmus hat exakt den gleichen Aufbau wie das eben aufgeführte zweite Beispiel, mit dem wir die Notwendigkeit von Kommunikationskontexten gezeigt haben (Abbildung 6.3). Um zu sichern, dass es keine Beeinflussung durch Nachrichten von anderen Programmteilen gibt, müssen wir einen separaten Kommunikator verwenden. Zusätzlich würden wir gerne mehrere Ibcast-Operationen zulassen, die zur gleichen Zeit aktiv sind. Wir könnten eigene Kommunikatoren für jede verwenden. In diesem Beispiel wollen wir aber zeigen, wie eindeutige Werte für die Nachrichtenetiketten zusammen mit einem privaten Kommunikator anzuwenden sind,
158
6 Parallele Bibliotheken
um abzusichern, dass sich auch mit demselben Kommunikator verschiedene Aufrufe von Ibcast nicht gegenseitig stören. Wir wählen hierfür folgendes Vorgehen: Für jeden Eingabekommunikator (Argument 'MPI_Comm comm' in Ibcast) erzeugen wir mit MPI_Comm_dup einen neuen, privaten Kommunikator und behalten die Übersicht mit Hilfe der (mit Null beginnenden) Nachrichtenetiketten, die zur Ausführung der Aufrufe von Ibcast mit diesem privaten Kommunikator benutzt werden. Um das zu erreichen, müssen wir sowohl den privaten Kommunikator als auch den nächsten noch nicht verwendeten Etikettenwert im Auge behalten. Die C-Struktur zur Verwaltung dieser Informationen, hier mit Ibcast_syshandle bezeichnet, wird in Abbildung 6.5 gezeigt. Doch wo können wir die entsprechenden Werte speichern, sodass wir bei einem Aufruf von Ibcast mit einem Kommunikator den passenden privaten Kommunikator und verfügbaren Etikettenwert finden können? Möglich wäre die Verwendung einer Tabelle, in der wir die Beziehung zwischen den Kommunikatoren, die wir bereits gesehen haben, und den privaten Ibcast_syshandles, die wir bereits erzeugt haben, abspeichern. Es wäre aber besser, wenn wir den Ibcast_syshandleWert irgendwie dem Eingabekommunikator mitgeben könnten. Zum Glück ist genau das mit M P I möglich. Die Bezeichnung in M P I für dieses Erfordernis ist das AttributCaching. Es wird typischerweise angewendet, um einen Zeiger auf eine Datenstruktur zu verwalten, die von der Bibliothek benutzt wird, um Informationen zwischen Aufrufen zu bewahren. In Fortran ist das ein Integer. Attribute werden über Schlüsselwerte (engl.: key values) identifiziert, die vom System vergeben werden, sodass mehrere Bibliotheken auf Attribute desselben Kommunikators zugreifen können, ohne voneinander zu wissen. In diesem Fall bestehen die im Cache zwischengespeicherten Informationen aus einem Kommunikator, der für die interne Kommunikation von ibcast benutzt wird, und aus einem Auftragsetikett, mit dem ibcasts, die denselben Kommunikator benutzen, voneinander getrennt werden können. Somit hat die Header-Datei für die Bibliothek mit nichtblockierendem Broadcast die in der Abbildung 6.5 gezeigte Gestalt. Die vollständige Bibliothek für nichtblockierende Broadcast-Operationen wird in den Abbildungen 6.6, 6.7, 6.8, 6.9 und 6.11 vorgestellt. Sie zeigt, wie die Kommunikatoren und das Caching zu verwenden sind. Die Art und Weise, wie Attribute den Kommunikatoren beigefügt werden, berücksichtigt deren selektive Weitergabe bei einem Aufruf von MPI_Comm_dup, so wie wir es später in diesem Beispiel definieren werden. Der bereits besprochene Aufruf von MPI_Comm_create kopiert keine Attribute zu dem neu erzeugten Kommunikator. Die Bibliothek mit nichtblockierendem Broadcast benötigt die Anwendung des Cachings, um persistente Informationen zu speichern. Sie erfordert die Benutzung eines im Cache zwischengespeicherten Kommunikators, um den Anwender vor Kommunikationsoperationen der Bibliothek zu schützen. Zudem veranschaulicht sie die Notwendigkeit der Benutzung von Etiketten (innerhalb des im Cache abgelegten Kommunikationskontextes) als einen weiteren Sicherheitsmechanismus in einer nichtdeterministisch gestalteten Bibliothek dieser Art. Der Quelltext der zentralen Routine Ibcast ist in den Abbildungen 6.6 und 6.7 zu sehen. Was passiert weiter in der Bibliothek? Zuerst prüft die Routine Ibcast, ob sie von diesem Prozess schon einmal aufgerufen wurde, was an einem gültigen Wert der globalen Integer-Variable ibcast_keyval erkennbar ist. Falls sie von diesem Prozess noch nicht aufgerufen worden ist, muss sie ein Attribut key value erhalten, so dass sie ihre Informa-
159
6.2 Eine erste MPI-Bibliothek #include "mpi.h" /* handle for ibcast operations on a communicator */ typedef struct { MPI_Comm comm; int ordering_tag; } Ibcast_syshandle; /* handle for a particular ibcast operation */ typedef struct { MPI_Request MPI_Status int int } Ibcast_handle; Abbildung
*req_array; *stat_array; num_sends; num_recvs;
6.5: Die Header-Datei für Ibcast Ο
tionen hierüber und zu allen weiteren Kommunikatoren, die Ibcast in diesem Prozess aufrufen dürfen, im Cache Zwischenspeichern kann. Dies wird mit MPI_Keyval_create vorgenommen. Anderenfalls ist ibcast_keyval bereits der von dieser Bibliothek zu verwendende Schlüsselwert. Im nächsten Schritt prüft das Programm, ob überhaupt schon ein Ibcast bezüglich des aktuellen Kommunikators angewendet wurde, indem das Attribut des Schlüsselwertes ibcast_keyval betrachtet wird. Falls Ibcast noch nicht auf diesen Kommunikator angewendet worden ist, wird ein zu diesem Schlüsselwert gehöriges SystemHandle erzeugt und dem Kommunikator beigefügt. Bemerkenswert ist hier der Aufruf von MPI_Comm_dup, der ein vollständiges Duplikat des Eingabekommunikators, aber mit neuem Kommunikationskontext, anlegt. Alle „anderen" Attribute, die vom System zwischengespeichert wurden, werden gemäß den Festlegungen des Anwenders selektiv kopiert, wenn diese Attribute angefügt werden. (Die Signaturen der Funktionen für das Kopieren und Löschen von Attributen sind in Tabelle 6.1 zu finden.) Die nächste Aufgabe von Ibcast besteht darin, alle spezifischen Daten zu ermitteln, die benötigt werden, um das Anwender-Handle dieses speziellen Aufrufs umzusetzen, sodass die eigentliche Datenübertragung beginnen und der Anwender auch Ibcast.wait in zulässiger Weise aufrufen kann. Ibcast baut für einen gegebenen Wurzelprozess einen Broadcastbaum aus Sende- und Empfangsoperationen auf. Für jeden dieser Aufrufe fügt Ibcast ein neues „Auftragsetikett " (engl.: ordering tag) bei, um Störungen durch gerade laufende Ibcast-Aufrufe zu vermeiden. Diesen Typ von Störungen bezeichnen wir mit „Rückwärtseinfügung" (engl.: back-masking) insofern, als hier ein nachfolgender Ibcast-Aufruf seine Daten möglicherweise vor denen eines vorher erfolgten Ibcast-Aufrufs einfügen könnte, was ein fehlerhaftes Resultat zur Folge hätte. Der duplizierte Kontext (der Ibcast gegenüber dem Anwender und anderen Bibliotheken schützt) und die Strategie der Etiketteninkrementierung (die die Ibcast-Aufrufe voneinander trennt) unterbinden gemeinsam
160
6 Parallele Bibliotheken
#include "ibcast.h" static int ibcast.keyval = MPI_KEYVAL_INVALID; /* keyval for attribute caching */ int Ibcast(void #buf, int count, MPI_Datatype datatype, int root, MPI_Comm comm, Ibcast_handle **handle_out)
{ Ibcast_syshandle »syshandle; Ibcast_handle »handle; int flag, mask, relrank; int retn, size, rank; int req.no = 0; /* get info about the communicator */ MPI_Comm_size ( comm, fcsize ); MPI_Comm_rank ( comm, fcrank ); /* If size is 1, this is trivially finished */ if (size == 1) { (*handle_out) = (Ibcast.handle *) 0; return (MPI.SUCCESS);
>
/* first see if this library has ever been called. Generate new key value if not. */ if (ibcast.keyval == MPI_KEYVAL_INVALID) { MPI_Keyval_create( MPI_NULL_COPY_FN, MPI_NULL_DELETE_FN, &ibcast_keyval, NULL);
>
/* this communicator might already have used this collective operation, and so it would consequently have information of use to us cached on it. */ MPI_Attr_get(comm, ibcast_keyval, (void **)&syshandle, fcflag); if (flag = = 0 ) { /* there was no attribute previously cached */ syshandle =
>
(Ibcast_syshandle *)malloc(sizeof(Ibcast_syshandle)); /* fill in the attribute information */ syshandle->ordering_tag = 0; /# start with tag zero */ MPI_Comm_dup(comm, &(syshandle->comm)); /* duplicate comm */ /* attach information to the communicator */ MPI_Attr_put(comm, ibcast_keyval, (void *)syshandle);
Abbildung 6.6: Erster Teil der zentralen Bibliotheksroutine
von Ibcast()
6.2 Eine erste MPI-Bibliothek
161
/* create a handle for this particular ibcast operation */ handle = (Ibcast.handle *)malloc(sizeof(Ibcast.handle)); handle->num_sends = 0; handle->num_recvs = 0; /* count how many send/recv handles we need */ mask = 0x1; relrank = (rank - root + size) '/, size; while ((mask k relrank) == 0 kk mask < size) { if ((relrank I mask) < size) handle->num_sends++; mask « = 1;
>
if (mask < size) handle->num_recvs++; /* allocate request and status arrays for sends and receives */ handle->req_array = (MPI_Request *) malloc(sizeof(MPI.Request) * (handle->num_sends + handle->num_recvs)); handle->stat_array = (MPI_Status *) malloc(sizeof(MPI_Status) * (handle->num_sends + handle->num_recvs)); /* create "permanent" send/recv requests */ mask = 0x1; relrank = (rank - root + size) '/, size; while ((mask k relrank) == 0 kk mask < size) { if ((relrankImask) < size) MPI_Send_init(buf, count, datatype, ((relrank I mask) +root) '/.size, syshandle->ordering_tag, syshandle->comm, &(handle->req_array[req_no++])); mask « = 1;
> if (mask < size) MPI_Recv_init(buf, count, datatype, ((relrank k C mask)) + root) '/. size, syshandle->ordering_tag, syshandle->comm, &(handle->req_array[req_no++])); retn = Ibcast_work(handle); /* prepare to update the cached information */ ++(syshandle->ordering_tag); /* make bigger for next ibcast operation to avoid back-masking */ (*handle_out) = handle; /* return the handle */ return(retn);
> Abbildung
6.7: Zweiter
Teil der zentralen
Bibliotheksroutine
von Ibcast()
162
6 Parallele Bibliotheken
int MPLComm_dup(MPI_Comm comm, MPLComm *newcomm) int MPLKeyval_create(MPLCopy.function *copy_fn, MPLDelete.function *delete.fn, int *keyval, void* extra_state) int MPLAttr_put(MPI_Comm comm, int keyval, void* attribute_val) int MPI_Attr_get(MPI_Comm comm, int keyval, void* attribute_val, int *flag) int MPLKeyval_free(int *keyval) int MPI_Attr_delete(MPLComm comm, int keyval) typedef int MPLCopy_function(MPLComm oldcomm, int *keyval, void *extra_state, void *attribute_valueJn, void *attribute_value_out, int *flag) typedef int MPLDelete_function(MPLComm comm, int *keyval, void *attribute.value, void *extra_state) Tabelle 6.1: C-Signaturen neuer MPI-Funktionen, die von Ibcast benötigt werden, sowie verwandter Funktionen. Die Variable attribute_value in MPI_Attr_get wird als void * übergeben, um Typumwandlungen zu vereinfachen; das aktuelle Argument muss ein Zeiger auf einen Zeiger des richtigen Typs sein. Das gilt ebenso für attribute_value_out in MPI_Copy_function diese Fehlermöglichkeit. Somit arbeitet Ibcast getrennt von allen anderen kollektiven und Punkt-zu-Punkt-Kommunikationen seines Kommunikators, da es einen duplizierten Kommunikator verwendet. Darüber hinaus wirkt jeder Aufruf von Ibcast unabhängig von vorherigen Aufrufen mit dem gleichen Kommunikator, da für das Senden und Empfangen verschiedene Etiketten benutzt werden. 1 In dieser Bibliothek ergeben sich bezüglich des Prozessverhaltens drei allgemeine Klassen: die Wurzel, die Blätter und alle übrigen Knoten des Broadcastbaums. Der Wurzelprozess sendet nur, die Blätter empfangen nur, alle anderen Prozesse müssen sowohl senden als auch empfangen. Die Position eines jeden Prozesses im Baum bestimmt daher die Anzahl der persistenten Sende- und Empfangsanforderungen, die Ibcast erzeugt und im „Anwender-Handle" speichert. (Die MPI-Aufrufe MPIJ3end_init und MPI_Recv_init erzeugen die persistenten Operationen, aber starten sie nicht. Sie werden hier angewendet, um zu vermeiden, dass die MPI Jlequests wiederbenutzt werden, wie dies in Abschnitt 5.2.2 geschieht. Vielmehr ist hiermit eine einfache Möglichkeit gegeben, die Parameter zu speichern, die MPI_Isend für die Prozesse benötigt, die weder Wurzel noch Blätter im Broadcastbaum sind.) Ibcast ruft dann Ibcast_work auf, um den Hauptblock der Broadcastübertragung auszuführen. 1 Die hier verwendete Strategie der Etiketteninkrementierung ist wohl für dieses Beispiel etwas überzogen, da Ibcast ohne die Verwendung von Platzhaltern sendet und empfängt und sich folglich auf eine paarweise Zuordnung von Nachrichten (siehe Glossar) für die korrekte Arbeit verlassen kann. Gleichwohl veranschaulicht der Code eine wichtige Strategie des Bibliotheksentwurfs. Sie kann zum Beispiel in Bibliotheken angewendet werden, in denen MPI_ANY_SOURCE beim Empfangen benutzt wird und deshalb Etiketten zum Sortieren von Nachrichten verwendet werden müssen.
6.2 Eine erste MPI-Bibliothek
163
Intracomm M P I : : l n t r a c o m m : : D u p ( ) const int MPI::Create_keyval(const Copy Junction* copy.fn, const Delete_function* delete_fn, void* extra.state) void MPI::Comm::Set_attr(int keyval, const void* attribute.val) const bool MPI::Comm::Get_attr(int keyval, void* attribute.val) const void MPI::Free_keyval(int& keyval) void MPI::Comm::Delete_attr(int keyval) const Tabelle 6.2: C++-Signaturen neuer MPI-Methoden, die von Ibcast benötigt werden, sowie verwandter Methoden. Die Typdefinitionen für Copy-function und Delete_function sind analog zu denen für C in Tabelle 6.1
Der Wurzelprozess, der keine Empfangsoperationen ausführt, startet in Ibcast_work mit dem Aufruf der Routine MPI_Startall unverzüglich alle seine Sendeoperationen. Alle anderen Knoten des Broadcastbaums starten mit MPI_Startall ihre Empfangsoperationen. Nach dem Start dieser Aufrufe terminiert Ibcast_work und gibt die Steuerung an Ibcast zurück. Schließlich hat Ibcast nur noch eine kleine Verwaltungsaufgabe: das Inkrementieren des Auftragsetiketts, das vom nächsten Ibcast-Aufruf, der auch vor dem Aufruf von Ibcast.wait zulässig ist, verwendet wird. Ibcast gibt bei Beendigung ein Handle zurück, das die persistenten Informationen enthält, die übergeben werden, wenn der Anwender Ibcast_wait aufruft, um den Broadcast zu beenden. Ibcast_wait setzt da ein, wo Ibcast_work aufhört. In der Wurzel wartet die Routine auf das Ende der Sendeoperationen, in den Blättern auf das Ende der Empfangsoperationen. Bei den anderen Knoten des Broadcastbaums wartet sie auf die Empfangsoperationen, startet dann die Sendeoperationen und wartet auf deren Beendigung. Zu diesem Prozess gehören die Aufrufe von MPI_Startall und MPI_Waitall. Kommunikatoren werden häufig durch das Kopieren existierender Kommunikatoren (mit MPI_Comm_dup) erzeugt und irgendwann frei gegeben, wobei an dieser Stelle der Wunsch bestehen kann, den einem Attribut zugewiesenen Speicher zu bereinigen. Um bei dieser Operation mit zugehörigen Attributen umgehen zu können, legt MPI fest, dass der Anwender, sofern ein Schlüsselwert erzeugt wird, Funktionen für das Kopieren bzw. Löschen des Attributwerts bereit stellen kann, wenn der Kommunikator kopiert bzw. frei gegeben wird. In manchen Fällen möchte eine Bibliothek sicher stellen, dass die zwischengespeicherten Attributinformationen an neue, mit MPI_Comm_dup erzeugte Kommunikatoren weitergegeben werden. Die Version von Ibcast in Abbildung 6.6 leistet dies nicht, aber es ist nur eine kleine Änderung erforderlich, um diese Eigenschaft hinzuzufügen. Durch Modifikation einer Zeile im Ibcast-Code können wir Rückruffunktionen (engl.: callback function) zum Kopieren und Löschen hinzufügen:
164
6 Parallele Bibliotheken
MPLCOMM_DUP(comm, newcomm, ierror) integer comm, newcomm, ierror MPI_KEYVAL_CREATE(copy_fn, delete.fn, keyval, extra_state, ierror) integer copyJn, delete_fn external copy.fn, delete.fn integer keyval, extra_state, ierror MPI_ATTR_PUT(comm, keyval, attribute.val, ierror) integer comm, keyval, attribute_val, ierror MPLATTR_GET(comm, keyval, attribute_val, flag, ierror) integer comm, keyval, attribute_val, ierror logical flag MPI_KEYVAL_FREE(keyval, ierror) integer keyval, ierror MPI_ATTR_DELETE(comm, keyval, ierror) integer comm, keyval, ierror integer function COPY_FI\l(oldcomm, keyval, extra-state, attribute_valueJn, attribute_value_out, flag) integer oldcomm, keyval, extra_state, attribute_valueJn, attribute_value_out, flag integer function DELETE_FN(comm, keyval, attribute-value, extra_state, flag) integer comm, keyval, attribute-value, extra_state logical flag Tabelle 6.3: Fortran-Signaturen neuer MPI-Routinen, die von Ibcast benötigt werden, sowie verwandter Routinen. COPY-FN und DELETE_FN sind keine MPI-Funktionen; sie zeigen, wie die ersten beiden Parameter in MPI_KEYVAL_CREATE zu gestalten sind /* first see if this library has ever been called. */ if (ibcast.keyval == MPI_KEYVAL_INVALID) { /* our first mission is to create the process-local keyval for this library, while specifying callbacks */ MPI_Keyval_create(Ibcast_copy, Ibcast_delete, &ibcast_keyval, NULL);
Wir definieren dabei auch die Punktionen, die das Kopieren (Ibcast_copy, siehe Abbildung 6.10) und Löschen (lbcast_delete, siehe Abbildung 6.11) ausführen, um unsere Modifikationen zu vervollständigen. Mit diesen Änderungen verfügt die Bibliothek über die Eigenschaft, dass sich Aufrufe von MPI_Comm_dup und MPI-ConnnJree bezüglich eines Kommunikators, der Ibcast benutzt hat, so verantwortlich wie möglich bei der Speicherbereinigung verhält. Diese Funktionen sollten den Wert Null zurückgeben, wenn
165
6.2 Eine erste MPI-Bibliothek #include "ibcast.h" int Ibcast_work(Ibcast_handle *handle)
•c
>
/* if I don't have any recv's, start all my sends — the root */ if (handle->num_recvs == 0) MPI_Startall( handle->num_sends, handle->req_array ); /* start all my recv's */ else MPI_Startall( handle->num_recvs, &handle->req_array[handle->num_sends] ); return (MPI_SUCCESS);
Abbildung
6.8: Funktion Ibcast.workO, die Teile des Broadcasts für IbcastO
ausführt
kein Fehler auftritt. Wenn sie einen von Null verschiedenen Wert zurückgeben, dann zeigt die MPI-Routine, die deren Aufruf ausgelöst hat, ebenfalls einen Fehler an. Zu beachten ist hierbei, dass der von der MPI-Routine gelieferte Fehlerwert (sofern die MPI-Fehlerbehandlung auf die Rückgabe eines Fehlerwertes eingestellt wurde) nicht der von Null verschiedene Wert ist, der von der Kopier- oder Löschfunktion zurückgegeben wird. Für Systeme, die Threads unterstützen, können diese Ideen und der oben beschriebene Quellcode zur Erzeugung einer nichtblockierenden Reduktionsoperation und einer nichtblockierenden Barrier-Operation wie auch für andere kollektive Aufrufe, die nicht direkt zum MPI-Standard gehören, verwendet werden. Die Nutzung von Threads mit MPI wird detaillierter in Using MPI-2 [GLT99] besprochen.
6.2.1
Routinen für das Attribut-Caching in MPI-2
Eine Schwierigkeit bei der Attribut-Caching-Funktion (Zwischenspeichern von Attributen, engl.: attribute-caching), besteht darin, dass in C und Fortran Werte verschiedener Art abgelegt werden. In C ist dies ein Zeiger (void *), in Fortran ein integer. Wie in Abschnitt 5.5 besprochen wurde, kann ein integer in Fortran kleiner sein als ein Zeiger in C. Das erschwert die Arbeit mit Attributen in Fällen, in denen sowohl in C als auch in Fortran geschriebene Routinen auf das Attribut zugreifen müssen. Um dieses Problem zu beheben, führte das MPI-Forum neue Routinen ein, deren C-Signaturen die gleichen sind wie die der Versionen in MPI-1, aber deren Fortran-Signaturen integer (kind=MPI-ADDRESS_KIND) anstelle von integer als Typ des Parameters für den Attributwert benutzen. Diese neuen Routinen werden in den Tabellen 6.4 und 6.5 gezeigt. Die C++-Signaturen wenden diese neue Form an.
6.2.2
Eine Alternative in C++ zu MPI_Comm_dup
In den C++-Signaturen von MPI gibt es fünf verschiedene Typen für die verschiedenen Arten von Kommunikatoren: MPI::Comm, MPI: : Intercomm, MPI: : Intracomm, MPI: :Graphcomm u n d MPI: : Cartcomm. Die Klasse MPI::Comm ist die a b s t r a k t e B a -
6 Parallele Bibliotheken
166
#include "ibcast.h" int Ibcast_wait(Ibcast_handle **handle_out) { Ibcast_handle *handle = (*handle_out) ; int retn, i; /* Α NULL handle means there's nothing to do */ if (handle == (Ibcast_handle *)0) return (MPI.SUCCESS); /* If I wasn't the root, finish my recvs and start & wait on sends */ if (handle->num_recvs != 0) { MPI_Waitall(handle->num_recvs, &handle->req_array[handle->num_sends], &handle->stat_array[handle->num_sends]); MPI_Startall ( handle->num_sends, handle->req_array );
> /* Wait for my receive and then start all my sends */ retn = MPI_Waitall(handle->num_sends, handle->req_array, handle->stat_array); /* free permanent requests */ for (i=0; i < (handle->num_sends + handle->num_recvs); i++) MPI_Request_free (&(handle->req_array[i])); /* Free request and status arrays and ibcast handle */ free (handle->req_array); free (handle->stat_array); free (handle); /* Return a NULL handle */ (*handle_out) = (Ibcast_handle *)0; return(retn);
> Abbildung
6.9: Funktion Ibcast.waitO, die das „wait" für Ibcast 0
realisiert
6.2 Eine erste MPI-Bibliothek
167
/* copier for ibcast cached information */ int Ibcast_copy(MPI_Comm *oldcomm, int *keyval, void *extra, void *attr_in, void **attr_out, int *flag)
{ Ibcast_syshandle »syshandle = (Ibcast_syshandle *)attr_in; Ibcast_syshandle *new_syshandle; /* do we have a valid keyval */ if ( ( (*keyval) == MPI_KEYVAL_INVALID ) || ( (*keyval) != ibcast_keyval ) || ( (syshandle == (Ibcast_syshandle *)0) ) ) { /* A non-zero return is an error and will cause MPI_Comm_dup to signal an error. The return value here is *not* the MPI error code that MPI_Comm_dup will return */ return 1;
> /* create a new syshandle for the new communicator */ new_syshandle = (Ibcast_syshandle *)malloc(sizeof(Ibcast_syshandle)); /* fill in the attribute information */ new_syshandle->ordering_tag = 0; /* start with tag zero */ /* dup the "hidden" communicator */ MPI_Comm_dup(syshandle->comm, &(new_syshandle->comm)); /* return the new syshandle and set flag to true */ (*attr_out) = (void *)new_syshandle; (•flag) = 1; return MPI_SUCCESS;
> Abbildung
6.10: Funktion Ibcast_copy(), die Kopier-Rückruf-Dienste
für Ibcast () ausführt
168
6 Parallele Bibliotheken
/* destructor for ibcast cached information */ int Ibcast_delete(MPI_Comm *comm, int *keyval, void *attr_val, void *extra)
{ Ibcast_syshandle *syshandle = (Ibcast_syshandle *)attr_val; /* do we have a valid keyval */ if ( ( (*keyval) == MPI_KEYVAL_INVALID ) || ( (*keyval) != ibcast_keyval ) I I ( (syshandle == (Ibcast_syshandle *)0) ) ) { /* Give a non-zero return code to indicate an error */ return 1;
> /* free the "hidden" communicator and memory for syshandle */ MPI_Comm_free ( &(syshandle->comm) ); free (syshandle); return MPI_SUCCESS;
> Abbildung ausführt
6.11:
Funktion
Ibcast_delete(), die Lösch-Rückruf-Dienste
für
IbcastO
int MPI_Comm_create_keyval( Μ ΡLComm_copy_attr Junction *comm_copy.attr_fn, MPLComm-delete_attr_function *comm_delete_attr_fn, int *comm_keyval, void *extra.state) int MPI_Comm_free_keyval(int *comm_keyval) int MPI_Comm_set_attr(MPLComm comm, int comm.keyval, void *attribute_val) int MPI_Comm_get_attr(MPLComm comm, int comm.keyval, void *attribute_val, int *flag) int MPI_Comm_delete_attr(MPLComm comm, int comm.keyval) Tabelle 6.4: C-Signaturen für das Attribut-Caching
in MPI-8
169
6.2 Eine erste MPI-Bibliothek MPI_COMM_CREATE_KEYVAL(comm_copy_attr_fn, comm.delete.attr.fn, comm.keyval, extra-State, ierror) external comm.copy-attr.fn, comm.delete.attr.fn integer comm.keyval, ierror integer(kind=MPI_ADDRESS_KII\ID) extra_state MPI_COMM_FREE_KEYVAL(comm.keyval, ierror) integer comm.keyval, ierror MPI_COMM_SET_ATTR(comm, comm_keyval, attribute_val, ierror) integer comm, comm.keyval, ierror integer(kind=MPI_ADDRESS.KIND) attribute.val MPI_COMM_GET_ATTR(comm, comm.keyval, attribute.val, flag, ierror) integer comm, comm.keyval, ierror integer(kind=MPI_ADDRESS.KIND) attribute.val logical flag MPI_COMM_DELETE_ATTR(comm, comm.keyval, ierror) integer comm, comm.keyval, ierror Tabelle
6.5: Fortran-Signaturen
für das Attribut-Caching
in
MPI-2
sisklasse, von der MPI: : Intercomm und MPI: : Intracomm abgeleitet sind. Die Kommunikatoren MPI: :Cartcomm und MPI: Graphcomm, die Topologien enthalten, sind von MPI: : Intracomm abgeleitet. Das steht im Kontrast zu den C- und Fortran-Fassungen, wo es nur einen einzigen Typ (MPI_Comm in C) für alle Kommunikatoren gibt. Die feinere Unterscheidung gestattet es einem C++-Übersetzer, eine qualifiziertere Prüfung auf die korrekte Kommunikatorart bei der Ubersetzung vorzunehmen (ζ. B. in kollektiven Routinen, in denen in MPI-1 nur Intrakommunikatoren erlaubt sind). Die feinere Unterscheidung ist jedoch ein Nachteil, wenn eine MPI-Applikation mit einem MPI-Kommunikator beliebiger Art arbeiten möchte. Um das Problem zu verstehen, betrachten wir MPI.Comm.dup. Die Dup-Methode dupliziert einen Kommunikator vom Typ MPI::Intercomm, MPI::Intracomm, MPI::Graphcomm oder MPI::Cartcomm und gibt einen Kommunikator des jeweils selben Typs zurück. Wenn wir den Kommunikator manipulieren wollten, so als wäre er von der einfachen Art MPI: : Comm, so könnten wir das nicht, da MPI: :Comm eine abstrakte Basisklasse ist, also keine Variablen vom Typ MPI: :Comm deklariert werden können. Das heißt, Quellcode der Art
void mysample( MPI::Comm comm ) { MPI::Comm localcomm; localcomm = comm.DupO ; localcomm.FreeO ;
>
170
6 Parallele Bibliotheken
Comm& MPI::Comm::Clone() const = 0 lntracomm& MPI::lntracomm::Clone() const lntercomm& MPI::lntercomm::Clone() const Cartcomm& MPI::Cartcomm::Clone() const Graphcomm& MPI::Graphcomm::Clone() const Tabelle 6.6: C++-Signaturen für das Klonen eines
Kommunikators
ist nicht erlaubt. Wir können jedoch eine Referenz einer abstrakten Basisklasse benutzen, wodurch es möglich wird, diesen Fall in der Form v o i d mysample( MPI::Comm &comm ) { MPI::Comm *localcomm; localcomm = &comm.Clone(); localcomm->Free(); d e l e t e localcomm;
> zu implementieren. Zu beachten ist, dass wir hier Clone anstelle von Dup auf die Referenz von MPI: : Comm comm angewendet haben. Die Methode Clone liefert einen neuen MPIKommunikator desselben Typs und, wie auch Dup, mit einem neuen Kontext, aber als Referenz, nicht als Wert. Die Signaturen für Clone sind in Tabelle 6.6 zusammengestellt (es gibt keine Versionen von Clone in C oder Fortran, da man dort mit MPI_Comm_dup auskommt). Bei der Freigabe der Referenz eines mit Clone erzeugten Kommunikators ist zu beachten, dass sowohl die Methode F r e e zur Freigabe des Kommunikators durch M P I aufzurufen ist, als auch C + + veranlasst werden muss, den durch Clone belegten Speicherplatz mit d e l e t e wieder verfügbar zu machen.
6.3
Lineare Algebra auf Gittern
Viele Anwendungen können mit gewöhnlichen Vektoroperationen auf einem Gitter oder „Mesh" geschrieben werden. Das Poisson-Problem in Kapitel 4 ist ein typisches Beispiel hierfür. Wir haben dort gesehen, dass die wesentliche Frage in der Dekomposition der Daten besteht, also in der Frage, wie die Elemente der Vektoren auf die Prozesse aufzuteilen sind. Dieser Abschnitt beschreibt Entwurf und Implementierung von MPIBibliotheken, die verschiedene Fragestellungen angehen, die bei der Bereitstellung effizienter und flexibler Routinen für Vektoroperationen auf Gittern auftreten und bei vielen Datenaufteilungen korrekt arbeiten. Diese Bibliotheken sind zunächst „objektbasiert"
6.3 Lineare Algebra auf Gittern
171
oder „objektorientiert". Die objektbasierten Bibliotheken benutzen C-Datenstrukturen und bieten daher Abstraktionen wie Vektoren und Datenaufteilungen. Die objektorientierten Bibliotheken gehen einen Schritt weiter, indem sie das Konzept der Vererbung anwenden und damit besser in der Lage sind, ihre Laufzeit durch eine Reihe von Maßnahmen außerhalb innerer Schleifen zu optimieren. Typischerweise erreichen sie das mit langlebigen Objekten, die die Details einer gewünschten parallelen Berechnung beschreiben. Die Objekthierarchie wird in Abbildung 6.14 gezeigt. Wir werden die Objekte in diesem Bild von unten beginnend besprechen.
6.3.1
Abbildungen und Logische Gitter
Bei der Zuordnung oder Abbildung der Daten (engl.: data mapping) helfen zwei grundlegende Abstraktionen. Die erste ist eine logische Gittertopologie, die die Namen der Prozesse und deren Verbindungen beschreibt. Diese Datenstruktur unterstützt die Beschreibung von Kommunikationsstrukturen und Anordnung der Daten. Die Datenanordnung selbst wird durch Abbildungsfunktionen beschrieben, die die Transformationen der Indizes der Daten für die logischen Gittertopologien in jeder Dimension angeben. In diesem Kapitel wollen wir unsere Betrachtungen auf zweidimensionale logische Gittertopologien einschränken. Logische 2 D - G i t t e r t o p o l o g i e n . Im Kapitel 4 hatten wir gesehen, dass MPI einen Satz von Routinen bereit stellt, mit denen virtuelle Topologien erzeugt werden können. Die Routinen zur Erzeugung virtueller kartesischer Topologien sind jedoch leider nicht immer anwendbar. In einigen Fällen kann es erforderlich sein, einen Kommunikator in Abschnitte aufzuteilen, die auf allgemeineren Kriterien beruhen. Zu diesem Zweck gibt es in MPI die Funktion MPI_Comm_split (siehe Abschnitt 3.9). Wir erinnern uns, dass diese Funktion einen Kommunikator, eine Farbe und einen Schlüssel als Eingabe verlangt. Alle Prozesse mit demselben c o l o r - W e r t gehören zu dem neuen Kommunikator, der im vierten Parameter zurückgegeben wird. Die Prozesse im neuen Kommunikator werden entsprechend der nach key gegebenen Reihenfolge nummeriert; haben zwei Prozesse denselben Schlüssel wert, behalten sie die gleiche relative Reihenfolge wie im alten Kommunikator. Als Beispiel für die Anwendung von MPI_Comm_split zeigen wir in Abbildung 6.13 eine alternative Möglichkeit zur Erzeugung einer zweidimensionalen kartesischen Topologie. Die Datenstruktur für das von dieser Routine erzeugte logische Gitter ist in Abbildung 6.12 dargestellt. Durch mehrfaches Aufrufen von MPI_Comm_split können Kommunikatoren für Untergruppen erzeugt werden, die sich überlappen. Abbildung 6.13 zeigt diese Möglichkeit, Topologien zu konstruieren, am Beispiel von Zeilen- und Spaltenkommunikatoren in einem zweidimensionalen Gitter. Zurück z u G i t t e r n m i t M P I - T o p o l o g i e f u n k t i o n e n . Eine Alternative zur Funktion MPI_Comm_split bei der Arbeit mit einer (zum Beispiel mit MPI_Cart_create erzeugten) kartesischen Topologie ist die Anwendung von MPI_Cart_sub. Eingabe für diese Routine ist ein Kommunikator mit kartesischer Topologie. Erzeugt wird ein neuer Kommunikator, der aus all den Prozessen besteht, die in einer Hyperebene der kartesischen Topologie des Eingabekommunikators liegen. Die Hyperebene wird durch das zweite Ar-
172
6 Parallele Bibliotheken
typedef struct { int P, Q; int ρ, q;
/* global shape of grid */ /* local position on grid */
MPI_Comm grid_comm; /* parent communicator */ MPI_Comm row_comm; /* row communicator */ MPI_Comm col_comm; /* column communicator */ > LA_Grid_2d; Abbildung
6.12: Struktur für ein logisches
2D-Gitter
LA_Grid_2d *la_grid_2d_new(MPI_Comm comm, int P, int Q) { LA_Grid_2d *grid; MPI_Comm row, col; int my_rank, p, q; /* Determine row and column position */ MPI_Comm_rank(comm, &my_rank); ρ = my_rank / Q; q = my_rank % Q; /* pick a row-major mapping */
>
/* Split comm into row and col comms */ MPI_Comm_split(comm, p, q, &row); /* color by row, rank by column */ MPI_Comm_split(comm, q, p, &col); /* color by column, rank by row */ /* Make new grid */ grid = (LA_Grid_2d *)malloc(sizeof(LA_Grid_2d)); /* Fill in new grid structure */ grid->grid_comm = comm; grid->row_comm = row; grid->col_comm = col; grid->P = Ρ grid->Q = Q grid->p = ρ grid->q = q /* Return the newly built grid */ return (grid);
Abbildung
6.IS:
Quellcode zur Konstruktion
eines logischen
2D-Gitters
6.3 Lineare Algebra auf Gittern
173
Abbildung 6.14: Hierarchie der Datenstrukturen in den Beispielen zur linearen Algebra int M P I _ C a r t _ s u b ( M P L C o m m oldcomm, int *remain_dims, M P L C o m m *newcomm)
Tabelle 6.7: C-Signatur der MPI-Topologiefunktion, die in la.grid_2d_new_II verwendet wird
gument angegeben; für jede Dimension der ursprünglichen kartesischen Topologie zeigt ein t r u e - W e r t an, ob diese Dimension in der kartesischen Topologie des neuen Kommunikators verbleiben soll. Betrachten wir zum Beispiel ein zweidimensionales kartesisches Gitter, in dem die Zeilen die erste, die Spalten die zweite Dimension seien. Dann repräsentiert der mit remain_dim = { 1, 0 } erzeugte Kommunikator die Zeile, die den Prozess enthält, im Falle remain-dim = { 0 , 1 } die entsprechende Spalte. Die Anwendung von MPI_Cart_sub zur Konstruktion der Zeilen- und Spaltenkommunikatoren in der virtuellen Topologiehierarchie wird in Abbildung 6.15 gezeigt. Es ist zu beachten, dass die MPI-Funktionen für kartesische Topologien die Prozesse auf eine besondere Art und Weise auf die kartesische Topologie abbilden: die Ränge der Prozesse in dem mit MPI_Cart_create erzeugten Kommunikator sind in spezifischer Weise zeilenweise geordnet. Das bedeutet: wenn die Prozesse einer dreidimensionalen kartesischen Topologie mit den kartesischen Koordinaten ( i , j , k ) beschrieben werden, hat der jeweils nächst folgende Prozess im kartesischen Kommunikator die Koordinaten (i,j,k + 1), sofern k noch nicht seinen maximalen Wert (Anzahl der Prozesse in dieser Dimension) erreicht hat. In diesem Fall wird j (oder i, falls j ebenfalls seinen maximalen Wert bereits erreicht hat) um eins inkrementiert. Wird eine andere Dekomposition bevorzugt, zum Beispiel eine spaltenweise (d.h. der erste Wert von ( i , j , k ) ändert sich zuerst), muss stattdessen zum Beispiel mit MPI_Comm_split gearbeitet werden. A b b i l d u n g v o n F u n k t i o n e n u n d A u f t e i l u n g e n . Da wir gern flexible Bibliotheksroutinen zur Verfügung stellen wollen, überlassen wir dem Anwender die Festlegungen zur Datenanordnung im System. Hierfür stellen wir Funktionen zur Abbildung der Daten bereit, vorzugsweise für gebräuchliche Anordnungen (linear lastverteilt, gestreut usw.). Die Anwender haben natürlich auch die Möglichkeit, andere Abbildungsstrategien zu benutzen, solange sie die Regeln für solche Abbildungen beachten. Die Aufteilun-
174
6 Parallele Bibliotheken
#define N.DIMS 2 #define FALSE 0 #define TRUE 1 LA_Grid_2d *la_grid_2d_new_II(MPI_Comm coinm, int P, int Q) { LA_Grid_2d *grid; MPI_Comm comm_2d, row, col; int my_rank, p, q; int dims[N_DIMS], /* hold dimensions */ local[N_DIMS], /* local position */ period[N_DIMS], /* aperiodic flags */ remain_dims[N_DIMS]; /* flags for sub-dimension computations */ /* Generate a new communicator with virtual topology added */ dims[0] = P; period[0] = FALSE; dims[1] = Q; period[1] = FALSE; MPI_Cart_create(comm, N_DIMS, dims, period, TRUE, &comm_2d); /* map back to topology coordinates: */ MPI_Comm_rank(comm, &my_rank); MPI_Cart_coords(comm_2d, my_rank, N_DIMS, local); ρ = local[0]; q = local [1]; /* this is "my" grid location */ /* Use cartesian sub-topology mechanism to get row/col comms */ remain_dims[0] = FALSE; remain_dims[1] = TRUE; MPI_Cart_sub(comm_2d, remain_dims, &row); remain_dims [0] = TRUE; remain.dims [1] = FALSE; MPI_Cart_sub(comm_2d, remain_dims, &col); grid = (LA_Grid_2d *)malloc(sizeof(LA_Grid_2d));
>
/* new grid */
/* rest of the code is the same as before */
Abbildung
6.15: 2D-Topologiehierarchie
mit Funktionen
zu kartesischen
Topologien
C a r t c o m m M P I : : C a r t c o m m : : S u b ( c o n s t bool remain_dims[]) const Tabelle 6.8: C++-Signatur
von MPI_Cart-.sub
M P L C A R T _ S U B ( o l d c o m m , remain_dims, newcomm, terror) integer oldcomm, newcomm, ierror logical remain_dims(*) Tabelle wird
6.9: Fortran-Signatur
der MPI-Topologie-Routine,
die in la_grid_2d_new verwendet
175
6.3 Lineare Algebra auf Gittern #define LA_MAPPING_BLINEAR 1 #define LA_MAPPING_BSCATTER 2 typedef struct
ΐ int
map_type; /* Used for quick comparison of mappings */
void (*mu)(int I, int P, int N, void *extra, int *p, int *i); /* Mapping of I->(p,i) */ void (*mu_inv)(int p, int i, int P, int N, void *extra, int *I); /* Inverse (p,i)->I: */ void (*local_len)(int p, int P, int N, void *extra, int *n); /* # of coefficients mapped to each process: */ void *extra; > LA_Mapping;
/* for mapping-specific parameters */
/* some pre-defined mappings ... */ extern LA_Mapping *LA_Mapping_Blk_Linear, *LA_Mapping_Blk_Scatter, *LA_Mapping_Linear, *LA_Mapping_Scatter; Abbildung 6.16: Definition ponenten auf Prozesse
einer Datenstruktur
zur eindimensionalen
Aufteilung
von
Kom-
gen, die wir hier zeigen werden, decken jedoch schon viele Fälle ab. Sie verdeutlichen insbesondere unser Entwurfsziel der „Unabhängigkeit der Datenverteilung". Einzelne Abbildungen sind nicht ausreichend, da sie eindimensionale Umwandlungen zwischen einem globalen Indexraum und der lokalen Prozessbezeichnung für die Komponenten beschreiben; sie geben an, welche global nummerierte Komponente welchem Prozess einer Menge von Prozessen zugeordnet wird. Für zweidimensionale Datenstrukturen (wie z.B. Matrizen), sind zwei solche Abbildungen erforderlich, um zu beschreiben, wie Daten auf eine Prozesstopologie abzubilden sind. Um diesen Anforderungen zu genügen, entwickeln wir eine Datenstruktur, die wir „Aufteilung" nennen wollen. Wir werden sie hier nur für zwei Dimensionen ausarbeiten. Sie lässt sich jedoch auf höherdimensionale Gitter verallgemeinern.
typedef struct { LA_Grid_2d *grid; /* grid on which the distribution is based */ LA_Mapping *row; /* row mapping */ LA_Mapping *col; /* col mapping */ > LA_Distrib_2d; Abbildung
6.17: Definition
einer Datenstruktur
zur zweidimensionalen
Datenaufteilung
176
6 Parallele Bibliotheken
Die Abbildung 6.17 veranschaulicht die Datenstruktur, die sowohl die Informationen zum logischen Gitter als auch zwei zusammengehörige Abbildungen umfasst. Diese Daten erweisen sich als ausreichend zur Beschreibung elementarer Probleme der linearen Algebra.
6.3.2
Vektoren und Matrizen
Ausgehend von logischen Gittern, Abbildungen und den auf diesen basierenden zweidimensionalen Aufteilungen können wir nun zu verteilten Datenstrukturen innerhalb der linearen Algebra übergehen. V e k t o r e n . In einer logischen 2D-Gittertopologie können Vektoren entweder Zeilenoder Spaltenvektoren sein. Wir wollen annehmen, dass jeder Zeilenvektor (bzw. Spaltenvektor) auf jede Zeile (bzw. Spalte) eines logischen Gitters (wiederholt) abgebildet wird. Abbildung 6.18 zeigt schematisch, wie zwei Zeilenvektoren in einem Gitter verteilt werden könnten. In diesem Beispiel wird jeder Vektor in zwei Teilvektoren zerlegt und auf die Spalten des Gitters verteilt. Mit dieser Replikation verfügt jede Prozesszeile über eine vollständige Kopie beider Vektoren (siehe auch [FJL+88, SB91]). V e k t o r s t r u k t u r e n und d e r e n K o n s t r u k t i o n . Verteilte Vektoren werden durch die in Abbildung 6.19 dargestellte Datenstruktur definiert. Als Instanz eines verteilten Objekts enthält ein Vektor Informationen sowohl zu seiner globalen als auch lokalen Länge,
r
\
Zeilenvektor X
Zeilenvektor Y
X,
Aufteilung der Vektoren X und Y auf ein logisches (2x2)-Gitter Q = 0 P = 0
X
l
P = 1
X
l '
/ Y
Y
Q = 1
l
X
2
l
Χ
2
/ Y
2
/
2
γ
Replikation , der Vektoren ' X und Y für jede Zeile
Abbildung 6.18: Anordnung von zwei Zeilenvektoren in einem logischen (2 χ 2)-Prozessgitter; Spaltenvektoren werden in analoger Weise orthogonal zu den Zeilenvektoren angeordnet
6.3 Lineare Algebra auf Gittern
177
typedef struct la_local_dvector { int m; double *data; > LA_Local_Dvector;
/* local vector length */ /* vector data */
typedef struct la_dvector { LA_Local_Dvector v; int M; int type; LA_Distrib_2d *dis; } LA_Dvector; Abbildung
6.19: Definition
/* /* /* /*
Local vector */ full length of vector */ row or column type */ how to map data on grid */
des verteilten
Vektors auf dem logischen
Gitter
einen Zeiger zum realen lokalen Speicherplatz und eine Variable, die angibt, ob Zeilenoder Spaltenaufteilung gewählt wird. Zusätzlich verweist die Vektordatenstruktur auf eine darunterliegende Datenstruktur der logischen Gittertopologie und eine Datenaufteilung. Die letztere Information vervollständigt die Angabe, wie Vektorkomponenten abgebildet und über die Topologie repliziert werden. Matrizen. Matrizen auf zweidimensionalen Prozessgittern werden analog zu Vektoren definiert, so wie es in Abbildung 6.20 dargestellt ist. Eine Matrix wird in Untermatrizen geteilt, jede mit einer lokalen Größe von πι χ η. Die Werte für m und η sind im Allgemeinen in jedem Prozess verschieden, so wie die Längen von Teilvektoren gewöhnlich in jedem Prozess unterschiedlich sind. Eine Matrix kann zeilen- oder spaltenweise abgespeichert sein. (Der von uns benutzte Standardkonstruktor legt lokale Matrizen spaltenweise an; dieses Detail ist ein Punkt, über den man nachdenken muss, will man den lokalen SpeicherzugrifF optimieren). Wie bei Vektoren beziehen sich verteilte Matrizen auf ein darunterliegendes Prozessgitter und eine Datenaufteilung, durch die sie detaillierte Informationen zur Topologiegestalt und zur Abbildung der Komponenten erhalten. typedef struct la_local_dmatrix { int storage_type; /* Storage strategy (row/column-major) */ int m, η; /* Local dimensions */ double **data; /* The local matrix, as set of pointers */ } LA_Local_Dmatrix; typedef struct la_dmatrix { LA_Local_Dmatrix a; int Μ, N; LA_Distrib_2d *dis; } LA_Dmatrix; Abbildung
6.20:
Verteilte
/* Local matrix */ /* Global dimensions of LA_Dmatrix. */ /* how to map data onto grid */
Matrixstruktur
178
6.3.3
6 Parallele Bibliotheken
Komponenten einer parallelen Bibliothek
Hochwertige Bibliotheken müssen mehr als eine Version für eine Funktion bereitstellen, so dass sie unter verschiedenen Bedingungen effizient arbeiten kann (wir nennen eine solche Menge von Methoden einen Polyalgorithmus). Wir sehen zum Beispiel mehrere Versionen für Funktionen zur Berechnung von Vektorsumme und innerem Produkt (beide mit gleicher sequentieller Komplexität) vor. Die „strided"-Fassung dieser Funktionen erfordert weniger Berechnungsaufwand auf Kosten von aufwändigerer Kommunikation. Umgekehrt ist die „nonstrided"-Version rechenintensiver, erfordert aber weniger Nachrichten, da der Algorithmus Datenredundanzen ausnutzt — im Fall der Vektorsumme (mit kompatibel gespeicherten Daten) werden überhaupt keine Nachrichten benötigt. Die effizienteste Funktion wird ermittelt, indem Aspekte wie Nachrichtenlatenz, Bandbreite sowie Geschwindigkeit der Gleitkommaoperationen, jeweils in Abhängigkeit von der Größe des Vektors und den Ausdehnungen Ρ und Q des logischen Gitters, verglichen werden. (Das Programm upshot und etwas empirisches Modellieren helfen, die beste Funktion für ein gegebenes Problem auf einem realen System zu finden.) Vektorsumme. Die Vektorsumme ist eine typische Operation ohne Kommunikationsbedarf, sofern die zu addierenden Vektoren kompatibel gespeichert sind, d.h., dass die zugehörigen Komponenten in den Prozessen mit passenden Abständen ( o f f s e t s ) verfügbar sind. Dies ist die einfachste parallele Vektoroperation, die mit verteilten Vektoren möglich ist. Die auszuführende Operation ist ζ = α χ + ßy, wobei α und β reelle skalare Konstanten sind. Gute Implementierungen berücksichtigen Spezialfälle wie α = 0 und β = 1.0, um eine bessere Leistung zu erzielen. Bessere Implementierungen nutzen sogar die sequentielle Bibliothek BLAS (Basic Linear Algebra Subprograms [DDHH88, DDDH89, LHKK89]), wenn die lokalen Vektoren lang genug sind, sodass die Kosten für den Aufruf einer Routine verhältnismäßig gering gegenüber dem Leistungsgewinn durch einen hochoptimierten sequentiellen Kern ausfallen. So muss eine Reihe von verschiedenen Versionen für die Berechnung der Vektorsumme verfügbar sein und zu unterschiedlichen Situationen passend ausgewählt werden können. Hierin unterscheiden sich objektorientierter und objektbasierter Zugang im Bibliotheksentwurf. Im objektorientierten Fall ist der Anwender gezwungen, sich auf bestimmte Operationen festzulegen, um die Datentransformationen, eine eventuelle Wahl von BLAS-Routinen usw. vorab bewerten zu können. Mit diesem Herangehen werden die relativ aufwändigen Entscheidungen nur einmal ausgeführt, auch wenn es sein kann, dass weitere einmalige Zusatzkosten für eine weitere Leistungssteigerung in Kauf genommen werden. Die objektbasierten Versionen — dies sind Objekterweiterungen zu den vertrauten BLAS-Routinen — müssen jedes Mal auf Fehler, Datenanordnungen und so weiter testen und verursachen so einen etwas höheren Zusatzaufwand bei gebräuchlichen Datenanordnungen als die objektorientierten Versionen. Diese Konzepte werden im Detail mit Code und zugehöriger Dokumentation unter 'http://www.mcs.anl.gov/mpi/usingmpi' erklärt (Anhang D). Inneres Produkt. Für das innere Produkt (auch Skalarprodukt oder Punktprodukt genannt) gibt es ähnliche Anforderungen wie für die Vektorsumme. Zusätzlich zu der Möglichkeit, Versionen mit oder ohne Replikation zu wählen, bleibt noch die Entscheidung, mit oder ohne BLAS zu arbeiten. Wir veranschaulichen hier eine Quellcodeversion nach der objektorientierten Herangehensweise. Dazu wird ein „Punktpro-
179
6.3 Lineare Algebra auf Gittern double ddot_stride(LA_Dvector *x, LA_Dvector *y, int *error) { int i, start, stride, m; double local_sum = 0.0, sum, *x_data, *y_data; if (x->type != y->type) { /* Check for compatible types */ *error = LA_TYPE_ERR0R; /* FAILURE */ return(0.0);
} /* Determine the stride based on type */ if (x->type == LA_GRID_R0W) { start = x->dis->grid->p; stride = x->dis->grid->P;
> else { start = x->dis->grid->q; stride = x->dis->grid->Q;
> /* Sum up my part (non-optimized) */ m = x->v.m; x_data = &(x->v.data[0]); y_data = &(y->v.data[0]); for (i = start; i < m; i += stride) local_sum += x_data[i] * y_data[i]; /* Get the sum of all parts */ MPI_Allreduce(&local_sum, &sum, 1, MPI_D0UBLE, MPI.SUM, x->dis->grid->grid_comm); /* Return result */ •error = MPI_SUCCESS; return(sum);
} Abbildung 6.21: Objektbasiertes inneres (Punkt-)Produkt Vektoren, ohne BLAS
(„strided"- Version) für verteilte
dukt"-Objekt erzeugt, das die Beziehung zwischen den zwei Vektoren für das Punktprodukt beschreibt. Immer wenn ein Punktprodukt benötigt wird, wird (*xy->op) () ausgeführt. Das Fehlen von Argumenten erscheint hier wie eine Unzulänglichkeit, aber es ist tatsächlich ein Zeichen dafür, dass wir unseren Code stark optimiert haben! 2 Wir zeigen die mit sequentiellem BLAS arbeitende Fassung, um den Leser daran zu erinnern, dass wir auf effiziente lokale Operationen zugreifen können (unter der Voraussetzung einer ausreichend großen Datenmenge); siehe Abbildungen 6.21 und 6.22. 2 I n C + + kann man alternativ O p e r a t o r e n überladen, sodass Formeln mit Vektoren und Matrizen, wie in der M a t h e m a t i k gewohnt, ausgedrückt werden können. Leider verursacht das Uberladen durch t e m p o r ä r e O b j e k t e (engl.: temporaries) in vielen C + + - S y s t e m e n zusätzlichen Aufwand. In unserem Ansatz mit C sind alle t e m p o r ä r e n Variablen explizit sichtbar.
180
6 Parallele Bibliotheken
void LAX_ddot_strided_blas(LA_Dvector_ddot_stride_binop double *result)
*xy,
{ int start, stride; double local_sum = 0.0; LA_Dvector *x, *y; /* Dereference the binary vector operands: */ χ = xy -> binop.x; y = xy - > binop.y; /* Determine the stride based on type */ start = xy - > local_start; stride = xy -> local_stride; /* Sum up my part */ blas_ddot(&(x->v.m), &(x->v.data[start]), festride, &(y->v.data[start]), ftstride); /* Get the sum of all parts */ MPI_Allreduce(&local_sum, result, 1, MPI_D0UBLE, MPI_SUM, xy->binop.comm); /* Return result */ xy -> binop.error = 0;
} Abbildung 6.22: Objektorientiertes Vektoren, mit BLAS
inneres (Punkt-)Produkt
(„strided"-Version)
für
verteilte
Versetztes inneres Produkt. Falls die Vektoren zu Beginn der Operation nicht passend verteilt sind, wird hier die Umverteilung der Vektoren und das innere Produkt zu nur einer Berechnung verknüpft. Speziell löst es den Fall, in dem ein Vektor ein „Zeilen"vektor und der andere ein „Spalten"vektor ist. Bis jetzt hatten wir stets angenommen, dass die Vektoren kollinear sind (entweder beide Zeilenvektoren oder beide Spaltenvektoren) und in gleicher Weise verteilt vorliegen. In der zur Verfügung gestellten Software ist sowohl eine objektbasierte als auch eine objektorientierte Funktionsversion hierfür enthalten. Eine schöne Eigenschaft dieser Routine zeigt sich darin, dass die objektorientierte Variante benötigt wird, um einen angemessenen Berechnungaufwand zu erzielen, weil es hierfür unerlässlich ist, die Operationen in jedem Prozess ein für allemal zu organisieren. Matrix-Vektor-Produkt. Um die Verwendung von Vektoren gemeinsam mit dicht besetzten Matrizen zu veranschaulichen, haben wir auch die äquivalenten objektbasierten sowie objektorientierten Bibliotheken für das dicht besetzte Matrix-Vektor-Produkt aufgenommen. Hier wächst die Anzahl der Wahlmöglichkeiten zur Datenoptimierung. Infolgedessen muss eine große Anzahl von if-Anweisungen ausgeführt werden, wenn die Vektoren und Matrizen nicht passend verteilt sind. Mit der objektorientierten Herangehensweise wird dies ein für alle Mal erledigt. Beim objektbasierten Zugang müssen bei jedem Aufruf zahlreiche bedingte Verzweigungen abgearbeitet werden.
181
6.4 Der LINPACK-Benchmark mit MPI
6.4
Der LINPACK-Benchmark mit MPI
Der LINPACK-Benchmark ist ein bekanntes Paket numerischer Programme, das häufig zur Ermittlung der Leistung von Rechnern bezüglich ihrer Gleitkommaoperationen eingesetzt wird. LINPACK selbst ist ein Beispiel einer erfolgreichen numerischen Bibliothek und es ist interessant, kurz einige Aspekte einer parallelen Version dieses Programms zu besprechen. Wir weisen darauf hin, dass intensiv an einer parallelen Variante von LAPACK, dem Nachfolger von LINPACK, gearbeitet wird. Dieses Paket ist bekannt als ScaLAPACK (für Scalable LAPACK) [BCC+97]. Wir wollen nur einige der Themen, die bei der Entwicklung dieses Programms von Bedeutung sind, anreißen. Das Benchmark-Programm löst das lineare System Ax = b für χ bei gegebener Matrix Α und gegebenem Vektor b. Die Matrix Α ist dicht besetzt (d. h. alle Elemente sind verschieden von Null). Das Programm löst dieses Problem mit einer Faktorisierung von Α zu PLU; dabei sind L eine untere und U eine obere Dreiecksmatrix, Ρ eine Permutationsmatrix. Die Permutationsmatrix repräsentiert den Zeilentausch aus dem Algorithmus zur partiellen Pivotisierung, mit dem die numerische Stabilität verbessert wird. Da bei der Erzeugung der Faktorisierung jede Zeile der Matrix verwendet wird, wird zur Ermittlung der Permutation die Zeile mit dem größten Element benötigt. Wir nehmen an, dass die Zeilen (und Spalten) der Matrix über die Prozesse verteilt sein können. Um dieses Element zu finden, können wir den in Abbildung 6.23 dargestellten Code verwenden. Hier wird die vordefinierte Reduktionsoperation MPI_MAXLOC als MP I_0p-Argument im Aufruf von MP I _ALLREDUCE eingesetzt. Diese Routine liefert uns genau das, was wir brauchen, nämlich das Maximum in der ersten Komponente, dessen Lage, also die Zeile, in der zweiten Komponente. Diese Bibliothek hat eine interessante Eigenschaft. Sobald ein Programmstück erstellt wird, in dem ein allgemeiner Kommunikator benutzt wird (anstelle einer festen Verknüpfung mit MPI_C0MM_W0RLD), wird dieser unmittelbar für jede Teilmenge von Prozessen zugänglich. Deshalb kann eine mit MPI geschriebene parallele Bibliothek zur Lösung linearer Systeme für eine Anwendung, die mit „Task"-Parallelität arbeitet und bei der jede Teilaufgabe (task) selbst parallelisiert ist, genutzt werden (Task-Parallelität double p r e c i s i o n d t e m p ( 2 ) , ddummy(2) d t e m p ( l ) = d a b s ( work( j , j ) ) dtemp(2) = myrow c a l l MPI_ALLREDUCE( dtemp, ddummy, 2, MPI_DOUBLE_PRECISION, & MPI_MAXLOC, colcomm, i e r r ) i p i v n o d e = ddummy(2) i f ( ddummy(l) . e q . O.OdOO ) i p i v n o d e = icurrow
Abbildung
6.23:
Codeteil zur Ermittlung
einer
Pivotzeile
182
6 Parallele Bibliotheken
bedeutet die Teilung einer Problemabarbeitung in miteinander kommunizierende Aufgaben). Ohne Kommunikatoren (genauer: ohne die Gruppenkomponente des Kommunikators) könnte die Bibliothek für solch einen Fall nicht benutzt werden (siehe [Ban94]).
6.5
Strategien für die Erstellung von Bibliotheken
Das Beispiel zum nichtblockierenden Broadcast ist ein Beispiel für eine asynchrone Bibliothek. Im Rest dieses Kapitels konzentrieren wir uns auf lose synchrone parallele Bibliotheken. Unter losen synchronen Bibliotheken verstehen wir solche, die im Wesentlichen nach dem SPMD-Modell (single program, multiple data) arbeiten. Sie arbeiten auf analogen Teilen einer Datenmenge, die „kompliziert" oder irregulär sein kann, und kommunizieren von Zeit zu Zeit. Dabei kann es mal kollektive Kommunikationen, mal ausgewählte Punkt-zu-Punkt-Ubertragungen geben. In jedem Fall ist die Anzahl der erzeugten und aufgenommenen Nachrichten für solche Bibliotheken vorab genau bekannt. Meistens sind auch Größe und Quellen aller Nachrichten bereits absehbar, wenn das Programm geschrieben wird. Dass Prozesse anderen Prozessen zwischen synchronisierenden Kommunikationen „vorauseilen" können, ist der Grund für die Bezeichnung als „lose synchron" [FJL + 88]. Zu diesem Modell gehört auch die Idee, dass bestimmte Prozesse Nachrichten senden und weiter arbeiten können, auch wenn deren Partner die Empfangsoperation nicht ordnungsgemäß beenden konnte. Um das zu ermöglichen, nutzen wir MPI-Routinen, die nicht blockieren. Mit den Möglichkeiten von MPI gibt es nun spezielle Strategien für die Entwickler von Bibliotheken: • Dupliziere Kommunikatoren zum Zwecke eines sicheren Kommunikationsraums, wenn parallele Bibliotheken aufgerufen werden (MPI_Comm_dup, MPI_Comm_create). • Definiere auf Kommunikatoren basierende verteilte Strukturen. • Verwende mit MPI definierte virtuelle Topologien, um die Algorithmen leichter verständlich zu halten (z.B. MPI_Cart_create). • Benutze im Zusammenhang mit virtuellen Topologien geeignete weitergehende Technologien (z.B. Topologiehierarchien wie in Abschnitt 6.3.1). • Benutze Attribute zu Kommunikatoren (sogenanntes Attribut-Caching), wenn ein Kommunikator mit zusätzlichen kollektiven Operationen versehen wird (siehe Abschnitt 6.2). • Falls die Bibliothek mit Fehlerbehandlungen arbeiten sollte, die von einer MPIRoutine übergeben werden (ζ. B. um anwendungsspezifischere Fehlermeldungen zu bekommen), binde ein anderes Fehlerbehandlungsprogramm an den privaten Kommunikator der Bibliothek (siehe Abschnitt 7.7). Eine Reihe von Mechanismen, die in Bibliotheken vor der Zeit von MPI angewendet wurden, um korrekte Programme zu erzielen, sind nicht mehr notwendig und können getrost ausrangiert werden:
6.6 Beispiele für Bibliotheken
183
• Es ist nicht mehr erforderlich, seitens der Bibliothek die Benutzung von Etiketten bekannt zu geben, um Konflikte mit Etiketten des Anwendercodes zu vermeiden. Wenn die Bibliothek ihren eigenen Kommunikator verwendet, haben sowohl Bibliothek als auch Anwender Zugriff auf den vollständigen Umfang an Etiketten. • Eine parallele Bibliothek muss nun nicht mehr das Verhalten einer sequentiellen Bibliothek durch Synchronisation bei Start und Ende jedes parallelen Aufrufs nachbilden, um einen geordneten Ablauf zu gewährleisten. Bezüglich der Bibliothek stellen lokale Kommunikatoren sicher, dass keine Bibliotheksnachricht vom Anwendercode abgefangen wird und umgekehrt. • Es gibt keine Notwendigkeit, das Programmiermodell für Bibliotheken auf die Annahme von nicht überlappenden Prozessgruppen zu beschränken, da Kommunikatoren mit sich überlappenden Gruppen kein Problem für MPI-Bibliotheken darstellen. • Viele existierende Bibliotheken arbeiten nur bezüglich „aller" Prozessoren (Prozesse), die ein Anwender anfordert. Mit MPI ist es leicht, eine Bibliothek so zu schreiben, dass sie eine wie auch immer festgelegte Gruppe von Prozessen nutzen kann, wenn sie aufgerufen wird.
6.6
Beispiele für Bibliotheken
Eine der Hoffnungen des MPI-Forums war, dass die Eigenschaften von MPI zur Unterstützung der Bibliotheksentwicklung die Schaffung portierbarer Bibliotheken tatsächlich anstoßen würden, sodass die Entwickler paralleler Software über immer mehr leistungsstarke Werkzeuge bei der Erstellung ihrer Programme verfügen könnten. Die folgende Aufstellung zeigt eine Auswahl der Bibliotheken, die aktuell nutzbar sind. P E T S c ist eine Bibliothek zur parallelen Lösung linearer und nichtlinearer Gleichungssysteme, wie sie bei der Diskretisierung partieller Differentialgleichungen entstehen [BGMS97]. S c a L A P A C K enthält Routinen für die parallele Lösung von dicht besetzten linearen Gleichungssystemen und Bandsystemen linearer Gleichungen sowie für die Eigenwertberechnung [BCC+97], P L A P A C K ist eine weitere Bibliothek für Operationen mit dicht besetzten Matrizen [vdG97]. Parallel Print Function stellt eine komfortable Menge an p r i n t f ähnlichen Funktionen für C- und Fortranprogramme bereit. Es ist eins der Projekte des Parallel Tools Consortium, [May]. F F T W ist eine portierbare FFT-Bibliothek. Die mehrdimensionale F F T benutzt MPI [Fft],
184
6 Parallele Bibliotheken
Diese Bibliotheken unterscheiden sich in dem Grad, in dem sie die Eigenschaften von MPI zur Bibliotheksentwicklung ausnutzen. Einige Bibliotheken, wie ζ. B. PETSc, machen intensiven Gebrauch von der Leistungsfähigkeit von MPI, indem sie langlebige (persistente) Kommunikation, Kommunikatorattribute, private Kommunikatoren und andere Techniken einsetzen. Andere Bibliotheken nutzen eine weit geringere Menge an Routinen. In einigen Fällen kann man Bibliotheken finden, die einfache Portierungen von für ältere Message-Passing-Systeme geschriebenen Bibliotheken sind. Diese können sehr nützlich sein, sollten aber wegen der am Anfang dieses Kapitels beschriebenen Gründe gegebenenfalls mit entsprechender Vorsicht verwendet werden. Die häufigste Einschränkung für eine Bibliothek ist der Einsatz von MPI_C0MM_W0RLD für die gesamte Kommunikation. Eine Bibliothek, die MPI verwendet, um eventuell auftretende Uberlagerungen von Nachrichten innerhalb der Bibliothek mit denen außerhalb der Bibliothek in jedem Fall zu vermeiden, arbeitet entweder mit MPI_Comm_dup oder mit einer der anderen Routinen zur Konstruktion von Kommunikatoren (solche wie MPI_Cart_create), mit einem expliziten Kommunikatorargument oder mit MPI_Barrier zur Trennung der Bibliotheksnachrichten von Nachrichten, die nicht bibliotheksintern sind. Oft kann man den UNIX-Befehl nm anwenden, um herauszufinden, welche Routinen in einer Bibliothek benutzt werden. So listet zum Beispiel das Kommando nm /usr/local/lib/libpetsc.a I grep MPI_
alle MPI-Routinen auf, deren sich die Bibliothek l i b p e t s c . a bedient. In Abhängigkeit vom exakten Format der Ausgabe des nm-Kommandos gibt nm /usr/local/lib/libpetsc.a I grep - i MPI_ | \ awk '{print $8l·' I sort | uniq
eine sortierte Liste mit den benutzten MPI-Funktionen (und anderen Symbolen, die MPI_ enthalten) aus. Wenn diese Liste nur eine Teilmenge der Punkt-zu-Punkt-Funktionen (ζ. B. MPI-Send und MPI Jlecv) und die Funktionen MPI_Comm_size und MPI_Commjrank, mit denen auf die Informationen zu einem Kommunikator zugegriffen werden kann, enthält, dann weiß man, dass diese Bibliothek die Fähigkeiten von MPI zur Kommunikationssicherheit nicht nutzt und muss sie deshalb mit der gebotenen Vorsicht einsetzen. Dieses Kommando zeigt, dass ζ. B. die Bibliothek PETSc 46 MPI-Funktionen verwendet, darunter insbesondere die für Attribute und langlebige Kommunikation sowie MPI_Comm_dup.
7
Weitere Eigenschaften von MPI
Dieses Kapitel befasst sich mit weiter entwickelten Routinen des MPI-Standards, die in unseren Betrachtungen bisher nicht vorkamen. In diesem Rahmen führen wir mehrere interessante Beispielprogramme ein.
7.1
Simulation von Shared-Memory-Operationen
Durch das gesamte Buch hindurch haben wir uns auf das Message-Passing-Berechnungsmodell konzentriert. Für manche Anwendungen passt jedoch von deren „Natur" her das Shared-Memory-Modell besser, bei dem der gesamte Speicher des Parallelrechners von jedem Prozess aus direkt zu erreichen ist. In diesem Abschnitt erklären wir, wie man die Grundfunktionalität des Shared-Memory-Modells in einer Umgebung mit verteiltem Speicher umsetzen kann und welche MPI-Routinen dafür geeignet sind.
7.1.1
Gemeinsamer und verteilter Speicher
Die grundlegende Eigenschaft des Message-Passing-Modells besteht darin, dass mindestens zwei Prozesse an jeder Kommunikation beteiligt sind — Sende- und Empfangsoperationen müssen paarweise angeordnet sein. Dagegen ist für das Shared-Memory-Modell bezeichnend, dass jeder Prozess auf den gesamten Speicher einer Maschine zugreifen kann. In einer Architektur mit verteiltem Speicher, in der jede Speicheradresse zu einem bestimmten Prozessor gehört, bedeutet dies, dass jeder Prozess auf den lokalen Speicher eines anderen Prozesses zugreifen können muss, und zwar ohne jegliche besondere Aktivität seitens des Prozesses, in dessen Speicher gelesen oder geschrieben wird. Zu diesem Zweck müsste dann jeder Prozessor für zwei verschiedene Aufgaben zuständig sein: zum einen für den Rechenprozess, der die eigentliche Arbeit erledigt, und zum anderen für einen Datenserverprozess, der den anderen an einer Berechnung beteiligten Prozessen Zugriff auf seinen Speicher ermöglicht (siehe Abbildung 7.1). Diese zwei Prozesse müssen keine separaten MPI-Prozesse sein. In vielen Fällen würde das Betriebssystem dies auch nicht gestatten. Wenn doch, dann kann sich das Umschalten zwischen den beiden Prozessen als zu aufwändig erweisen. Zur Lösung dieser Problematik gibt es verschiedene Ansätze, abhängig davon, welche Erweiterungen zum Message-Passing-Modell von dem darunter liegenden System unterstützt werden. Eine Möglichkeit ist die Nutzung eines eigenständigen Threads zur Bearbeitung der Datenanforderungen. Mit MPI-2 ist ein direkteres Herangehen, der sogenannte einseitige (engl.: one-sided) oder entfernte Speicherzugriff (engl.: remote memory access) möglich, was in Using MPI-2 [GLT99] erläutert wird. Wir wollen hier annehmen, dass wir zum Erreichen unseres Ziels lediglich mit den in MPI-1 definierten Mitteln und einem Thread je Prozess arbeiten müssen.
186
Abbildung
7.1.2
7 Weitere Eigenschaften von M P I
7.1: Gemeinsame Benutzung eines verteilten Speichers
Ein Zähler-Beispiel
Wir beginnen mit dem einfachsten Fall, der Simulation eines einzelnen Speicherplatzes im gemeinsamen Speicher. Es ist erstaunlich nützlich, über eine einzige „gemeinsame" Variable zu verfügen, die von jedem Prozess mit einer atomaren Operation aktualisiert werden kann. In einer realen Shared-Memory-Umgebung wird der Zähler durch gewöhnliche Speicheroperationen gelesen und aktualisiert, wobei die Aktualisierung aber durch eine geeignete Sperre (engl.: lock) geschützt werden muss. In einer Message-PassingUmgebung ist keine Sperre erforderlich, da nur ein Prozess den Speicherplatz tatsächlich aktualisiert. Folglich kann man einen gemeinsamen Zähler leicht mit M P I implementieren, wenn man genau einem MPI-Prozess die Aufgabe überträgt, den Zähler zu verwalten sowie den Service zu Anfragen und Aktualisierung zu leisten. Um unser Anliegen besonders anschaulich zu gestalten, wollen wir annehmen, wir wünschten etwas in der Art des NXTVAL-Zählers der Message-Passing-Bibliothek T C G M S G [Har91]) zu implementieren. Die Funktion NXTVAL gibt den Wert eines eingebauten Zählers zurück, der beim Start mit 0 initialisiert wird. Jede Abfrage seines Werts erhöht diesen um 1. Es ist garantiert, dass beim Aufruf von NXTVAL durch mehrere Prozesse niemals zwei Prozesse den gleichen Wert erhalten und dass alle Werte des Zählers nacheinander zugeteilt werden, ohne einen zu überspringen. Wir bestimmen einen Prozess 1 als „Server" für diese Variable. Er kann zu einem großen Teil in der gleichen Weise wie der Server für die Zufallszahlen in Kapitel 3 aufgebaut werden. Wir wollen den Zähler durch Definition von drei Routinen kapseln, um die^ n einigen MPI-Implementierungen sind mehrere Prozesse je Prozessor zulässig. Das ist im MPIS t a n d a r d weder gefordert noch verboten. Eine solche Umgebung wäre für das hier besprochene Beispiel a m sinnvollsten; ansonsten wäre einer der Prozessoren die meiste Zeit unbeschäftigt, d a er auf die Zähleranfragen warten muss.
7.1 Simulation von Shared-Memory-Operationen
187
int MPI_Rsend(void* buf, int count, MPLDatatype datatype, int dest, int tag, MPLComm comm) int MPI_lprobe(int source, int tag, MPLComm comm, int *flag, MPLStatus *status) Tabelle 7.1: C-Signaturen für ΜΡI-Funktionen im Zähler-Beispiel MPI_RSEND(buf, count, datatype, dest, tag, comm, ierror) buf(*) integer count, datatype, dest, tag, comm, ierror MPI_IPROBE(source, tag, comm, flag, status, ierror) logical flag integer source, tag, comm, status(MPLSTATUS-SIZE), ierror Tabelle 7.2: Fortran-Signaturen für MPI-Routinen im Zähler-Beispiel sen mit der MPE-Bibliothek zu handhaben: MPE_Counter_create, MPEXounter_free und MPE_Counter_nxtval. MPE_Counter_create wird eine kollektive Operation sein (sie muss von allen Prozessen des zugehörigen Kommunikators aufgerufen werden) und einen Prozess aus der Gruppe des Kommunikators abspalten, der den Zähler verwaltet, und zwei neue Kommunikatoren zurückgeben. Der eine Kommunikator, counter_comm, wird in Aufrufen von MPE_Counter_nxtval eingesetzt, der andere, smaller_comm, wird von den verbleibenden Prozessen für die eigentlichen Berechnungen genutzt. Der Aufruf von MPE.Counterjfree gibt diese Kommunikatoren wieder frei und beendet den Serverprozess, wodurch der ursprüngliche Aufruf von MPI_Counter_create terminiert. Dieses Herangehen nutzt die Tatsache aus, dass MPI, im Unterschied zu vielen anderen Systemen, kollektive und Punkt-zu-Punkt-Operationen auf Kommunikatoren, die auf beliebigen Untergruppen von Prozessen basieren, unterstützt. So ist der Kommunikator smaller.comm genauso gut einsetzbar wie der an MPI_Counter_create übergebene, nur dass er über einen Prozess weniger verfügt. Diese Client-Server-Berechnung ist einfach genug, um hier den kompletten Quelltext zeigen zu können. MPE_Counter_create ist in Abbildung 7.2 dargestellt; enthalten ist auch die Behandlung eines nicht erwarteten Etiketts (was ein Fehler in unserem Programm wäre), in der MPIJVbort verwendet wird, um das Programm abzubrechen (siehe Abschnitt 7.7.4). MPE_Counter_nxtval wird in Abbildung 7.3 gezeigt. MPE_Counter jf ree in Abbildung 7.4 ist ebenfalls eine kollektive Operation. Der Prozess mit Rang 0 im Kommunikator des Zählers sendet eine Nachricht an den Zählerprozess, damit auch er MPE_Counter_free aufruft. Zur Erzeugung des Kommunikators smaller_comm benutzen wir MPI_Comm_split. Dazu verwenden wir den speziellen Wert MPIJJNDEFINED als color-Wert für den Prozess, der als Zähler-Prozess dienen soll und damit nicht zum Kommunikator smaller_comm
188
7 Weitere Eigenschaften von M P I
v o i d MPE_Counter_create( MPI_Comm old_comm, MPI_Comm *smaller_comm, MPI_Comm *counter_comm ) •c i n t c o u n t e r = 0; i n t m e s s a g e , done = 0, myid, numprocs, s e r v e r , c o l o r ; MPI_Status s t a t u s ; MPI_Comm_size(old_comm, &numprocs); MPI_Comm_rank(old_comm, femyid); s e r v e r = numprocs-1; /* l a s t proc i s server */ MPI_Comm_dup( old_comm, counter_comm ) ; / * make one new comm * / i f (myid == s e r v e r ) c o l o r = MPI.UNDEFINED; e l s e c o l o r = 0; MPI_Comm_split( old_comm, c o l o r , myid, smaller_comm ) ; if
(myid == s e r v e r ) { / * I am t h e s e r v e r * / while (Idone) { MPI_Recv(&message, 1, MPI.INT, MPI_ANY_S0URCE, MPI_ANY_TAG, *counter_comm, & s t a t u s ) ; i f (status.MPI.TAG == REQUEST) { MPI_Send(fecounter, 1, MPI_INT, status.MPI_S0URCE, VALUE, *counter_comm ) ; counter++;
>
e l s e i f (status.MPI_TAG == GOAWAY) { done = 1;
>
else { f p r i n t f ( s t d e r r , "bad t a g 7«d s e n t t o MPE c o u n t e r \ n " , status.MPI_TAG ) ; MPI_Abort( *counter_comm, 1 ) ;
>
>
MPE_Counter_free( smaller_comm, counter_comm ) ;
>
}
Abbildung
7.2: MPE^Count er .create
7.1 Simulation von Shared-Memory-Operationen
int MPE_Counter_nxtval( MPI_Comm counter_comm, int *value ) •C int server,numprocs; MPI_Status status; MPI_ComiD_size(counter_comni, ftnumprocs); server = numprocs-1; MPI_Send(NULL, 0, MPI_INT, server, REQUEST, counter.comm); MPI_Recv(value, 1, MPI_INT, server, VALUE, counter_comm, ftstatus); return 0;
> Abbildung 7.3: MPE_Counter_nxtval
int MPE_Counter_free( MPI_Comm *smaller_comm, MPI_Comm *counter_comm )
{ int myid, numprocs; MPI_Comm_rank( *counter_comm, tonyid ); MPI_Comm_size( *counter_comm,ftnumprocs); /* Make sure that all requests have been serviced */ if (myid == 0) MPI_Send(NULL, 0, MPI.INT, numprocs-1, GOAWAY, *counter_comm); MPI_Comm_free( counter_comm ); if (*smaller_comm != MPI_C0MM_NULL) •[ MPI_Comm_free( smaller_comm );
> return 0;
Abbildung
7·4: MPE_Counter_free
189
190
7 Weitere Eigenschaften von M P I
gehört. Für diesen Prozess gibt MPI_Comm_split den Wert MPI_C0MM_NULL zurück. Alle anderen Prozesse werden dem neuen Kommunikator zugeordnet. Da wir myid als keyArgument angegeben haben, sind die Prozesse in smaller_comm genau so geordnet wie in old_comm.
7.1.3
Realisierung des gemeinsamen Zählers per Polling anstelle eines gesonderten Prozesses
Der Nachteil der gerade beschriebenen Implementierung ergibt sich natürlich aus der Tatsache, dass ein Prozess mit der Beobachtung des Zählers „verschwendet" wird; er könnte in dieser Zeit bei der eigentlichen Berechnung (vielleicht) sinnvoll eingesetzt werden. Um das zu vermeiden, müsste dieser Prozess simultan zur Verwaltung des Zählers nützliche Arbeit leisten. Im Rahmen der Einschränkungen des Message-PassingBetriebs, in dem jegliche Kommunikation explizite Empfangsoperationen erfordert, besteht eine Möglichkeit darin, mit einem periodischen Test zu überprüfen, ob eine Anfrage für den „Zählerdienst" eingetroffen ist. Diese Technik nennt man Polling. Falls die Nachricht eingetroffen ist, wird sie empfangen und entsprechend beantwortet, andererseits die Berechnung fortgesetzt. Dieser Test kann mit einem Aufruf von MPI-Iprobe erfolgen. Diese Routine stellt fest, ob Nachrichten, die zu den übergebenen Argumenten für Quelle, Etikett und Kommunikator passen, angekommen sind. Der Unterschied in der Verwendung von MPI_Test und MPI_Iprobe besteht darin, dass MPI_Test mit einer spezifischen nichtblockierenden Sende- oder Empfangsoperation, die eine Anfrage (request) erzeugt hat, verbunden ist. Für den Fall des Empfangens bedeutet das speziell, dass der Puffer vom Anwenderprogramm bereits zur Verfügung gestellt wurde. MPI_Iprobe und die entsprechende blockierende Version MPI_Probe sind demgegenüber nicht mit einer speziellen Anfrage assoziiert. Sie testen nämlich auf Empfang von Nachrichten, die mit bestimmten Kenndaten ankommen. Für den periodischen Test auf Zähleranfragen und deren Verarbeitung ruft das Anwendungsprogramm damit periodisch MPE_Counter_service( comm ) auf, wobei comm der für den Zähler angelegte Kommunikator ist. Wir wollen annehmen, dass Prozess 0 für den Zähler verantwortlich ist, und COUNTER das Etikett für die Kommunikationen, die den Zähler betreffen, ist. Der Quelltext für MPE_Counter_service wird in Abbildung 7.5 gezeigt. Gemischte Verallgemeinerungen sind natürlich möglich (mehrfache Zähler, separate Kommunikatoren), aber wir zeigen hier nur den einfachsten Fall. Der Programmierer ist verantwortlich dafür, dass diese Routine oft genug vom Server aufgerufen wird, um Anfragen bearbeiten zu können. Das kann ζ. B. mit der im Abschnitt 7.6 beschriebenen Profiling-Schnittstelle, mit der MPI-Aufrufe überwacht werden können und auf eingetroffene Nachrichten getestet werden kann, erfolgen. Die Anfragen selbst werden mit MPI_Send( NULL, 0, MPI_INT, 0, COUNTER, comm ); MPI_Recv( feval, 1, MPI_INT, 0, COUNTER, comm, &status );
7.1 Simulation von Shared-Memory-Operationen
191
void MPE_Counter_service( MPI_Comm comm ) { static int counter = 0; int requester, flag; MPI_Status status; /* Process all pending messages */ do { MPI_Iprobe(MPI_ANY_S0URCE, COUNTER, comm, &flag, ftstatus ); if (flag) { requester = status.MPI_S0URCE; MPI_Recv(MPI_B0TT0M, 0, MPI_INT, requester, COUNTER, comm, festatus ); counter++; MPI_Send(&counter, 1, MPI_INT, requester, COUNTER, comm );
>
> while (flag);
> Abbildung
7.5: MPE.Counter.service
realisiert. Man beachte, dass Server und Etikett explizit angegeben werden, also bekannt sind. Der Parameter count in der send-Routine ist auf 0 gesetzt, da dieser Anfrage keine Daten zugeordnet sind, d. h. das Etikett ist ausreichend. Wir hätten an dieser Stelle, das sei noch bemerkt, auch mit MPI_Sendrecv arbeiten können.
7.1.4
Fairness im Message-Passing
Im gerade betrachteten Beispiel kann ein Prozess, der sehr schnell sehr viele Anfragen stellt, verhindern, dass andere Prozesse einen Zählerwert erhalten. Betrachten wir zum Beispiel was passiert, wenn MPI_Iprobe in Abbildung 7.5 stets dem Prozess mit dem niedrigsten Rang im Kommunikator, bei dem die Werte für Quelle, Etikett und Kommunikator übereinstimmen, antwortet. Wenn beispielsweise der Prozess mit Rang 0 viele Zähleranfragen in kurzen Abständen stellt, wird dieser Prozess durch den Code in Abbildung 7.5 gegenüber anderen Prozessen mit höherem Rang bevorzugt. MPI garantiert keine faire Auswahl von Nachrichten durch MPI_Recv oder MPI.Iprobe, wenn Platzhalter wie MPI_ANY_S0URCE oder MPI_ANY_TAG verwendet werden. Welches Verhalten wäre uns in diesem Fall lieb? Grundsätzlich wollen wir sicher nicht, dass ein Prozess den Zähler an sich reißt, sondern dass MPE.Counter .service allen um einen Wert anfragenden Prozessen antwortet, bevor ein ständig immer wieder anfragender Prozess erneut eine Antwort erhält. Um das zu erreichen, müssen wir auf den Einsatz von MPI_ANY_S0URCE verzichten. Damit muss der Aufruf von MPI.Iprobe zu for (rank=0; rank
>
192
7 Weitere Eigenschaften von M P I
geändert werden. Das funktioniert zwar, doch bietet M P I eine bessere Möglichkeit. Anstelle der Verwendung von MPI_Iprobe, gefolgt von MPI Jlecv, können wir mit MPI.Irecv und nachfolgendem MPI.Test arbeiten. Da in M P I jedes receive eine festgelegte Quelle (oder es ist MPI_PROC_NULL eingetragen) haben muss, brauchen wir ein MPI_Irecv für jeden Prozess aus dem Kommunikator. Dann soll MPE.Counter.service jede eingegangene Empfangsanfrage prüfen und feststellen, welche Prozesse bereit sind — diesen wird dann ein aktualisierter Zählerwert gesendet. Wir könnten also ein Teilprogramm ähnlich dem der Schleife mit MPI_Iprobe verwenden, doch bietet M P I eine einzelne Funktion, die genau das leistet, was wir beabsichtigen: MPI.Testsome. Diese Routine prüft jede Anfrage und stellt fest, welche Anfragen beantwortet wurden. Zurückgegeben werden in outcount die Anzahl beendeter Anfragen, in array_of-indices deren Indizes und in den ersten outcount Komponenten von array_of .statuses die entsprechenden Statuswerte. Diese „faire" Version von MPE_Counter_service ist in Abbildung 7.6 zu sehen. Da in diesem Beispiel MPI_Irecv verwendet wird, muss das Feld mit den Anfragen, die MPE_Connter_service überprüft, vorher angelegt werden. Dafür benutzt man die Routine MPE_Counter_service_setup. Diese Herangehensweise hat einige heikle Stellen. Eine davon ist die globale Variable reqs. Als Konsequenz der Verwendung dieser globalen Variable kann diese Version von MPE_Counter_service jeweils nur mit einem Kommunikator benutzt werden. Darüber hinaus muss die Routine MPE_Counter_service mit malloc und free arbeiten, um temporären Speicherplatz für MPI_Testsome bereit zu stellen. Hier ist ein Punkt, an dem die an den Kommunikator gebundenen Attribute (siehe Abschnitt 6.2) ein geeignetes und effektives Mittel sind, die Verwendbarkeit von Bibliotheksroutinen zu verbessern. Die Art und Weise, mit der M P I Fairness beim Message-Passing erreicht, ist dem Vorgehen in Unix, um Fairness bei der Bedienung von Lese- und Schreiboperationen mit Dateideskriptoren zu erzielen, sehr ähnlich. Zu Details hierfür verweisen wir auf Abschnitt 9.1.
7.1.5
Ausnutzung von Anfrage-Antwort-Mustern
In den vorherigen zwei Abschnitten bestand der „gemeinsame Speicher" aus einem einzelnen Wert, der von einem einzelnen Prozess verwaltet wird. Es ist sicher nicht schwer zu sehen, wie dieses Konzept zu verallgemeinern ist, um den gesamten, oder fast den gesamten, Speicher einer Maschine als gemeinsam zu behandeln. Wenn mehrere Prozesse je Prozessor zulässig sind, können wir das Vorgehen aus Abschnitt 7.1.2 anwenden, bei dem der Speicher jedes Knotens von einem separaten Prozess verwaltet wird. Wenn dies nicht gegeben ist oder wenn wir den Overhead, verursacht von in konstanten Zeitabschnitten wechselnden Prozessen auf einem Prozessor, nicht tolerieren können, ist der Zugang aus Abschnitt 7.1.3 möglich, allerdings auf Kosten der Aufrufe der Dienstroutine, die ausreichend häufig erfolgen müssen, damit rechtzeitig auf Anfragen reagiert werden kann. Genau dieser Wunsch, die zwei Nachteile zu vermeiden (mehrere Prozesse je Knoten oder häufige Aufrufe einer „Pollingroutine"), hat die Zugänge motiviert, die über das Message-Passing-Modell hinaus gehen. In Kapitel 10 sprechen wir dies kurz an.
7.1 Simulation von Shared-Memory-Operationen
/ • W e will see later how to remove the global variable reqs */ static MPI_Request *reqs; void MPE_Counter_service( MPI_Comm comm ) { static int counter = 0; int requester, outcount, size, i; MPI_Status »statuses; int »indices; MPI_Comm_size( comm, ftsize ); /* Allocate space for arrays */ statuses = (MPI_Status *)malloc( size * sizeof(MPI_Status) ); indices = (int *)malloc( size * sizeof(int) ); /* Process all pending messages FAIRLY */ do { MPI_Testsome( size, reqs,ftoutcount,indices, statuses ); if (outcount) { for (i=0; i > } while (outcount); free( statuses ); free( indices );
> void MPE_Counter_service_setup( MPI_Comm comm ) { int i, size; MPI_Comm_size( comm, ftsize ); for (i=0; i Abbildung 7.6: Eine faire Version von MPE_Counter.service
193
194
7 Weitere Eigenschaften von MPI
Es gibt eine Möglichkeit, die es uns erlauben sollte, die Leistungsfähigkeit unseres hier benutzten Mechanismus zu verbessern. Es gilt doch folgendes: Wann immer ein Prozess von einem anderen Daten anfordert, weiß er, dass die Anfrage auch beantwortet wird. Ebenso weiß der „Server"-Prozess, dass zu jeder Anfrage eine Anwort erwartet wird. Das bedeutet, dass die Anfrage von Daten, die bisher die Form MPI_Send MPI_Recv hatte, zu MPI_Irecv MPI_Send MPI_Wait umgeschrieben werden kann. Bei manchen Architekturen, vorzugsweise bei lose gekoppelten Systemen, die Netzwerke zum Datenaustausch zwischen Prozessen nutzen, kann das Protokoll zwischen sendenden und empfangenden Prozessen deutlich vereinfacht werden, wenn der sendende Prozess annehmen kann, dass das zugehörige receive bereits angestoßen wurde. Der Grund für ein einfacheres Protokoll ist der folgende: Wenn der sendenden Seite bekannt ist, dass ein Puffer bereit gestellt wurde (das ist die Hauptfunktion von MPI.Irecv), können alle Verhandlungen zwischen den Prozessen, um Platz für den Datenpuffer bereitzustellen, umgangen werden. In der Tat gibt es einige Netzwerklösungen, ζ. B. VIA [VIA], die das Vorhandensein von Empfangspuffern voraussetzen und zusätzliche Informationen und Nachrichtenverkehr erfordern, um zu gewährleisten, dass dieser Puffer auch wirklich vorhanden ist. MPI bietet für diese Situation eine spezielle Form für das Senden. Wenn der Sender sicher ist, dass das receive bereits angestoßen wurde, kann er mit MPI_Rsend arbeiten, (das „R" steht für „receiver ready", der MPIStandard nennt diese Art des Sendens „ready send".) Die MPI-Implementierung kann dies als normales Senden behandeln (die Semantik ist gleich der von MPI_Send), doch ist ihr hiermit, sofern es die Umstände zulassen, die Möglichkeit zur Optimierung des Protokolls gegeben. Wenn das zugehörige receive (noch) nicht angestoßen wurde, wird das als Programmierfehler behandelt und das Verhalten von MPI ist in diesem Fall Undefiniert. Da es bei der Verwendung von MPI_Rsend wichtig ist, sowohl die Senderais auch die Empfängerseite zu betrachten, zeigen wir eine Skizze des Codes für zwei Prozesse. Requester MPI_Irecv MPI_Send
Server MPI Jtecv MPI_Rsend
MPI_Wait Eine weitere Verfeinerung ist die Benutzung von MPI_Rsend sowohl durch den Anfrageprozess als auch durch den Server. Zu beachten ist, dass MPI_Rsend nur benutzt werden sollte, wenn bekannt ist, dass das receive bereits erfolgt ist. Die Message-Passing-Programmierung sieht bewusst kaum
7.2 Die vollständige Konfigurationswechselwirkung als Anwendungsbeispiel
195
Möglichkeiten vor, Programme zu schreiben, deren korrekte Ausführung vom genauen zeitlichen Ablauf und der Reihenfolge von Ereignissen abhängt. Die Routine MPI_Rsend aber ermöglicht das Schreiben von Code, der formal inkorrekt ist (weil das Programm nicht garantieren kann, dass MPI_Rsend nur aufgerufen wird, wenn das zugehörige MPI Jlecv bereits angestoßen worden ist), aber fast immer korrekt abläuft. Das bedeutet, dass man mit MPI_Rsend nur arbeiten sollte, wenn sich dadurch die Leistung des Programms verbessert, aber auch dann nur mit großer Vorsicht. Zudem nutzen zurzeit nur wenige Implementierungen diese durch MPI gebotene Möglichkeit. Es gibt aber mindestens eine MPI-Implementierung, die diese Möglichkeit bereitstellt, und Tests haben gezeigt, dass dies bezüglich der Leistung von Vorteil ist.
7.2
Die vollständige Konfigurationswechselwirkung als Anwendungsbeispiel
Die ab initio Chemie versucht, chemische Ergebnisse aus den Grundsätzen der Quantentheorie zu geladenen Teilchen und ihren Wechselwirkungen zu berechnen. Das Lösen der Schrödingergleichung führt auf Probleme aus der linearen Algebra, die einer Bearbeitung mit Parallelrechnern zugänglich sind. Einige der oben besprochenen Techniken werden in einem realen Anwendungsprogramm der Chemie benutzt, laufen auf einem Rechner des Typs Intel Delta und wurden nach MPI portiert. Die Methode der vollständigen Konfigurationswechselwirkung (engl.: full-configuration interaction) (FCI) führt zur exakten Lösung der elektronischen Schrödingergleichung, soweit dies die initiale algebraische Approximation der finiten Ein-Partikel-Basismenge zulässt. Die einzigen Fehler in einem FCI-Ergebnis stammen entweder aus der darunter liegenden finiten Ein-Partikel-Basis oder aus den Approximationen des nichtrelativistischen Born-Oppenheimer Hamilton-Operators. Die Fähigkeit, FCI-Wellenfunktionen zu berechnen, erlaubt dann auch eine Beurteilung der Approximationsmethoden (ζ. B. SCF, Vielkörper-Methoden und Truncated CI*) und gestattet es, die Unzulänglichkeiten der Ein-Partikel-Basismenge und der Hamilton-Approximation durch Vergleiche mit Experimenten einzuschätzen. Mit diesem Anwendungsprogramm wurde eine große FCI-Berechnung mit 94.930.032 Konfigurationen bzw. 418.639.400 Determinanten erfolgreich ausgeführt. Die Berechnungen erfolgten für Methan (r = 1.085600Ä) einer cc-p VDZ-Basismenge in einer C21, Untergruppe von T4, ferner mit einem eingefrorenen kanonischen SCF-Kernorbital. Dies ist nur eine aus einer ganzen Serie von Berechnungen. Details sind in [HS93] beschrieben. Das dort erläuterte spezielle Programm arbeitet mit einem „gemeinsamen Speicher" auf zwei verschiedene Weisen. Erstens, da es sich nicht um lokale Berechnungen handelt, agieren alle Prozesse als Datenserver, indem sie für die Bedienung von Datenanforderungen einen Mechanismus, ähnlich dem eben dargestellten, anwenden. Das ursprüngliche Programm, das für eine Intel Delta Maschine, ein System mit 512 Knoten und Vorgänger der Intel Paragon, geschrieben wurde, arbeitete mit h r e c v (analog zu MPI_Recv in einem separaten Thread) zusammen mit „force-type"-Nachrichten (entsprechend MPI_Rsend), um die angeforderten Daten zurückzugeben.
196
7 Weitere Eigenschaften von MPI
Zweitens wird die Last mit Hilfe eines gemeinsamen Zählers verteilt. Dieser Zähler ist von der gleichen Art wie der im Beispiel in diesem Abschnitt. In TCGMSG [Har91], womit das Programm ursprünglich geschrieben wurde, wird NXTVAL benutzt, das auf der Intel Paragon mit hrecv implementiert ist.
7.3
Erweiterte kollektive Operationen
Bisher haben wir in diesem Buch neue kollektive Operationen aus MPI eingeführt, so wie wir sie für einen parallelen Algorithmus, ein Programm oder eine Bibliothek benötigten. Aber es gibt noch einige wenige Routinen, die wir in unserer Palette von Beispielen bis zu dieser Stelle noch nicht gebraucht haben. Da diese Routinen wichtig sind und vielleicht gerade für ein bestimmtes Problem von Nutzen sein könnten, stellen wir sie hier gleich zusammen vor.
7.3.1
Bewegen von Daten
MPI bietet zahlreiche Operationen für kollektives Bewegen von Daten. Wir kennen bereits die MPI_Bcast-Routine und die Routinen zum Sammeln von Informationen, die so genannten Gather-Routinen. Zusätzlich zu diesen stellt MPI die zum Sammeln umgekehrte Operation zur Verfügung, die mit Scatter (deutsch: Streuen) bezeichnet wird (MPI_Scatter und MPI_Scatterv), sowie eine umfassende Scatter-Operation „alltoall" (MPI_Alltoall und MPI_Alltoallv). MPI-2 führt mit MPI_Alltoallw eine zusätzliche Variante von MPI_Alltoallv ein. Diese Operationen sind in Abbildung 7.7 im Uberblick dargestellt.
7.3.2
Kollektive Berechnung
Wir haben auch schon einige kollektive Berechnungsroutinen vorgestellt: MPI_Reduce führt eine Reduktion der Daten von jedem Prozess zum Wurzelprozess aus. Wir haben die Anwendung von MPI_SUM als Operation in MPI_Reduce gesehen, um Werte von allen Prozessen eines Kommunikators aufzusummieren. Es gibt in MPI eine Reihe zusätzlicher Operationen, zusammengestellt in Tabelle 7.3, die in jeder kollektiven Berechnungsroutine benutzt werden können. Die meisten sind selbst erklärend. Die zwei letzten, MPI_MAXLOC und MPI_MINLOC, sind ähnlich zu MPI-MAX und MPI_MIN, nur dass sie auch den Rang des Prozesses, bei dem das ermittelte Maximum bzw. Minimum gespeichert ist, zurückgeben (wenn das Maximum oder Minimum bei mehreren Prozessen existiert, wird der Rang des ersten ausgegeben). Der für MPI_MAXLOC und MPI_MINLOC verwendete Datentyp enthält sowohl den Wert als auch den Rang. Für weitere Details zu MPI_MAXLOC und MPI_MINLOC sei auf den MPI-Standard oder die man-Seiten der Implementierung verwiesen. MPI_REPLACE ist eine weitere vordefinierte Operation, die in MPI-2 aufgenommen wurde. Sie ist für die Verwendung in Operationen zum Zugriff auf fernen Speicher vorgesehen und wird in Using MPI-2 [GLT99] behandelt. Anwenderdefinierte Operationen. MPI bietet auch die Möglichkeit, eigene Operationen, die den kollektiven Berechnungsroutinen übergeben werden können, zu defi-
7.3 Erweiterte kollektive Operationen
PO
197
A
PO
P1
P1
Broadcast
P2
P2
P3
P3
PO
Α
Β
C
A
D
A
A Scatter
P1 P2
P1
Β
P2
C
Gather P3
D
A P1
Β
P2
C
All g a t h e r >
D
AO A1 A 2 A 3 P1
BO B1 B 2 B 3
PO
A
Β
C
D
P1
A
Β
C
D
P2
A
Β
C
D
P3
A
Β
C
D
PO AO BO CO DO All to All
P1 A1 B1 C1 D1
P 2 CO C 1 C 2 C 3
P 2 A2 B2 C 2 D2
DO D1 D 2 D 3
P 3 A3 B3 C 3 D3
Abbildung
7.7; Schematische Darstellung von kollektiven Datenbewegungen in MPI
198
7 Weitere Eigenschaften von M P I MPI-Name MPI_MAX MPI_MIN MPI_PR0D MPI_SUM MPI_LAND MPI_L0R MPI_LX0R MPIJ3AND MPI_B0R MPI_BX0R MPI_MAXLOC MPI_MINLOC
Operation Maximum Minimum Produkt Summe Logisches Und Logisches Oder Logisches exklusives Oder (xor) Bitweises Und Bitweises Oder Bitweises xor Maximum (Wert und bei welchem Prozess) Minimum (Wert und bei welchem Prozess)
Tabelle 7.3: Vordefinierte Operationen in MPI für kollektive Berechnungen int MPI_Op_create(MPLUser.function *function, int commute, MPLOp *op) int MPLOp_free(MPLOp *op) typedef int MPI_User_function(void *invec, void *inoutvec, int *len, MPLDatatype *datatype)
Tabelle 7-4: C-Signaturen zur Definition von Funktionen für kollektive Berechnungen MPLOP_CREATE(function, commute, op, ierror) external function logical commute integer op, ierror MPI_OP_FREE(op, ierror) integer op, ierror User_function(invec, inoutvec, len, datatype) invec(*), inoutvec(*) integer len, datatype
Tabelle 7.5: Fortran-Signaturen zur Definition von Funktionen für kollektive Berechnungen. Die Funktion User_function ist kein Bestandteil von MPI, aber sie zeigt die Aufruffolge für den function-Parameter von MPI_OP_CREATE
nieren. So möchte man vielleicht eine komplexere arithmetische Operation ausführen (ζ. B. die Multiplikation von Matrizen). Eine neue Operation wird mit Hilfe der Routine MPI_Op_create definiert. Die Rückgabe dieser Routine (dritter Parameter) ist eine
199
7.3 Erweiterte kollektive Operationen
y
=
Α
χ
Abbildung 7.8: Verteilung einer Matrix und eines Vektors über einen
Kommunikator
neue Operation (in C vom Typ MPIJDp), die dann an Routinen wie ζ. B. MPI_Allreduce übergeben werden kann. Der erste der zwei Eingabeparameter ist eine Funktion, der zweite gibt an, ob die Operation kommutativ ist. Die Funktion hat für C und Fortran die gleiche Form, die Signaturen sind in den Tabellen 7.4 und 7.5 zu finden. Zum Löschen einer anwenderdefinierten Funktion wird MPIJDpJree aufgerufen. Im zweiten Parameter von MPI_Op_create kann man angeben, ob die Operation nicht kommutativ ist, also wenn a op b nicht dasselbe Resultat wie b op a liefert. Die Multiplikation von Matrizen ist ein bekanntes Beispiel einer nicht kommutativen Operation. Mit dieser Markierung (Flag) zur Kommutativität wird den MPI-Implementierungen mehr Freiraum für die Reihenfolge bei der Ausführung der Berechnung eingeräumt. Andere kollektive Berechnungsoperationen. In MPI gibt es zwei weitere Operationen für kollektive Berechnungen, die für den Anwender von Nutzen sein können. Die erste, MPI_Scan, ist der Routine MPI_Allreduce sehr ähnlich, bei der die Ergebnisse durch Kombination von Werten aller Prozesse gebildet werden und jeder Prozess ein Ergebnis erhält. Der Unterschied besteht darin, dass das Ergebnis von Prozess r aus der Verknüpfung der Werte der Prozesse 0 , 1 , . . . , r ermittelt wird. So berechnet MPI_Scan ζ. B. mit der Operation MPI_SUM alle Partialsummen. In MPI-2 gibt es als Variante von MPI_Scan zusätzlich die Routine MPI_Exscan. Diese führt ein exklusives Scan aus: während mit MPI_Scan jeder Prozess bei der Ermittlung seines Ergebnisses den eigenen Wert mit verwendet, wird in MPI_Exscan der Wert des aufrufenden Prozesses nicht mit in die Operation einbezogen. Das bedeutet, dass das Ergebnis von MPI_Exscan bei Prozess r das Resultat der spezifizierten Operation mit den Werten der Prozesse 0, Ι , . , . , γ - 1 ist. Als Beispiel für die Anwendung von MPI_Scan und MPI_Exscan wollen wir das Problem betrachten, die Arbeit, d. h. den Berechnungsaufwand, zwischen den Prozessoren auszugleichen. Wir können zum Beispiel in der eindimensionalen Zerlegung, mit der wir in Kapitel 4 gearbeitet haben, alle Prozesse nach ihrem Rang ordnen und die Arbeit „zur Linken" relativ zur Gesamtarbeit betrachten. Wenn die Arbeit gleichmäßig verteilt ist, wird jeder Prozess feststellen, dass die Summe aus den Arbeitsanteilen
200
7 Weitere Eigenschaften von M P I
der Prozesse links von ihm, also seinen eigenen Anteil nicht mit berücksichtigt, gleich rank*total_work/nprocs sein wird. Der folgende Quellcode skizziert dies in einfacher Weise: tl = MPI::Wtime(); ... work ... my.time = MPI::Wtime() - tl; C0MM_W0RLD.Scan( &my_time, &time_to_me, 1, MPI::D0UBLE, MPI::SUM ); total_work = time_to_me; // Only for the last process COMM.WORLD.Beast( &total_work, 1, MPI::DOUBLE, nprocs - 1 ); fair_share = (rank + 1) * total_work / nprocs; if ( fair_share > time_to_me + EPS) { ... shift work to rank-1
>
else if ( fair_share < time_to_me - EPS) { ... shift work to rank+1
> Mit MPI_Exscan w ü r d e der W e r t von fair-share mit rank anstelle von rank+1 be-
stimmt. Die andere kollektive Berechnungsroutine ist MPI_Reduce_scatter. Hierbei handelt es sich um eine Kombination von MPI_Reduce mit MPI_Scatterv. Mit ihr können mehrere MPI_Reduce-Operationen gleichzeitig ausgeführt werden. In anspruchsvollen MPIImplementierungen kann Code mit MPI_Reduce_scatter schneller ablaufen als bei Verwendung von MPI-Reduce und MPI_Scatterv. Um zu verstehen, wie diese Routine angewendet werden kann, betrachten wir die Berechnung eines Matrix-Vektor-Produkts, in der Matrix und Vektor auf die Prozesse verteilt sind. Die Vektoren sind in folgender Weise aufgeteilt: jeder Prozess hat einen zusammenhängenden Abschnitt eines Vektors, die Abschnitte sind nach dem Rang geordnet. Prozess 0 hat damit Zugriff auf den ersten Block der Werte, Prozess 1 auf den nächsten Block usw. Für einen Vektor der Länge η und nprocs Prozesse bedeutet das, drückt man es präziser aus, dass Prozess 0 über die Komponenten (beginnend mit 1) l:n/nprocs verfügt, der zweite hält die Komponenten n/nprocs+1: 2n/nprocs usw. (wegen der Einfachheit setzen wir voraus, dass η ohne Rest durch nprocs teilbar ist). Die Matrix ist spaltenweise über die Prozesse verteilt, d. h. Prozess 0 hält die Spalten l : n / n p r o c s , Prozess 1 die Spalten n / n p r o c s + 1 : 2 n / n p r o c s usw. Die Aufteilung von Matrix und Vektor auf die Prozesse ist in Abbildung 7.8 dargestellt. Wir wollen nun das Matrix-Vektor-Produkt y = Ax bilden. At bezeichne den Teil der Matrix A, über den Prozess i verfügt, xt sei der Teil des Vektors x, der von Prozess i gehalten wird. Da wir Α spaltenweise auf die Prozesse verteilt haben, können wir das Produkt wl = AiXi in jedem Prozess i unabhängig von den anderen Prozessen berechnen, also ohne jede Kommunikation mit irgendeinem anderen Prozess. Der Ergebnisvektor wl ist ein Vektor der Länge η (nicht n/nprocs). Wir benutzen hier einen oberen Index, um zu verdeutlichen, dass wl nicht Teil des Vektors w ist, der in Prozess i gehalten wird, sondern das Ergebnis des lokalen Matrix-Vektor-Produkts im i-ten
7.3 Erweiterte kollektive Operationen
201
Prozess. Um das Ergebnis y zu erhalten, müssen alle wl zusammengefasst werden. Wir müssen also im Prozess 0 jeweils die ersten n/nprocs Komponenten aller wl aufsummieren, im Prozess 1 die nächsten n/nprocs Komponenten der w l und so fort. Genau das leistet MPI_Reduce_scatter. Die Routine übernimmt im Prozess 0 einen Sendepuffer sendbuf und ein Feld recvcounts, in dem die jeweilige Anzahl der aufzusummierenden Werte abgespeichert ist, und bildet das Ergebnis, indem sie die ersten recvcounts (1) (in Fortran-Notation) Elemente aus den Sendepuffern sendbuf aller Prozesse zu einem Wert zusammenfasst, der dann in recvbuf abgelegt wird. Analog werden im Prozess 1 die jeweils recvcounts (2) Elemente aus den Sendepuffern sendbuf der Prozesse, beginnend mit dem Element recvcounts(l)+l zu einem Wert zusammengefasst und so weiter. Diese Verfahrensweise ist gerade passend, um yi aus den entsprechenden Teilen der wk zu bilden. Der Quelltext für das Matrix-Vektor-Produkt wird in Abbildung 7.9 gezeigt. Die Signaturen für MPI_Reduce_scatter sind in den Tabellen 7.6, 7.7 und 7.8 zu finden.
subroutine matvec( n, m, lmatrix, lx, ly, counts, comm ) use mpi integer n, m, comm, counts(*) real lmatrix(n,m), lx(m), ly(m) integer i, j real sum real, allocatable :: tmp(:) allocate (tmp(n)) ! Perform the local matrix-vector multiply ! Should use the level-2 BLAS routine SGEMV do i=l,n sum = 0 do j=l,m sum = sum + lmatrix(i,j)*lx(j) enddo tmp(i) = sum enddo ! Perform the local matrix-vector product call MPI_REDUCE_SCATTER( tmp, ly, counts, & MPI_REAL, MPI.SUM, comm, ierr) deallocate (tmp) ! We're done! end Abbildung zen
7.9: MPIJleduce.scatter in einer Matrix-Vektor-Multiplikation
für dichte Matri-
202
7 Weitere Eigenschaften von MPI
int MPI_Reduce_scatter(void *sendbuf, void *recvbuf, int*recvcounts, MPLDatatype datatype, MPLOp op, MPLComm comm) Tabelle 7.6: C-Signatur für MPI_Reduce_scatter M P L R E D U C E _ S C A T T E R ( s e n d b u f , recvbuf, recvcounts, datatype, op, comm, ierror) sendbuf(*), recvbuf(*) integer recvcounts(*), datatype, op, comm, ierror Tabelle 7.7: Fortran-Signatur für MPI_Reduce_scatter void MPI::lntracomm::Reduce_scatter(const void* sendbuf, void* recvbuf, int recvcounts[], const Datatype^ datatype, const Op& op) const Tabelle 7.8: C++-Signatur für MPI_Reduce_scatter
7.3.3
Typische Fehler und Missverständnisse
Bei der Anwendung kollektiver Routinen werden häufig vier spezielle Fehler gemacht, zwei von ihnen betreffen alle kollektiven Routinen. MPI verlangt, dass kollektive Routinen desselben Kommunikators von allen Prozessen dieses Kommunikators aufgerufen werden, und zwar in der gleichen Reihenfolge. Am häufigsten wird diese Regel verletzt, wenn eine kollektive Operation innerhalb einer Verzweigung mit einer Abfrage ( i f ) bezüglich des Rangs verwendet wird, aber die kollektive Routine im anderen Zweig vergessen oder bezüglich der Reihenfolge (bei mehreren kollektiven Operationen) falsch platziert wird. Der zweite typische Fehler entsteht aus der Annahme, dass alle Prozesse, nur weil sie eine kollektive Routinen aufrufen, diesen Aufruf (annähernd) zur gleichen Zeit beenden. Selbst MPI_Barrier garantiert nur, dass kein Prozess die Sperre (barrier) verlässt, bevor sie von allen erreicht wurde. MPI macht aber keine Angaben dazu, mit welchem zeitlichen Abstand unterschiedliche Prozesse die Sperre verlassen. Bei kollektiven Routinen wie ζ. B. MPI_Bcast kann der Wurzelprozess nach Erledigung seiner Aufgabe den Aufruf schon beenden, bevor einige oder alle anderen Prozesse die kollektive Routine beginnen. Der MPI-Standard ermöglicht zwar, dass kollektive Routinen synchronisierend wirken können, fordert das aber, mit Ausnahme von MPI_Barrier, nicht. Natürlich können einige Routinen, so wie MPI_Allreduce, wegen ihrer speziellen Definition nicht in irgend einem Prozess beendet werden, solange noch nicht alle Prozesse ihre Daten beigesteuert haben. Der dritte typische Fehler betrifft alle Routinen, in denen die Nutzung des Eingabepuffers als Ausgabepuffer sinnvoll sein könnte. Ein gutes Beispiel hierfür ist MPI_Allreduce,
7.4 Interkommunikatoren
203
wo oft Code wie c a l l MPI_ALLREDUCE( a , a , 1, MPI.REAL, MPI.SUM, comm, i e r r o r ) erwünscht ist. Leider verletzt dies den Fortran-Standard, sodass MPI eine solche Möglichkeit nicht erlauben kann. Aus Konsistenzgründen verbietet MPI dies auch in C- und C++-Programmen. In MPI-2 wurde eine Erweiterung aufgenommen, die dieses Problem angeht. Hierfür verweisen wir auf Anhang E. Der letzte der häufig auftretenden Fehler betrifft speziell MPI_Bcast. Alle Prozesse, sowohl der Wurzelprozess (der Sender) als auch die anderen Prozesse (die Empfänger), müssen MPI_Bcast aufrufen. In manchen Message-Passing-Systemen gibt es eine „multicast"- oder „multisend"-Routine, die Nachrichten an mehrere Prozesse sendet. Die Zielprozesse benutzen einen normalen receive-Aufruf zum Empfang der Nachricht. Das MPI-Forum zog solch ein „Multisend" in Betracht, fürchtete aber, dass es die Leistungsstärke der regulären receives (es müsste nun auf Broadcasts geprüft werden) sowie die Skalierbarkeit des Broadcast selbst nachteilig beeinflussen würde.
7.4
Interkommunikatoren
Obwohl wir uns inzwischen vom Komfort der Kommunikatoren überzeugen konnten, erweist sich dennoch eine allgemeinere Art von Kommunikatoren, die speziell auf die Kommunikation zwischen Gruppen abzielt, als vorteilhafte Erweiterung für MPI. Diese „erweiterten Kommunikatoren" werden im Standard als Interkommunikatoren bezeichnet, die bisher betrachteten normalen Kommunikatoren dagegen formal als Intrakommunikatoren. MPI definiert nur eine minimale Anzahl von Operationen für diese Interkommunikatoren, aber diese Operationen bilden einen leistungsstarken Ansatzpunkt für die Kommunikation zwischen Gruppen. Die Abbildung 7.10 veranschaulicht die Beziehungen zwischen Prozessen und Gruppen eines Interkommunikators. In jedem Interkommunikator gibt es zwei Gruppen. Ein Prozess, der einem Interkommunikator angehört, ist per Definition in einer der beiden Gruppen zu finden. Bezogen auf einen beliebigen Prozess, bezeichnen wir die Gruppe, zu der der Prozess gehört, als lokale Gruppe, die andere als entfernte Gruppe (engl.: remote group). Mit Zugriffsfunktionen (die vor allem von Bibliotheken, die auf MPI aufsetzen, benutzt werden) kann ermittelt werden, ob der Kommunikator ein Interkommunikator ist (MPI_Comm_test_inter), und auf Informationen zur entfernten Gruppe zugegriffen werden (MPI_Comm_remote_size und MPI_Commj-emote_group). Auf die lokale Gruppe des Interkommunikators kann wie bisher mit den üblichen Routinen MPI_Comm_size und MPI_Comm__group zugegriffen werden. Das Ziel einer mit MPI-Send und verwandten Routinen gesendeten Nachricht ist die entfernte Gruppe. Zum Senden einer Nachricht werden Prozesse in der entfernten Gruppe durch ihren dortigen Rang benannt. Empfangen werden Nachrichten mit MPI Jlecv und den verwandten Routinen. Der Wert von source in den Argumenten der receive-Aufrufe und im MPI_SOURCE-Datenfeld von MPI_Status bezieht sich auf den Rang in der sendenden Gruppe (die folglich für den empfangenden Prozess die entfernte Gruppe ist). Dieser Sachverhalt wird in Abbildung 7.10 veranschaulicht.
204
7 Weitere Eigenschaften von M P I
Abbildung 7.10: Schematische Darstellung eines MPI-Interkommunikators. Dargestellt ist eine Sendeoperation von Prozess 1 der einen Gruppe an Prozess 2 der anderen Gruppe. Aus Sicht des sendenden Prozesses aus Gruppe Α ist Α die lokale, Β die entfernte Gruppe. Für den Empfänger (Prozess 2 in Gruppe B) hingegen ist Β die lokale und Α die entfernte Gruppe. Jede Punkt-zu-Punkt-Kommunikation in einem Interkommunikator erfolgt zwischen den zwei Gruppen, aus denen der Interkommunikator besteht
Die zwei Gruppen eines Interkommunikators überlappen sich nicht, das heißt, kein Prozess der entfernten Gruppe kann gleichzeitig der lokalen Gruppe angehören. Für die Interkommunikatoren sind in MPI-1, neben speziellen Operationen zu deren Erzeugung und Freigabe, nur Punkt-zu-Punkt-Kommunikationen definiert. Diese Operationen sind für C, Fortran und C + + in den Tabellen 7.9, 7.10 bzw. 7.11 zusammengestellt. Die Funktion MPI_Intercomm_create wird am häufigsten verwendet. Die gewohnten Aufrufe von MPI_Send und MPI_Recv sind mit einem Interkommunikator als Argument genauso zulässig wie mit dem uns schon vertrauten Intrakommunikator.
int MPI_Comm_testJnter(MPLComm comm, int *flag) int MPI_Comm_remoteJsize(MPI.Comm comm, int *size) int MPLComm_remote_group(MPLComm comm, MPLGroup *group) int MPI_lntercomm_create(MPLComm locaLcomm, int locaüeader, MPLComm peer.comm, int remoteJeader, int tag, MPLComm *newintercomm) int MPI_lntercomm_merge(MPLComm intercomm, int high, MPLComm *newintracomm)
Tabelle 7.9: C-Signaturen von
Interkommunikator-Funktionen
7.4 Interkommunikatoren
205
MPLCOMM_TEST_INTER(comm, flag, ierror) integer comm, ierror logical flag MPLCOMM_REMOTE_SIZE(comm, size, ierror) integer comm, size, ierror MPLCOMM_REMOTE_GROUP(comm, group, ierror) integer comm, group, ierror MPI_INTERCOMM_CREATE(locaLcomm, local.leader, peer.comm, remoteJeader, tag, newintercomm, ierror) integer locaLcomm, localJeader, peer_comm, remoteJeader, tag, newintercomm, ierror MPIJNTERCOMM_MERGE(intercomm, high, intracomm, ierror) integer intercomm, intracomm, ierror logical high
Tabelle 7.10: Fortran-Signaturen von
Interkommunikator-Routinen
bool MPI::Comm::ls_inter() const int MPI::lntercomm::Get_remote_size() const Group MPI::lntercomm::Get_remote_group() const Intercomm MPI::lntracomm::Create_intercomm(jnt local.leader, const Comm& peer.comm, int remoteJeader, int tag) const Intracomm MPI::lntercomm::Merge(bool high) const
Tabelle 7.11: C++-Signaturen von
Interkommunikator-Methoden
In MPI-1 wurden keine allgemeinen kollektiven Operationen für Interkommunikatoren definiert. Man kann jedoch einen Interkommunikator mit MPI_Intercomm_merge in einen Intrakommunikator umwandeln. Diese Routine gibt die Möglichkeit, einen Intrakommunikator zu erhalten, mit dem die kollektiven Operationen (genauso wie die Punkt-zu-Punkt-Kommunikationen) ausgeführt werden können. Diese Operation ist in den drei eben genannten Tabellen aufgeführt. MPI-2 hingegen definiert allgemeine kollektive Operationen für Interkommunikatoren. Dabei wird die Eigenheit genutzt, dass ein Interkommunikator aus genau zwei Gruppen besteht, sodass sich die kollektiven Operationen von denen der Intrakommunikatoren in MPI-1 unterscheiden. So sendet zum Beispiel MPIJcast in einem Interkommunikator Daten von einem Prozess in der einen Gruppe an alle Prozesse der anderen Gruppe.
206
7 Weitere Eigenschaften von M P I
N X T V A L n e u a u f g e l e g t . W i r h a t t e n weiter vorn in diesem K a p i t e l eine ClientServer-Berechnung vorgestellt, u m den Dienst NXTVAL-Zähler bereit zu stellen. Die Verwendung von I n t e r k o m m u n i k a t o r e n erweist sich als eine weitere Möglichkeit, diesen Dienst zu implementieren u n d vereinfacht sogar die P r o g r a m m i e r u n g verschiedener F u n k t i o n a l i t ä t e n . Aus diesem G r u n d h a b e n wir den Dienst, wie in den Abbildun-
#define ICTAG 0 int MPE_Counter_create_ic(MPI_Comm oldcomm, MPI_Comm *smaller_comm, MPI_Conrm *counter_comm)
{ int counter = 0, message, done = 0, myid, numprocs, server; int color, remote_leader_rank; MPI_Status status; MPI_Comm oldcommdup, splitcomm; MPI_Comm_dup(oldcomm, &oldcommdup); MPI_Comm_size(oldcommdup, fenumprocs); MPI_Comm_rank(oldcommdup, &myid); server = numprocs-1; /* last proc is server */ color = (myid == server); /* split into server and rest */ MPI_Comm_split(oldcomm, color, myid, fesplitcomm); /* build intercommunicator using bridge w/ oldcommdup */ if(!color) { /* I am not the server */ /* 1) the non-server leader process is chosen to have rank "0" in the peer comm. oldcommdup != rank of server guaranteed that this leader "0" has rank "0" in both oldcommdup and in this splitcomm too, by virtue of MPI_Comm_split 2) server has rank "server" in oldcommdup */ remote_leader_rank = server; /* server rank, oldcommdup */ *smaller_comm = splitcomm; /* return new, smaller world */
> else remote_leader_rank = 0 ; /* non-server leader, oldcommdup */ MPI_Intercomm_create(splitcomm, 0, oldcommdup, remote_leader_rank, ICTAG, counter_comm); MPI_Comm_free(&oldcommdup); /* not needed after Intercomm_create */ /* rest of code unchanged from before... */
> Abbildung
7.11: MPE_Counter_create mit
Interkommunikatoren
207
7.4 Interkommunikatoren
gen 7.11, 7.12 und 7.13 gezeigt, reimplementiert. Während der hier realisierte Dienst äquivalent zu dem alten ist, erweist sich die Verwaltung eines Interkommunikators als einfacher, weil die entfernte Gruppe aus Sicht der Clients ein Server ist (mit dem bekannten Rang 0). Wie bisher verfügen die Clients über ihren eigenen Kommunikator „smaller-comm", innerhalb dessen sie arbeiten, aber im Unterschied zu den früheren Beispielen ist counter-comm ein Interkommunikator. Die einzig sinnvolle Interaktion seitens des Servers bezüglich counter.comm ist die Kommunikation mit den Clients (und die der Clients mit dem Server). Das erlaubt eine elegante Trennung von jeglicher Kommunikation, die man vielleicht innerhalb des Kommunikators old-comm (der auch MP I -C0MM-W0RLD hätte sein können) vorsieht. Obwohl dieses einfache Beispiel recht hilfreich bei der Vorstellung der Arbeit mit Interkommunikatoren ist, zeigt es nicht die Vorzüge, die die Verwendung von Interkommunikatoren in der Regel hat, wenn beide Gruppen mehr als einen Prozess umfassen. In diesem allgemeinen Fall sind Interkommunikatoren ein gutes Mittel, um Berechnungen vom Typ „paralleler Client, paralleler Server" zu implementieren [SDV94]. #define SERVER_RANK 0 int MPE_Counter_nxtval_ic( MPI_Comm counter_comm, int *value ) { MPI_Status status; /* always request/receive services from intercomm (remote) rank=0 */ MPI_Send(NULL, 0, MPI_INT, SERVER.RANK, REQUEST, counter.comm ); MPI.Recv(value, 1, MPI.INT, SERVER.RANK, VALUE, counter.comm, festatus ); return(O);
> Abbildung
7.12: MPE_Counter_nxtval mit
Interkommunikatoren
«define SERVER.RANK 0 int MPE_Counter_free_ic( MPI_Comm *smaller_comm, MPI_Comm *counter_comm )
{
int myid; MPI_Comm_rank( *smaller_comm, femyid ); MPI_Barrier( *smaller_comm ); if (myid == 0) MPI_Send(NULL, 0, MPI.INT, SERVER.RANK, G0AWAY, *counter_comm); MPI_Comm_free( counter_comm ); MPI_Comm_free( smaller_comm ); return(0);
} Abbildung
7.13: MPE_Counter_free mit
Interkommunikatoren
208
7 Weitere Eigenschaften von MPI
Um ein noch besseres Gefühl für Interkommunikatoren zu vermitteln, wollen wir hier noch kurz auf zwei Dienste eingehen, die mit Interkommunikatoren unterstützt werden können: partnerorientierte Interkommunikatoren, die eine Verbindung getrennt entwickelter „Module" auf höherer Ebene ermöglichen (eine Abstraktion der Kommunikation in einem Klima/Meer-Modell), und ein Schwarzes-Brett-System analog zum TupleSpace in Linda [CG89]. Wechselwirkung zwischen K l i m a und Meer. Eine der vielfältig diskutierten Grand Challenge-Anwendungen ist die Modellierung von Meer und Atmosphäre in nur einem übergreifenden Programm. Mehrere Gruppen entwickeln solche Programme oder schreiben entsprechende sequentielle Programme um. Hierbei wird häufig der Code für Atmosphäre und Meer voneinander getrennt erstellt, um die Teile später auf einer höheren Ebene zu koppeln; die Informationen zur Interaktion zwischen Meer und Atmosphäre werden mittels Nachrichten ausgetauscht. Interkommunikatoren sind in dieser Situation das passende Konzept. Die zwei Programme können für sich mit Intrakommunikatoren arbeiten und so separat entwickelt und getestet werden. Die für das Meer lokale Gruppe des Interkommunikators besteht aus allen Prozessen des Meeres, die genauso auch in dem Intrakommunikator für die meeresinternen Nachrichten auftreten. Entsprechend vereint der Interkommunikator für die Atmosphäre als lokale Gruppe die Prozesse der Atmosphäre. Die an das Meer angrenzenden Atmosphärenprozesse bilden die für das Meer entfernte Gruppe und umgekehrt. Andere Strategien sind, abhängig von den Details des Datenaustausche an der Schnittstelle zwischen Meer und Atmosphäre, auch denkbar. Um ein Beispiel zu geben, wollen wir annehmen, dass die zwei Teile der Anwendung so geschrieben wurden, dass sie einen vorgegebenenen Kommunikator comm anstelle von MPI_C0MM_W0RLD verwenden. Die Programmteile nennen wir do_ocean(comm) und do_atmos(comm). Außerdem soll es eine Routine ocean_and_atmos(intercomm) geben, die für den Datenaustausch zwischen Meeres- und Atmosphärenmodell zuständig ist. Das entsprechende Hauptprogramm ist in der Abbildung 7.14 dargestellt. Zu beachten ist hier, dass MPI_Comm_split einen Kommunikator entweder für die MeerRoutine (falls c o l o r = OCEAN) oder für die Routine der Atmosphäre (falls c o l o r = ATMOS) erzeugt. MPI_Comm_split gibt immer einen einzelnen neuen Kommunikator (falls c o l o r nicht negativ ist) oder MPI_C0MMJiULL zurück. In Using MPI-2 [GLT99] können wir sehen, wie die in MPI-2 realisierten Möglichkeiten für dynamische Prozesse benutzt werden können, um zwei getrennte MPI-Programme zu vereinen, statt die hier vorgestellte Technik zu wählen, MPI_C0MM_W0RLD aufzuteilen. Erstellung eines Schwarzen B r e t t s (oder Linda TupleSpace). Interkommunikatoren können auch verwendet werden, wenn man mit einer Gruppe paralleler Datenserver arbeitet. Das Modell des TupleSpace in Linda bietet eine Art Schwarzes Brett für Daten, auf die, im Stil eines virtuellen gemeinsamen Speichers, über Namen zugegriffen werden kann. Um mit dieser Methode eine angemessene Skalierbarkeit zu erzielen, müssen mehrere Prozesse für die Bedienung der Anfragen vorgesehen werden. Operationen, die hierfür offensichtlich unterstützt werden müssen, sind das Platzieren eines benannten Objekts in den Datenraum, das Abfragen seines Werts und das Abfragen mit anschließendem Löschen des Objekts. Die lokale Gruppe eines Prozesses, der als
209
7.4 Interkommunikatoren program main use mpi integer ocean_or_atmos_conuii, intercomm, ocean_comm, atmos_comm integer nprocs, rank, ierr, color integer OCEAN, ATMOS parameter (0CEAN=0,ATM0S=1) call MPI_INIT( ierr ) call MPI_COMM_SIZE( MPI_C0MM_W0RLD, nprocs, ierr ) call MPI_C0MM_RANK( MPI_C0MM_W0RLD, rank, ierr ) if (rank .It. size/2) then color = OCEAN else color = ATMOS endif call MPI_COMM_SPLIT( MPI_C0MM_W0RLD, color, rank, & ocean_or_atmos_comm, ierr ) call MPI_INTERCOMM_CREATE( ocean_or_atmos_comm, 0, & MPI_C0MM_W0RLD, 0, 0, intercomm, ierr ) if (color .eq. OCEAN) then ocean_comm = ocean_or_atmos_comm call do_ocean( ocean_comm ) else atmos_comm = ocean_or_atmos_comm call do_atmos( atmos_comm ) endif call ocean_and_atmos( intercomm ) end
Abbildung 7.14·' Programm zur Kombination von zwei Anwendungen, nikatoren nutzen
die separate
Kommu-
Client die Dienste des Schwarzen Bretts in Anspruch nimmt, besteht nur aus diesem einen Prozess oder zusätzlich aus den anderen Client-Prozessen, denen die gleiche Klasse oder Priorität von Diensten zugeordnet ist. Die entfernte Gruppe für diese Clients besteht aus der Gruppe der Server (oder einer Teilmenge der Server), die das Schwarze Brett verwalten. Der entscheidende Aspekt dieser Anfragen ist, dass die Clients nicht wissen müssen, wo sich die Daten befinden, und nicht angeben müssen, wo sie abzulegen sind. Vielmehr wird eine Anfrage gestellt, möglicherweise an einen Masterserver, der diese dann weiter verteilt. Einer der Server wird den Dienst erbringen. Die Abgrenzung der Kommunikation durch einen Interkommunikator ermöglicht, dass ein Client eine Antwort von irgend einem der Server erhält, also nicht unbedingt von dem, der die Anfrage ursprünglich entgegen nahm.
210
7.5
7 Weitere Eigenschaften von MPI
Rechnen in einer heterogenen Umgebung
In vielen Fällen kann ein leistungsstarker Parallelrechner durch die Vernetzung von Arbeitsplatzrechnern aufgebaut werden. Stammen diese Rechner von verschiedenen Herstellern, so nutzen sie möglicherweise unterschiedliche Datenformate. Zum Beispiel kann die Reihenfolge der Bytes für den Integer-Typ unterschiedlich sein. Auch kann die Anzahl der Bytes zur Darstellung von ganzen Zahlen oder Gleitkommazahlen je nach System variieren. Wir sprechen in diesem Fall von einem heterogenen Parallelsystem oder einem heterogenen Parallelrechner bzw. von heterogeneous computing. MPI wurde so entwickelt, dass in derartigen Umgebungen fehlerfrei gearbeitet werden kann. MPI sichert die korrekte Übertragung von Daten, wenn diese mit MPI-Datentypen der gleichen Typsignatur gesendet und empfangen werden (sofern die Daten sowohl auf Sender- als auch Empfängerseite entsprechend abbildbar sind). Es sind keine speziellen Vorkehrungen zu treffen, um ein Programm auf einem heterogenen Parallelrechner zu portieren. Es ist bemerkenswert, dass diese Flexibilität durch eine MPIImplementierung zur Verfügung gestellt werden kann, ohne dadurch Leistungseinbußen auf einem homogenen System in Kauf nehmen zu müssen. Dies unterstützt das Schreiben von Programmen, die zwischen zweckbestimmten MPPs und Workstation-Clustern echt portierbar sind. Von Anfang an unterstützten bestimmte MPI-Implementierungen eine heterogene Verarbeitung, so z.B. MPICH [GLDS96] und LAM [BDV94]. Neuere Versionen geben Unterstützung hinsichtlich solcher Problemkreise wie erweiterte Sicherheit, Ressourcenverwaltung oder Scheduling — siehe [FK99a], dort werden einige dieser Themen angesprochen, und [FGG + 98], wo die Beschreibung einer solchen Implementierung zu finden ist. Eine andere umfassende Implementierung ist [leg]. Ein wichtiger Vorteil von MPI als Standard besteht darin, dass Hard- und Softwarehersteller optimierte Versionen für spezifische Plattformen erstellen können. Diese Implementierungen müssen aber nicht mit MPI-Implementierungen anderer Hersteller zusammen arbeiten können. Deshalb ist es Anwendern, die in einer heterogenen parallelen Umgebung arbeiten wollen, nicht möglich, hochoptimierte Versionen von MPI einzusetzen. Mit Blick auf diese Problematik wurde eine Projektgruppe gebildet, um einen Standard für ein vollständig kompatibles MPI (engl.: interoperable MPI) (IMPI) [Com98] zu definieren. Diese Arbeiten waren während der Entstehung dieses Buches noch nicht ganz beendet. Sie sollen die Zusammenarbeit von MPI-Implementierungen verschiedener Herkunft gestatten. Einige Entwickler von MPI-Implementierungen haben bereits angekündigt, zu IMPI konforme Versionen bereit zu stellen.
7.6
Die MPI-Schnittstelle zur Programmanalyse
Das MPI-Forum erkannte, dass Programmanalyse (engl.: profiling) und andere Formen der Leistungsmessung entscheidend für den Erfolg von MPI waren. Gleichzeitig aber schien es viel zu früh für die Standardisierung irgend einer speziellen Herangehensweise zur Leistungsbewertung zu sein. Allen Zugängen gemeinsam ist jedoch die Forderung, dass zum Zeitpunkt eines jeden MPI-Aufrufs eine bestimmte Maßnahme er-
7.6 Die MPI-Schnittstelle zur Programmanalyse
211
folgen muss, zum Beispiel eine Zeitmessung, das Schreiben eines Protokolleintrags oder die Ausführung komplexerer Aktionen. Das MPI-Forum entschloss sich deshalb, in MPI eine Spezifikation aufzunehmen, die es jedem, auch ohne den Quellcode der MPI-Implementierung, erlaubt, Aufrufe der MPI-Bibliothek zu überwachen und frei wählbare Aktionen auszuführen. Der Kunstgriff besteht darin, dass die Überwachung der Aufrufe zur Zeit des Bindens und nicht während des Übersetzens realisiert wird. Die MPI-Spezifikation verlangt, dass jede MPI-Routine auch mit einem alternativen Namen aufgerufen werden kann. Insbesondere muss jede Routine der Art MPI_xxx auch mit dem Namen PMPI_xxx aufrufbar sein. Es muss den Anwendern sogar möglich sein, eigene Versionen von M P I J X X X ZU schreiben. Mit diesem Konzept können die Anwender eine beschränkte Anzahl an „Hüllen" (engl.: wrappers) schreiben und innerhalb dieser Hüllen Aktionen ausführen, welche auch immer sie möchten. Um die „wirklichen" MPI-Routinen aufzurufen, werden diese dann mit dem Präfix PMPI. angesprochen. Wenn wir zum Beispiel Protokolldateien automatisch statt explizit erzeugen wollen, so wie wir es in Kapitel 3 ausgeführt hatten, dann könnten wir unsere eigene Version, z.B. für MPIJBcast, so schreiben, wie es in Abbildung 7.15 zu sehen ist. Wir müssen dann nur noch sicher stellen, dass der Binder auch tatsächlich unsere Version von MPI_Bcast benutzt, wenn Referenzen auf diese vom Anwenderprogramm aufzulösen sind. Unsere Routine ruft PMPI_Bcast auf, um die bestimmungsgemäße Arbeit zu erledigen. Die Reihenfolge der Bibliotheken, wie sie sich dem Binder darstellt, ist in Abbildung 7.16 illustriert. Die Protokollierungsroutinen von MPE verlangen eine Initialisierung ihrer Datenstrukturen. Das kann man erreichen, wenn man eine „Profile"-Version von MPI_Init, so wie in Abbildung 7.17 dargestellt, einsetzt. In gleicher Weise kann eine Analyseversion von MPI_Finalize verwendet werden, um beliebige Abschlussbehandlungen auszuführen, ζ. B. das Schreiben von Protokolldateien oder die Ausgabe von Statistiken, die während der einzelnen Aufrufe von Analyseversionen der MPI-Routinen aufgesammelt wurden. int MPI_Bcast( void *buf, int count, MPI_Datatype datatype, int root, MPI_Comm comm )
{ int result; MPE_Log_event( S_BCAST_EVENT, Bcast_ncalls, (char *)0 ); result = PMPI_Bcast( buf, count, datatype, root, comm ); MPE_Log_event( E_BCAST_EVENT, Bcast.ncalls, (char *)0 ); return result;
} Abbildung
7.15:
Version von MPI_Bcast mit
Programmanalyse
7 Weitere Eigenschaften von MPI
212
MPI._Bcast
MPLBcast PMPI Bcast -
MPLBcast -ή- PMPLBcast - - MPLSend
MPI__Send Anwenderprogramm Abbildung
7.16: Zuordnung
Profile-Bibliothek
von Routinen
bei Einbindung
MPI-Bibliothek einer
Profile-Bibliothek
int MPI_In.it ( int *argc, char ***argv ) { int procid, returnVal; returnVal = PMPI_Init( arge, argv ); MPE_Initlog(); MPI_Comm_rank( MPI_C0MM_W0RLD, &procid ); if (procid == 0) { MPE_Describe_state( S_SEND_EVENT, E_SEND_EVENT, "Send", "blue:gray3" ); MPE_Describe_state( S_RECV_EVENT, E_RECV_EVENT, "Recv", "green:light_gray" );
> return returnVal;
> Abbildung
7.17:
Version von MPI_Init für die
Progammanalyse
Verschiedene Bibliotheken für die Programmanalyse wollen den Anwender gerne in die Lage versetzen, einige dieser Funktionen zur Laufzeit zu steuern. Einfache Beispiele hierfür sind die Routinen MPE_Stoplog und MPE-Startlog, die wir in Kapitel 3 definiert hatten. Die Schwierigkeit für eine Schnittstelle zur Programmanalyse liegt hier in der zu erwartenden breiten Variation der zu steuernden Typen von einer Analysebibliothek zur anderen. Die Lösung liegt in der Definition einer einzigen MPI-Routine zur Steuerung der Programmanalyse, MPI_Pcontrol, die eine Parameterliste mit variabler Länge besitzt. Der erste Parameter gibt an, in welchem Umfang die Programmanalyse bzw. welche Art der Programmanalyse ausgeführt werden soll — wir sprechen in diesem Zusammenhang von der Ebene der Analyse. Diese Routine hat die etwas seltsame Eigenschaft, dass sie nicht definiert wurde, um irgend etwas auszuführen. Ein Entwickler einer Bibliothek für Programmanalysen kann sie aber, so wie andere MPI-Routinen auch, neu definieren. Die Signaturen für MPIJPcontrol sind in den Tabellen 7.12, 7.13 und 7.14 zu finden. int MPI_Pcontrol(const int level, . . . ) Tabelle
7.12:
C-Signatur
der Funktion zur Steuerung
der Μ PI-Programmanalyse
7.6 Die MPI-Schnittstelle zur Programmanalyse
213
MPLPCONTROL(level) integer level Tabelle 7.13: Fortran-Signatur der Routine zur Steuerung der MPI-Programmanalyse. Zu beachten ist, dass diese Routine eine der neuen MPI-Routinen ist, deren Fortran-Signatur keine Fehlerrückgabe enthält void MPI::Pcontrol(const int level, . . . ) Tabelle 7.14·' C++-Signatur der Methode zur Steuerung der MPI-Programmanalyse Mit dem Code int MPI_Pcontrol(const int flag, ...) { if (flag) MPE_Startlog(); else MPE_Stoplog(); return 0;
} kann man zum Beispiel der MPE-Bibliothek zur automatischen Protokollierung die Punktionen MPE_Stoplog und MPE_Startlog zur Verfügung stellen. Das Zweckmäßige an dieser Vereinbarung ist, dass der Anbieter einer Analysebibliothek nur von den Routinen, die ihn in diesem Zusammenhang interessieren, spezielle Versionen zur Programmanalyse bereit stellen muss. Zur MPICH-Implementierung von M P I gehören drei Bibliotheken zur Programmanalyse, die hierfür die MPI-Schnittstelle verwenden. Diese funktionieren nicht nur mit MPICH, sondern mit jeder anderen MPI-Implementierung auch. • Die erste ist extrem einfach. Sie protokolliert das Betreten und Verlassen einer jeden MPI-Routine auf s t d o u t . Das kann ζ. B. hilfreich sein, wenn man nach der MPI-Routine sucht, in der ein Programm hängen bleibt. • Die zweite erstellt MPE-Protokolldateien, die mit einer Vielfalt an Werkzeugen, wie ζ. B. Jumpshot, auswertbar sind. Diese Analysebibliothek wurde für die Erzeugung der Protokolldaten benutzt, mit denen in Kapitel 4 Kommunikationsalternativen untersucht wurden. • Die dritte Bibliothek implementiert eine recht einfache Echtzeitanimation, indem sie mit Hilfe der MPE-Graphikbibliothek Prozesszustände und Nachrichtenverkehr zeitgleich sichtbar macht. Ein Bildframe sieht hierbei etwa so aus, wie dies in Abbildung 7.18 zu sehen ist. Hat man sich einmal für die Art der Programmanalyse entschieden, sind natürlich die meisten Anweisungen, die in die Analyseversionen der Routinen eingehen, gleich. Es ist
214
7 Weitere Eigenschaften von MPI
Abbildung
7.18: Programmanimation,
erzeugt mit der ΜPI-Schnittstelle
zur
Programmana-
nicht schwer, einen Meta-Analysemechanismus zu entwickeln, der das automatische Erstellen der Hüllen für alle oder eine spezifische Teilmenge von MPI-Routinen ermöglicht, sofern die auszuführende Aktion in jeder Routine dieselbe ist. Solch ein Werkzeug ist auch Bestandteil der MPICH-Implementierung (siehe „Automatic generation of profiling libraries" in [GL96b]). Mit der Analyseschnittstelle kann man auch Fragen zu einem Anwendungsprogramm beantworten, ohne dessen Quelltext zu ändern. Wir wollen dies an zwei Beispielen veranschaulichen.
7.6.1
Entdecken von Pufferproblemen
Die Routine MPI_Send wird oft mit einer gewissen internen Pufferung implementiert, wodurch Programme, die formal unsicher sind (weil sie von der Pufferung abhängen), praktisch doch lauffähig werden. Von der Pufferung abhängig zu sein, ist jedoch keine gute Praxis. Gibt es irgend eine einfache Methode, um herauszufinden, ob ein Programm von einer Pufferung in MPI_Send abhängt? Das ist hinsichtlich einer allgemeinen Beantwortung eine sehr schwierige Frage, aber mit den folgenden Herangehensweisen kann man häufig Programme entdecken, die von der Pufferung abhängig sind. Unsere erste Lösung ist sehr einfach. Wir schreiben unsere eigene Fassung von MPI_Send, die keine Pufferung vorsieht: s u b r o u t i n e MPI_SEND( b u f , c o u n t , d a t a t y p e , d e s t , & t a g , comm, i e r r ) include 'mpif.h' i n t e g e r b u f ( * ) , c o u n t , d a t a t y p e , d e s t , t a g , comm, i e r r c a l l PMPI_SSEND( b u f , c o u n t , d a t a t y p e , d e s t , t a g , comm, i e r r ) end
7.6 Die MPI-Schnittstelle zur Programmanalyse
215
Mit dieser Version von MPI_Send werden viele Programme, die davon ausgehen, dass MPI_Send mit Pufferung arbeitet, blockieren. Speziell wird ein solches Programm diese Routine betreten, aber niemals beenden, da das zugehörige receive, das starten muss, bevor MPI_Ssend und PMPI_Ssend beendet werden können, nie gestartet wird. Um die Stelle zu finden, an der das Programm hängen bleibt, kann, falls vorhanden, ein paralleler Debugger eingesetzt werden. Hier ist zu beachten, dass in der Analysefassung use mpi nicht einfach anstelle von include 'mpif. h ' verwendet werden kann, da der Fortran-Compiler eine Neudefinition von MPI_SEND in dieser Art nicht zulässt. Diese Herangehensweise, MPI_Ssend zu verwenden, ist etwas ungeschickt, da sie nur im Fall einer Verklemmung und bei Einsatz eines parallelen Debuggers funktioniert. Können wir vielleicht eine Variante von MPI_Send erstellen, die erkennen kann, dass es ein Problem gibt? Das ist möglich, wenn wir voraussetzen, dass jedes MPI_Send innerhalb einer spezifischen Zeitdauer beendet wird. So gibt es zum Beispiel in vielen wissenschaftlichen Berechnungen in der Regel keine Sendeoperation, die länger als ein paar Sekunden dauert. Nehmen wir einmal an, dass jedes MPI_Ssend, für das mehr als zehn Sekunden benötigt werden, auf ein Problem hinweist. Wir brauchen also eine Sendeoperation, die nichtblockierend ist (so dass wir nach deren Start die Zeit stoppen und nach spätestens zehn Sekunden abbrechen können) und nicht beendet werden kann, bevor die zugehörige Empfangsoperation beginnt. Wir müssen also mit der nichtblockierenden synchronen Sendeoperation MPI_Issend arbeiten. Die Signaturen dieser Routine sind die gleichen wie die der anderen nichtblockierenden Senderoutinen (siehe Tabellen 7.15, 7.16 und 7.17). Der entsprechende Quelltext wird in der Abbildung 7.19 gezeigt. Hier wird durch sich ständig wiederholende Aufrufe von PMPI_Test aktiv auf das Ende von PMPI_Issend gewartet. Verfeinerte Versionen sollten das Ende von MPI_Issend mittels PMPI_Test nur über einen kurzen Zeitraum abfragen, dann eine oder zwei Sekunden inaktiv warten (unter Verwendung des UNIX-Aufrufs sleep), dann wieder abfragen usw., bis maximal 10 Sekunden vergangen sind. Eine weitere Variante könnte MPI_Pcontrol benutzen, mit der der Anwender die Länge der Wartezeit, im Gegensatz zur Festlegung auf zehn Sekunden, variabel gestalten kann.
int MPIJssend(void* buf, int count, MPLDatatype datatype, int dest, int tag, MPLComm comm, MPLRequest *request)
Tabelle 7.15: C-Signatur der Funktion für nichtblockierendes synchrones Senden MPLISSEND(buf, count, datatype, dest, tag, comm, request, ierror) buf(*) integer count, datatype, dest, tag, comm, request, ierror
Tabelle 7.16: Fortran-Signatur der Routine für nichtblockierendes synchrones Senden
216
7 Weitere Eigenschaften von M P I
Request MPI::Comm::lssend(const void* buf, int count, const Datatype^ datatype, int dest, int tag) const
Tabelle 7.17: C++-Signatur der Methode für nichtblockierendes synchrones Senden subroutine MPI_SEND( buf, count, datatype, dest, & tag, comm, ierr ) include 'mpif.h' integer buf(*), count, datatype, dest, tag, comm, ierr integer request, status(MPI_STATUS_SIZE) logical flag double precision tstart tstart = MPI_WTIME() call PMPI_ISSEND( buf, count, datatype, dest, tag, comm, & request, ierr ) ! wait until either ten seconds have passed or ! the issend completes. 10 continue call PMPI_TEST( request, flag, status, ierr ) if (.not. flag .and. MPI_WTIME() - tstart .It. 10.0) goto 10 ! Signal error if we timed out. if (.not. flag) then print *, 'MPI.SEND call has hung!' call PMPI_ABORT( comm, ierr ) endif end Abbildung 7.19: Version von MPI_Send, in der MPI_Issend angewendet wird, um unsichere Programme zu erkennen
7.6.2
Erkennung ungleichmäßiger Last Verteilung
Wir betrachten ein Anwendungsprogramm, das mit MPI_Allreduce arbeitet und für das eine Analyse, die ζ. B. mit der MPE-Analysebibliothek erstellt wurde, zeigt, dass gerade MPI_Allreduce sehr langsam ist, so dass die parallele Leistungsfähigkeit geschmälert wird. In vielen Fällen liegt die Ursache hierfür nicht in der Implementierung von MPI_Allreduce, sondern ist oft einer schlechten Lastverteilung im Anwendungsprogramm geschuldet. Da MPI_Allreduce synchronisierend wirkt (kein Prozess kann diese Operation abschließen, bevor alle Prozesse ihre Werte beigesteuert haben), schlägt sich jegliche Unausgewogenheit der Arbeitslast in der Ausführungszeit von MPI_Allreduce nieder. Wir können die Größe der Unausgewogenheit einschätzen, indem wir die Ausführungszeit für ein MPI_Barrier unmittelbar vor dem MPI_Allreduce messen. Die Abbildung 7.20 zeigt eine einfache Implementierung und insbesondere, wie MPI_Finalize angewendet werden muss, um abschließende Statistiken aufzuzeichnen.
217
7.6 Die MPI-Schnittstelle zur Programmanalyse static double t_barrier = 0, t_allreduce = 0; int MPI_Allreduce(void* sendbuf, void* recvbuf, int count, MPI_Datatype datatype, MPI_0p op, MPI_Comm comm)
•C double tl, t2, t3; tl = MPI_Wtime(); MPI_Barrier( comm ); t2 = MPI_Wtime(); PMPI_Allreduce(sendbuf, recvbuf, count, datatype, op, comm); t3 = MPI_Wtime(); t.barrier += (t2 - tl); t.allreduce += (t3 - t2); if (t3 - t2 < t2 - tl) i int myrank; MPI_Comm_rank( comm, fcmyrank ); printf("Barrier slower than Allreduce on y,d\n", myrank);
> return MPI_SUCCESS;
>
int MPI_Finalize() { printf ("Barrier time in Allreduce °/0f; Allreduce time %f\n", t_barrier, t_allreduce); return PMPI_Finalize();
} Abbildung 7.20: C-Version von MPI_Allreduce mit Anwendung lung der Unausgewogenheit der Last
7.6.3
von MPI_Barrier zur
Ermitt-
Der Mechanismus zur Nutzung der Analyseschnittstelle
Der MPI-Standard spezifiziert nicht, wie Programme mit MPI zu übersetzen oder zu binden sind (außer der Festlegung, dass die Header-Dateien mpi.h, m p i f . h bzw. für Fortran90 ein Modul mpi existieren müssen). Ebenso sind die Namen der MPIBibliothek und der Name einer beliebigen separaten Bibliothek mit den Analyseversionen der MPI-Routinen nicht vorgegeben. In manchem System umfasst eine einzige Bibliothek sowohl die MPI- als auch die PMPIRoutinen — das ist der einfachste Fall. Die PMPI-Routinen können aber auch zu einer getrennten Bibliothek gehören. So wollen wir als Beispiel annehmen, dass die MPIRoutinen zur Bibliothek l i b m p i und die PMPI-Versionen zu l i b p m p i gehören. Dann könnte ein übersetztes Programm myprog. ο durch cc - o myprog myprog.ο - l p r o f - l m p i - l p m p i
218
7 Weitere Eigenschaften von MPI
mit einer Analysebibliothek l i b p r o f gebunden werden. Man beachte, dass hier die Analysebibliothek vor der regulären Bibliothek (-lmpi) angegeben wird. Mitunter kann es notwendig sein, die Bibliotheksnamen zu wiederholen. Arbeitet man mit Fortran oder C++, können weitere spezielle Bibliotheken erforderlich sein. Man sollte die Dokumentation der verwendeten MPI-Implementierung lesen und nicht darauf vertrauen, dass die Ausführung von cc . . . -lmpi -lpmpi stets alles sei, was notwendig ist.
7.7
Fehlerbehandlung
Fehlerbehandlung und Fehlerbehebung sind sehr wichtige und schwierige Themen. Ursachen für Fehler können falsche Programmanweisungen des Anwenders (ζ. B. inkorrekte Argumente), Hardwarefehler (ζ. B. Ausfall der Stromversorgung), NichtVerfügbarkeit von Ressourcen (ζ. B. kein weiterer freier Speicherplatz) oder Fehler in der Basissoftware sein. MPI enthält einige Hilfen zur Fehleraufzeichnung, speziell über Bibliotheken.
7.7.1
Routinen zur Fehlerbehandlung
In MPI gehört zu jedem Kommunikator eine Routine zur Fehlerbehandlung. Wird ein Fehler erkannt, ruft MPI die zu dem verwendeten Kommunikator gehörende Fehlerbehandlungsroutine auf. Gibt es keinen Kommunikator, wird MPI_C0MM_W0RLD benutzt. Wurde MPI_Init aufgerufen, sorgt im Fehlerfall die initiale (Standard-) Fehlerbehandlungsroutine dafür, dass das Programm abgebrochen wird (d.h., alle Prozesse werden beendet). Die meisten MPI-Implementierungen geben zudem eine Fehlernachricht aus. Anstelle des Abbrechens im Fehlerfall kann MPI einen Fehlercode zurückgeben. In Fortran steht hierfür in den meisten Routinen der Parameter i e r r o r . In C übernimmt der Rückgabewert der MPI-Funktion diese Aufgabe. Der allgemeinen Praxis folgend wird in C + + anstelle der Fehlercoderückgabe eine Ausnahme ausgelöst. Dies trifft nur für MPI-Wtime und MPI_Wtick nicht zu. MPI enthält zwei vordefinierte Routinen für die Fehlerbehandlung: MPI_ERRORS_ARE_FATAL (die Standardbehandlung) und MP I -ERRORS -RETURN. MP I .ERRORS -RETURN veranlasst die MPI-Routinen, nicht abzubrechen, sondern einen Fehlerwert zurückzugeben. MPI: :ERRORS_THROW-EXCEPTIONS ist in C + + die Standardfehlerbehandlung, die die Auslösung der Ausnahme MPI:: Exception bewirkt. Gibt es keine Anweisungen für das Auffangen der Ausnahme, erfolgt die gleiche Reaktion wie bei MPI_ERRORS_ARE_FATAL. Mit der Routine MPI_Errhandler_set kann die Fehlerbehandlungsroutine geändert werden. Die zurückzugebenden Fehlercodes werden, mit Ausnahme von MPI-SUCCESS, durch jede MPI-Implementierung definiert. Das gibt einer MPI-Implementierung die Möglichkeit, zusätzliche Daten in den Fehlercode einzufügen. MPI spezifiziert darüber hinaus einige Fehlerklassen: ganze Zahlen, die die Fehlercodes in eine kleine Anzahl von Kategorien einteilen. So können zum Beispiel für die Fehlerklasse MPI_ERR_TAG in der Fehlerinformation Hinweise zur Ursache eines nicht korrekten Etiketts (ζ. B. zu groß oder zu klein) sowie auf die MPI-Routine, bei der der Fehler auftrat, enthalten sein. Die Fehlerklassen in MPI-1 sind in Tabelle 7.18 aufgelistet. MPI-2 fügt für die neuen Funktionen weitere Fehlerklassen hinzu (beschrieben in Using MPI-2 [GLT99]).
7.7 Fehlerbehandlung
219
Gegenüber MPI_ERR-UNKNOWN können zu MPI_ERR_OTHER mittels MPI_Error_string nützliche Informationen übergeben werden. Die Fehlerklasse MPI_ERR_UNKNOWN versetzt eine MPI-Implementierung in die Lage, auf unerwartete Situationen zu reagieren, z.B. wenn ein Fehlercode durch ein Programmstück zurückgegeben wird, das die MPIImplementierung selbst verwendet. Die Fehlerklassen MPI_ERR.IN_STATUS und MPI_ERR_PENDING stellen Spezialfälle dar. In MPI-1 gibt es vier Routinen, die mehrfache Anfragen ausführen und ein Feld mit Zuständen zurückgeben: MPI_Waitsome, MPI_Waitall, MPI-Testsome und MPI_Testall. Bei diesen vier Funktionen können Fehler bezüglich jeder Teilmenge der Anfragen auftreten. Die Routine kann also nicht nur einen einzelnen Fehlerwert zurückgeben. Vielmehr wird in diesen Fällen der Fehlerwert MPI_ERR_IN_STATUS zurückgegeben. Hierdurch wird angezeigt, dass die tatsächlichen Fehlercodes in der Komponente MPI-ERROR des Statusfelds abgespeichert sind (status. MPI_ERR0R in C, status (MP I .ERROR) in Fortran). Um die Funktionsweise der Fehlerklasse MPI_ERR_PENDING leichter verstehen zu können, betrachten wir die Routine MPI_Waitall. Für jede an MPI_Waitall übergebene Anfrage gibt es drei Möglichkeiten. Erstens, die Anfrage wird erfolgreich abgeschlossen und der MPI-ERROR-Wert der zugehörigen Komponente des Statusfelds auf MPI-SUCCESS gesetzt. Wird die Anfrage infolge eines Fehlers abgebrochen, so wird der MPI-ERROR-Wert der entsprechenden Komponente im Statusfeld auf den MPI-Fehlercode, der den Grund des Anfrageabbruchs angibt, gesetzt. Der dritte Fall tritt ein, wenn die Anfrage weder abgeschlossen wurde noch gescheitert ist. Hier wird der Wert in MPI-SUCCESS MPI_ERR_BUFFER MP I -ERR-COUNT MPI-ERR.TYPE MPI-ERR-TAG MPI-ERR.COMM MP I-ERR-RANK MP I -ERR-REQUEST MPI_ERR_R00T MPI-ERR-GROUP MPI_ERR_0P MPI_ERR_T0P0L0GY MPI_ERR_DIMS MPI_ERR_ARG MP I _ERR_UNKNOWN MP I -ERR-TRUNC ATE MP I _ERR_0THER MP I -ERR-INTERN MPI-ERR-IN-STATUS MPI-ERR-PENDING MP I -ERR-LASTCODE
Kein Fehler Ungültiger Zeiger auf den Puffer Ungültiges Zählerargument Ungültiges Datentypargument Ungültiges Etikettenargument Ungültiger Kommunikator Ungültiger Rang Ungültiger Request (Handle) Ungültige Wurzel Ungültige Gruppe Ungültige Operation Ungültige Topologie Ungültiges Argument für die Dimension Ungültiges Argument irgend einer anderen Art Unbekannter Fehler Beim Empfang wurde Nachricht abgeschnitten Kein in dieser Liste bekannter Fehler Interner MPI-Fehler Fehler aus dem Status-Feld entnehmen Operation nicht vollständig (siehe Text) Letzter Standard-Fehlercode (keine Klasse)
Tabelle 7.18: In MPI-1 definierte
Fehlerklassen
220
7 Weitere Eigenschaften von M P I /* Install a new error handler */ MPI_Errhandler_set( MPI_C0MM_W0RLD, MPI_ERRÜRS.RETURN ); /* Send a message to ein invalid destination */ dest = -1; errcode = MPI_Send( ..., dest, ... ); if (errcode != MPI_SUCCESS) { MPI_Error_class( errcode, &errclass ); if (errclass == MPI_ERR_RANK) { puts( "Invalid rank (7,d) in call to MPI_Send", dest );
>
}
Abbildung 7.21: Prögrammstück, mit dem ein von einer MPI-Routine zurückgegebener Fehlercode auf die entsprechende Fehlerklasse geprüft wird. Man beachte, dass der MPI-Standard nicht festlegt, welche Fehlerklasse ein bestimmter Fehler zurückgibt; andere Möglichkeiten für diesen Fehler erfasst MPI-ERR-ARG MPI_ERR0R auf MPI_ERR_PENDING gesetzt, um zu signalisieren, dass die Anfrage noch unbeantwortet ist, ohne dass bisher ein Fehler auftrat. Mit MPI_Error_class kann man einen Fehlercode in eine Fehlerklasse konvertieren. Als Beispiel hierfür betrachte man die Abbildung 7.21. Jede MPI-Implementierung bietet die Möglichkeit, einen Fehlercode oder eine Fehlerklasse in eine Zeichenkette umzuwandeln. Die Routine MPI_Error_string übernimmt einen Fehlercode bzw. eine Fehlerklasse und liefert, abgelegt in einem vom Anwender bereit gestellten Zeichenkettenpuffer, eine textuelle Beschreibung des Fehlers zusammen mit der Länge des Textes in der Zeichenkette. Hierbei muss der Zeichenkettenpuffer die Länge von MPI _MAX_ERR0R_STRING haben. Damit können wir auf MPI_Error_class, wie noch im obigen Beispiel verwendet, verzichten und ζ. B. mit dem Programmstück
if (errcode != MPI_SUCCESS) { MPI_Error_class( errcode, fterrclass ); if (errclass == MPI_ERR_RANK) { char buffer[MPI_MAX_ERROR_STRING]; int resultlen; MPI_Error_string( errcode, buffer, fcresultlen ); puts( buffer );
> arbeiten.
>
7.7 Fehlerbehandlung
221
Der Wert von MPI_MAX_ERROR_STRING ist in C um eins größer als in Fortran, um den Zeichenkettenabschluss in C zuzulassen. Das heißt, sowohl in C als auch in Fortran ist dieselbe maximale Anzahl an Zeichen erlaubt, doch C erfordert ζ. B. die Deklaration char buf [11], um zehn Zeichen aufzunehmen, gegenüber character*10 buf in Fortran. Fortran-Programmierer sollten zusätzlich beachten, dass character*10 buf und character buf (10) sehr unterschiedliche Deklarationen sind. Zeichenketten für die Aufnahme von Fehlern müssen mit character*(MPI_MAX_ERROR_STRING) buf
deklariert werden.
7.7.2
Ein Beispiel zur Fehlerbehandlung
In der Abbildung 7.22 wird Quellcode gezeigt, mit dem jeder Fehler aufgezeichnet wird, der während der Ausführung einer der vier MPI-Routinen, die mehrfache Anfragen auszuführen haben, auftritt. Der Quellcode setzt voraus, dass MP I_ERR0RS-RETURN für die Fehlerbehandlungsroutine gesetzt wurde. Die C++-Version dieses Quellcodes, die eine Routine zur Fehlerbehandlung verwendet, ist in Abbildung 7.23 dargestellt. Die Ausnahmebehandlung in C + + hat die sehr schöne Eigenschaft, dass der eigentliche Programmcode nicht mit Anweisungen zur Fehlerbehandlung überfrachtet ist. Darüber MPI_Request MPI.Status char
req_array[100]; status.array[100]; msg[MPI_MAX_ERR0R_STRING];
err = MPI_Waitall( n, req_array, status_array ); if (err == MPI_ERR_IN_STATUS) { for (i=0; i 16KB
Tabelle 8.1: Mögliche Zuordnung der MPI-Sendemodi sind, wie im Text angedeutet, möglich
8.1.3
Protokoll immer Rendezvous immer Eager Eager Rendezvous zu Protokollen.
Andere
Zuordnungen
Rendezvous-Protokoll
Zur Lösung des Problems, dass zu viele Daten an den Empfänger übertragen werden, müssen wir überprüfen, wieviele Daten beim Empfänger eintreffen und wann die Daten zuzustellen sind. Eine einfache Lösung besteht darin, zunächst nur die Hülle an den Zielprozess zu senden. Wenn dann der Empfänger die Daten bekommen möchte (und Platz für deren Speicherung hat), schickt er eine Nachricht an den Sender zurück, die mit einer Aufforderung wie „Sende jetzt die Daten zu dieser Nachricht" versehen ist. Der Sender kann dann die Daten in der Gewissheit senden, dass der Empfänger über ausreichend Platz zu deren Speicherung verfügt. Diese Technik nennt man Rendezvous-Protokoll, weil Sender und Empfänger verabreden müssen, wann die Daten zu übertragen sind. Der aufmerksame Leser wird bemerken, dass diese Herangehensweise das Problem nur in Bezug auf den Speicherplatz beim Empfängerprozess für die eigentlichen Daten angeht, nicht aber in Bezug auf den Speicherplatz, der für die Hüllen benötigt wird. In jeder MPI-Implementierung ist die Anzahl nicht passender (unerwarteter) Nachrichten, die verarbeitbar sind, beschränkt. Diese Anzahl ist gewöhnlich recht hoch (ζ. B. einige Tausend), kann aber mitunter auch von Anwendungen, die sehr viele Nachrichten an andere Prozesse senden, ohne irgend eine Nachricht zu empfangen, überschritten werden. Wie zum Beispiel die MPI-Implementierung LAM die Ressourcen für die Hüllen steuert, wird in [BD95] besprochen.
8.1.4
Zuordnung zwischen Protokollen und MPI-Sendemodi
Die Gründe für die verschiedenartigen MPI-Sendemodi werden nun sicher klar. Jeder Modus repräsentiert eine andere Art der Kommunikation und kann normalerweise mit einer Kombination aus Eager- und Rendezvous-Protokoll implementiert werden. Eine mögliche Zuordnung von MPI-Sendemodi zu Protokollen ist in Tabelle 8.1 dargestellt. Es ist wichtig, nicht zu vergessen, dass MPI hier keine spezielle Art der Implementierung festlegt. Eine MPI-Implementierung könnte zum Beispiel MPI_Send, MPI_Rsend, and MPI_Ssend ausschließlich mit dem Rendezvous-Protokoll implementieren, also das Eager-Protokoll überhaupt nicht verwenden. Ebenso könnte MPI_Ssend mit einem modifizierten Eager-Protokoll implementiert werden, mit dem die Nachricht so schnell es geht gesendet wird, aber MPI_Ssend nicht abgeschlossen wird, bevor der sendende Pro-
242
8 Wie arbeiten MPI-Implementierungen?
zess eine Bestätigung vom empfangenden Prozess erhält. Damit wird erzwungen, dass MPI_Ssend nicht beendet werden kann, bevor das zugehörige receive gestartet wurde (dadurch wird MPI_Ssend etwas schneller als bei einer Implementierung, die nur das Rendezvous-Protokoll verwendet; in früheren Versionen von MPICH wurde auch so verfahren).
8.1.5
Auswirkungen auf die Leistung
Der größte Vorteil des Rendezvous-Protokolls besteht in der Fähigkeit, mit beliebig großen Nachrichten in beliebiger Anzahl umgehen zu können. Warum benutzt man dann dieses Protokoll nicht für alle Nachrichten? Der Grund besteht darin, dass die Eager-Methode schneller sein kann, speziell bei kurzen Nachrichten. Um das zu verstehen, wollen wir auf die Analyse der Zeitkomplexität im Kapitel 4 zurückgreifen und sie auf die Nachrichten anwenden, die die MPI-Implementierung selbst sendet. Die Kosten zum Senden einer Nachricht mit einer Länge von η Bytes seien s + rn, die Hülle habe eine Länge von e Bytes. Außerdem seien die Kosten für das im Fall des Eager-Protokolls eventuell erforderliche Kopieren der Nachricht aus einem temporären Puffer in den vom Empfänger bereitgestellten Puffer c Sekunden pro Byte. Die jeweiligen Kosten bei Eager- und Rendezvous-Protokoll sind in Tabelle 8.2 zusammengestellt. Wenn Nachrichten erwartet werden, ist das Eager-Protokoll stets schneller als das Rendezvous-Protokoll. Auch für unerwartete Nachrichten ist das Eager-Protokoll schneller, sofern die Nachrichtenlänge kleiner als 2(s + re)/c Bytes ausfällt. Bei kurzen Nachrichten werden die Kosten der Nachrichtenübertragung durch den Latenzanteil s dominiert — unter dieser Bedingung ist die Rendezvous-Methode dreimal langsamer als die Eager-Methode. Deshalb gibt es in den Implementierungen häufig eine Mischung solcher Verfahren. Typischerweise liegt s/c zwischen 103 und 104, sodass in vielen Anwendungen, in denen relativ kurze Nachrichten (etwa Tausend Elemente oder weniger) gesendet werden, das Eager-Protokoll immer schneller arbeitet. Auf Basis dieser Untersuchungen könnten wir stets die schnellste Methode auswählen (unter der Annahme, dass entweder alle oder einige Teile der per Eager-Methode zugestellten Nachricht unerwartet sind). In diesem Fall müssen wir jedoch etwa 2 s / c Speicherplatz für jede Nachricht, die wir ohne Verzögerung empfangen könnten, vorhalten. Wenn wir zum Beispiel annehmen, dass von jedem anderen Prozess (es seien ρ Prozesse) eine Nachricht je Kommunikator (u steht für die Anzahl der Kommunikationsräume) empfangen wird, benötigen wir 2 s p u / c Bytes Platz für jeden Prozess (bzw. 2 s p 2 u / c für alle Prozesse insgesamt). In einem System mit vielen Prozessen ist das ein signifi-
Protokoll Eager (erwartet) Eager (unerwartet) Rendezvous Tabelle
8.2:
Vergleich der Kosten
Zeit s + r(n + e) s + r(n + e) + cn 3s + r(n + 3e)
bei Eager- und
Rendezvous-Protokoll
8.1 Einführung
243
kant hoher Bedarf an Speicherplatz. Viele Implementierungen beheben dieses Problem derzeit durch eine Limitierung des verfügbaren Speichers und geben jedem potentiellen Quellprozess den gleichen Speicherplatz für sofort versendbare Nachrichten. Andere Methoden sind denkbar, zum Beispiel eine dynamische Zuweisung von Speicherplatz für die Eager-Nachrichten nach Bedarf.
8.1.6
Alternative Strategien für MPI-Implementierungen
Wir haben hier eine Implementierungsmöglichkeit kurz skizziert. Es gibt aber alternative Herangehensweisen. Einer Empfangsoperation ist es zum Beispiel möglich, eine Information an den Absender zu schicken (sofern nicht MPI_ANY_SOURCE verwendet wurde), die anzeigt, dass eine Nachricht erwartet wird [OTY98]. Damit kann die durch das Rendezvous-Protokoll verursachte Latenz reduziert werden, weil der Austausch einer Nachricht vermieden wird. Andere Methoden sind anwendbar, wenn die Hardware den Zugriff auf entfernten Speicher oder gewisse Arten eines gemeinsamen Speichers unterstützt.
8.1.7
Anpassung von MPI-Implementierungen
Die meisten MPI-Implementierungen verfügen über Parameter, mit denen der MPINutzer die Leistung auf seine Anwendung abstimmen kann. Aus den letzten Betrachtungen wissen wir, dass sowohl der für unverzüglich zuzustellende Nachrichten verfügbare Speicherplatz als auch die Nachrichtenlänge, bei der ein MPI_Send oder MPI_Isend vom Eager- auf das Rendezvous-Protokoll umschaltet, „natürliche" Parameter sind. Viele MPI-Implementierungen gestatten dem Anwender, diese Parameter in gewisser Weise zu steuern. In der Implementierung von IBM zum Beispiel gibt es eine Umgebungsvariable MP_EAGER_LIMIT, mit der die Nachrichtenlänge, ab der das RendezvousProtokoll verwendet werden soll, gesetzt werden kann. Man kann jedoch den Wert nicht einfach auf 4000000000 setzen; in der Regel gibt es einen Bereich mit zulässigen Werten. Diese Parameter sind implementierungsabhängig und können sich von Zeit zu Zeit ändern. Die MPI-Implementierung von SGI zum Beispiel stellt MPI_THRESH0LD zur Verfügung, um die Umschaltung von Eager- auf Rendezvous-Methode zu steuern, führt aber (während der Entstehung dieses Buchs) eine Reihe weiterer Umgebungsvariablen ein, die sich sehr eng an diese spezielle MPI-Implementierung anlehnen (in der unter anderem eine Verallgemeinerung der Rendezvous-Methode umgesetzt wird). Bezüglich der verfügbaren Umgebungsvariablen sollte auf jeden Fall die MPI-Dokumentation des entsprechenden Herstellers zu Rate gezogen werden. In manchen Fällen gibt es Optionen, die man möglicherweise bei der Programmentwicklung einsetzen möchte. So arbeitet die Implementierung von SGI standardmäßig ohne Fehlerprüfung. Während der Entwicklung eines Programms ist es jedoch wichtig, die Umgebungsvariable MPI-CHECK_ARGS zu setzen.
244
8.2
8 Wie arbeiten MPI-Implementierungen?
Wie schwierig ist es, MPI zu implementieren?
MPI enthält viele Funktionen. Wie schwierig ist es, diese vollständig zu implementieren? Wie schwer ist es, eine leistungsfähige Implementierung zu entwickeln? MPI wurde sorgfältig entworfen, so dass die Eigenschaften von MPI orthogonal sind. Damit ist die Möglichkeit gegeben, sowohl eine vollständige Implementierung, die sich nur auf eine geringe Anzahl von Basisroutinen stützt, zu realisieren als auch eine MPIImplementierung schrittweise zu verbessern, indem Teile der Implementierung durch verfeinerte Techniken ersetzt werden, wodurch sich die Implementierung voll entwickeln kann. Das ist eine durchaus übliche Verfahrensweise. Im Bereich Graphik oder Druck zum Beispiel sind graphische Ausgaben so ausgelegt, dass ein einzelnes Pixel an eine beliebige Position gesetzt werden kann. Jede beliebige andere Graphikfunktion kann nun erstellt werden, die diese einfache, elegante Grundmethode nutzt. Sehr leistungsstarke Graphik-Displays jedoch verfügen über eine große Vielfalt an zusätzlichen Punktionen, vom Blockkopieren über das Zeichnen von Linien bis zur Schattierung von SDOberflächen. MPI-Implementierungen tendieren zur gleichen Herangehens weise: in der untersten Ebene der Implementierung findet man Basisroutinen zum Austausch von Daten zwischen Prozessen. Zu Beginn ihres Lebenszyklus kann eine MPI-Implementierung alles von MPI unter ausschließlicher Benutzung dieser Basisroutinen umsetzen. Anschließend kann die Implementierung um fortgeschrittenere Funktionen erweitert werden. Zum Beispiel könnten spezielle Routinen durch Ausnutzung besonderer Eigenschaften der Hardware des Parallelrechners schnellere kollektive Operationen ermöglichen. Die Topologie-Routinen (ζ. B. MPI_Cart_create und MPI_Graph_create) könnten mit Kenntnissen über die Verbindungstopologie eines Parallelrechners ausgestattet werden. Ein Beispiel für ein Merkmal von MPI, für das die Implementierungen schrittweise verbessert werden, ist der Umgang mit den abgeleiteten MPI-Datentypen. Frühe Implementierungen arbeiteten häufig mit einem Äquivalent zur Anwendung von MPI_Pack mit nachfolgendem Senden zusammenhängender Daten. Mit der Weiterentwicklung der Implementierungen richteten sich die Bemühungen auf eine bessere Leistungsfähigkeit im Zusammenhang mit der Arbeit mit abgeleiteten Datentypen — ein Beispiel hierzu kann man in [GLS99b] finden.
8.3
Zusammenspiel von Hardware-Eigenschaften und MPI-Bibliothek
Weiter oben hatten wir bemerkt, dass die Betrachtung der Hardwareschnittstellen einige der Entwurfsentscheidungen in MPI verständlicher werden lässt. Jede Message-PassingBibliothek vermittelt ein bestimmtes Bild zu den Eigenschaften der Hardware, für die sie gedacht ist. MPI wurde mit großer Sorgfalt gestaltet, um so wenige Beschränkungen wie möglich einzuführen, wodurch den Implementierungen Raum gegeben wird, die Vorzüge moderner, leistungsfähiger Hardware weitgehendst auszunutzen. Als einfaches Beispiel hierfür betrachten wir die Routine MPI_Probe, die blockierende „Test"-Funktion. Wir erinnern uns, dass es in MPI auch eine entsprechende nichtblockie-
8.4 Sicherheit der Datenübertragung
245
rende Version, MPI.Iprobe, gibt. Nicht jede Message-Passing-Bibliothek unterstützt diese Testfunktionen. Wenn derartige Tests unterstützt werden, dann meist nur in der nichtblockierenden Fassung, weil die blockierende Version vom Anwender selbst mit einer Schleife, die die nichtblockierende Routine aufruft, implementiert werden kann. Jedoch ist dieses „aktive Warten" (engl: busy waiting) in einer Multithreading- oder gar Multiprogramming-Umgebung nicht wirklich akzeptabel. Wenn die Bibliothek auch den blockierenden Test bereitstellt, kann die Implementierung jede ihr zur Verfügung stehende Ressource nutzen, so ζ. B. eine intelligente Steuereinheit für die Kommunikation, um die C P U während des Wartens auf eine Nachricht frei zu halten. Gleichermaßen gibt es aus Sicht des Programmierers kein Muss für die (blockierende) Routine MPI_Recv, weil deren Funktionalität auch mit MPI.Irecv, gefolgt von MPIJiait, erzielt werden kann. Andererseits kann die Bibliothek die Hardware effizienter nutzen, wenn sie nicht mitten in der Operation zum Nutzer zurückkehren muss. Ein anderes bezeichnendes Beispiel ist die Verwendung eines Datentypparameters in allen MPI-Kommunikationsaufrufen anstelle des Sendens nichttypisierter Daten durch Verwendung der Routinen MPI_Pack und MPIJJnpack. Das manchmal notwendige explizite Packen und Entpacken von Daten erfordert ein Kopieren der Daten im Speicher, was gewöhnlich nicht notwendig ist. Andere Bestandteile der MPI-Bibliothek, so wie MPI.Waitany, können offensichtlich auf der Anwenderebene implementiert werden und sind damit für den Programmierer nicht notwendig, um einen Algorithmus umzusetzen. Diese zu eliminieren hieße, MPI zu einer kleineren und einfacheren Bibliothek zu machen. Auf der anderen Seite würden damit aber die Möglichkeiten für Optimierungen auf der Kommunikationsschicht verloren gehen. M P I ist vor allem deshalb eine relativ große Bibliothek, weil das MPI-Forum ganz bewusst die Idee von einer "minimalistischen" Bibliothek zugunsten der Erzielbarkeit einer hohen Leistungsfähigkeit geopfert hat.
8.4
Sicherheit der Datenübertragung
Der MPI-Standard legt fest, dass die Message-Passing-Operationen sicher sein müssen. Das bedeutet, dass der Anwendungsprogrammierer, der M P I benutzt, sich nicht darum sorgen muss, ob seine gesendeten Daten korrekt übertragen werden. DEIS bedeutet jedoch, dass die MPI-Implementierung Vorkehrungen für die Sicherstellung einer fehlerfreien Übertragung treffen muss. Kein Mechanismus zur Datenübertragung ist absolut sicher. Sogar bei direkten Speicherzu-Speicher-Verbindungen treten mit einer geringen Wahrscheinlichkeit Fehler auf. Dafür gibt es Paritäts- und ECC-Speicher (error correcting code) sowie spezielle Datenpfade. Mit der Paritätsprüfung kann man Fehler erkennen, mit E C C kann man verschiedene Fehler (in der Regel 1-Bit-Einzelfehler) korrigieren und andere Fehler (gewöhnlich Zweifachfehler) erkennen. Bei lose gekoppelten Systemen ist die Wahrscheinlichkeit für das Auftreten nicht korrigierbarer Fehler höher. Betrachten wir als Beispiel das allgemein benutzte Netzwerkprotokoll T C P , das sichere Datenverbindungen zwischen zwei Punkten bietet. Doch was bedeutet in diesem Kontext „sicher"? Da 100%ige Sicherheit unmöglich ist, muss jede TCP-Implementierung
246
8 Wie arbeiten MPI-Implementierungen?
entscheiden können, ob eine Verbindung fehlgeschlagen ist. In diesem Fall beendet T C P die Verbindung und setzt den Nutzer verlässlich davon in Kenntnis, dass die Verbindung geschlossen wurde. Das ist die Bedeutung von „sicher" in TCP: die Daten werden übertragen oder es ist feststellbar, dass während der Übertragung Fehler auftraten. Dieses Niveau an Sicherheit ist unter manchen Umständen angemessen, unter anderen aber nicht. Viele Mitwirkende im MPI-Forum hatten die Absicht, MPI mit etwas auszustatten, was häufig als „garantierte Sicherheit" bezeichnet wird: von MPI würde verlangt werden, dass eine Nachricht zugestellt wird, in der alle Fehler, die in den unteren Schichten des Datentransfers aufgetreten sind, beseitigt wurden. Im Fall von T C P würde das bedeuten, dass zu überwachen wäre, wieviele Daten erfolgreich übertragen wurden, und dass beim Auftreten von Verbindungsfehlern MPI automatisch eine Verbindung wiederherstellen und die Übertragung fortsetzen müsste. Die Fähigkeit, die Zustellung der Daten in jedem Fall sicherzustellen, verursacht jedoch zusätzlichen Overhead und kann die Leistung von MPI (oder eines anderen Message-Passing- oder Datenübertragungssystems) hinsichtlich der Geschwindigkeit verringern. Ein weiteres Beispiel ist ein System mit einem Hochgeschwindigkeitsnetzwerk wie HiPPI. HiPPI (auch mit HiPPI-800 bezeichnet, um es von der zweiten Generation, der Version HiPPI-6400, zu unterscheiden) verfügt über eine Bandbreite von 100 M B / s (800 Mb/s), die Fehlerrate liegt bei höchstens 1 0 - 1 2 Fehlern pro Sekunde. Diese Fehlerrate erscheint nur solange als sehr niedrig, bis man sich vergegenwärtigt, wie schnell HiPPI ist: Bei 8 χ 108 Bits pro Sekunde ist alle 1250 Sekunden, also etwa alle 20 Minuten, ein Fehler zu erwarten. Natürlich sind die tatsächlichen Fehlerraten niedriger als der spezifizierte Wert, d. h. im Durchschnitt werden Fehler weniger oft als alle 20 Minuten auftreten. Aber, eine lange laufende MPI-Anwendung ist auf die Verfügbarkeit von Fehlererkennung und -korrektur angewiesen, um erfolgreich arbeiten zu können — eine korrekte MPI-Implementierung wird diese Fähigkeit bereitstellen.
9
MPI im Vergleich
In der ersten Auflage dieses Buchs befasste sich ein Kapitel mit der Aufgabe, Programme, die andere Message-Passing-Systeme benutzten, nach MPI zu konvertieren. Eine solche Darstellung ist inzwischen nicht mehr notwendig, da die meisten Programme, die mit älteren Message-Passing-Systemen arbeiteten, bereits für MPI umgeschrieben oder gänzlich verworfen wurden. In der ersten Auflage wurde auch gezeigt, wie MPI die Vorzüge früherer Message-Passing-Systeme nutzte, so ζ. B. die von Chameleon [GS93a], Zipcode [SSD + 94] oder von kommerziellen Systemen wie Intel NX und EUI von I B M (später mit M P L bezeichnet). Dieses Kapitel half auch, die Aufmerksamkeit auf Unterschiede und Gemeinsamkeiten zwischen MPI und anderen Systemen zu lenken. Mit dem Verständnis für die Unterschiede zwischen den verschiedenartigen Systemen kann man insbesondere erkennen, warum bestimmte Entwurfsentscheidungen getroffen wurden. Desgleichen kann man besser entscheiden, welches konkrete Herangehen für eine gegebene Aufgabe geeignet ist. Dieses Kapitel der ersten Auflage ist im World Wide Web über http://www.mcs.cinl.gov/mpi/usingmpi zugänglich. Wir wollen in diesem Kapitel zwei andere Systeme zur Interprozesskommunikation genauer behandeln. Das erste sind Sockets, ein bekannter systemnaher Mechanismus zum Datenaustausch zwischen unterschiedlichen Rechnern. Das zweite ist P V M [GBD+94], ein beliebtes und weit verbreitetes Message-Passing-Modell, in dem besonderer Wert auf die Kommunikation zwischen Rechnersystemen gelegt wird und das eins von den Systemen ist, für das immer noch Portierungen nach M P I vorgenommen werden.
9.1
Sockets
Die Socket-Programmierschnittstelle (engl.: application programmer interface, API) ist die grundlegende Schnittstelle, um mit T C P (als einer möglichen Methode) Daten zwischen Prozessen, die über verschiedenste Netzwerke, einschließlich des Internets, verbunden sind, zu versenden. Einzelheiten zu dieser Schnittstelle wollen wir hier nicht behandeln. Eine detaillierte Darstellung der Arbeit mit Sockets ist zum Beispiel in [Ste98] zu finden. In unserer Betrachtung soll der Schwerpunkt auf der Nutzung von T C P mit Sockets liegen, d. h. wir werden nur einen Teil der Socketschnittstelle besprechen, um Unterschiede und Ähnlichkeiten im Vergleich mit M P I verständlich werden zu lassen. Sockets benutzt man vor allem, um einen Pfad für eine Punkt-zu-Punkt-Kommunikation zwischen Prozessen aufzubauen. Ist eine Verbindung zwischen zwei Prozessen eingerichtet (mit Verwendung von socket, bind, listen, accept und connect), können die beiden Prozesse mit Hilfe von Dateideskriptoren (engl.: file descriptors, fds) vom Socket lesen und auf den Socket schreiben. Sehr vereinfacht betrachtet ist ein read von einem
248
9 M P I im Vergleich
void Sender( int fd, char *buf, int count ) { int n; while (count > 0) { η = write( fd, buf, count ); if (n < 0) { ... special handling ... } count -= n; buf += n;
>
>
void Receiver( int fd, char *buf, int count ) { int n; while (count > 0) -( η = read( fd, buf, count ); if (n < 0) { ... special handling ... } count -= n; buf += n;
>
>
Abbildung 9.1: Teilprogramm für das Senden von Daten zwischen zwei Prozessen Socket einem MPI_Recv und ein write zum Socket einem MPI_Send ähnlich. Jedoch sollen uns hier gerade die Unterschiede zwischen M P I und Sockets interessieren. Wir wollen uns zunächst ansehen, wie mit Hilfe von Sockets Daten versendet werden können. Hier wird ein Aufruf von write mit der Rückgabe eines positiven oder negativen Werts abgeschlossen. Ist der Rückgabewert positiv, gibt er die Anzahl der geschriebenen Bytes an. Durch einen negativen Rückgabewert wird ein Problem angezeigt (es wurde kein Byte geschrieben). Das Verhalten von read ist analog: der zurückgegebene Wert gibt die Anzahl gelesener Bytes an, ein negativer Wert weist auf ein Problem hin (es wurde nichts gelesen). Wie in M P I ist auch für Sockets die Verwaltung von Nachrichtenpuffern der Schlüssel zum Verständnis der genauen Abläufe. Zuerst ist zu beachten, dass ein Versuch, count Bytes zum Socket zu schreiben, nur teilweise erfolgreich sein kann. Der Ablauf hängt davon ab, ob der Socket als nichtblockierend vereinbart wurde (hierzu ist die Markierung 0JJ0NBL0CK oder 0-NDELAY durch Aufruf von f cntl für den Dateideskriptor des Sockets zu setzen). Wurde der Socket nicht als nichtblockierend eingestellt, dann ist write erfolgreich, das heißt, die Operation gibt den Wert count zurück, es sei denn, count ist größer als die Größe des Socketpuffers (das setzt außerdem voraus, dass der Socketpuffer zu Beginn der Schreiboperation leer ist, also zum Beispiel noch keine Daten zum Socket gesendet wurden). Wenn count größer als die Pufferkapazität ist, blockiert write, bis genügend Daten von der anderen Seite des Sockets gelesen wurden. In dieser Einstellung ist write der Operation MPI_Send sehr ähnlich: ausreichend kleine Datenpuffer werden
249
9.1 Sockets
gesendet, auch wenn keine Empfangsoperation wartet, wogegen das Senden (Schreiben) großer Datenpuffer den Prozess blockieren kann, wenn auf eine passende Empfangsoperation auf der Gegenseite gewartet werden muss. Aus den gleichen Gründen, die wir bereits in Kapitel 4 besprochen haben, kann es in manchen Fällen schwierig sein, eine Verklemmung in einem Programm mit write und read zu vermeiden, wenn zwei Prozesse gleichzeitig versuchen, Daten zu einem Socket zu schreiben, der keinen freien Pufferplatz mehr hat. Um dieses Problem zu vermeiden, kann ein Socket als nichtblockierend eingestellt werden, wodurch sich read und write anders als im blockierenden Fall verhalten. Hat write nicht ausreichend Platz zum Schreiben der Daten, wird der Wert -1 zurückgegeben und die Fehlerinformation in errno auf EAGAIN1 gesetzt. Das verhindert zwar das Blockieren des Prozesses, erfordert aber vom Programmierer andere Maßnahmen, um die Daten zuzustellen. Wenn ein write erfolglos ist, kann der Programmierer zum Beispiel versuchen, mit read vom Socket zu lesen, falls von beiden Seiten auf den Socket geschrieben wird (die „unsichere" Variante bei jedem Prozess, dem anderen, mit der Absicht eines nachfolgenden Lesens, zu schreiben). Bei dieser Art des Datenaustausche entsprechen write und read etwa MPI_Isend und MPI_Irecv, aber mit einer anderen Technik der Behandlung von MPI.Wait. Man beachte, dass dieser nichtblockierende Modus für Sockets vorgesehen ist, damit Anwendungen bei begrenzten Pufferressourcen, so wie in nichtblockierenden Modi in MPI, fehlerfrei arbeiten können. In manchen Betriebssystemen gibt es asynchrone Lese- und Schreiboperationen, die mit aio_read bzw. aio_write bezeichnet werden. Diese bilden eine alternative Schnittstelle zu nichtblockierenden Lese- und Schreiboperationen und stehen den nichtblockierenden MPI-Operationen vom Konzept her näher. Es ist jedoch zu beachten, dass MPI nicht fordert, dass diese Operationen (bzw. hier in der Definition von aiojread oder aio_write) gleichzeitig zu den Berechnungen ausgeführt werden. In Analogie zu den nichtblockierenden MPI-Operationen gibt es in der Socket-Programmierschnittstelle die Operationen select und poll, die den Routinen MPI.Waitsome und MPI_Testsome entsprechen. Deren Argumente sind Masken für Dateideskriptoren. Die Operationen select und poll können, wie die Routine MPI_Testsome (bei Aufruf mit einem Timeout-Wert von 0), sofort abgeschlossen werden oder blockieren, bis mindestens für einen der Dateideskriptoren die Bereitschaft zum Lesen oder Schreiben gegeben ist. Der Unterschied besteht darin, dass select und poll lediglich anzeigen, ob ein Dateideskriptor benutzt werden kann. In MPI dagegen wird bei erfolgreichem Test oder Warten die zugehörige Kommunikation auch beendet. Abbildung 9.2 skizziert, wie MPI_Testsome in einer Implementierung aussehen könnte, die über Sockets kommuniziert.
9.1.1
Erzeugen und Beenden von Prozessen
Für die Beschreibung einer Message-Passing-Bibliothek oder einer Anwendung wird die meiste Zeit benötigt, um zu erklären, wie Daten zwischen Prozessen gesendet werden. Häufig wird dabei die Erklärung dafür vergessen, wie Prozesse überhaupt gestartet 'In einer Multithread-Umgebung muss auf den errno-Wert des entsprechenden Threads zugegriffen werden.
250
9 M P I im Vergleich int Testsome( int *flag ) { fd_set readmask, writemask; int nfds; struct timeval timeout; ... setup read and write masks ... set timeout to zero (don't block) timeout.tv_sec = 0; timeout.tv_usec = 0; nfds = select( maxfd+1, fcreadmask, &writemask, (fd_set*)0, fttimeout ); if (nfds == 0) { *flag = 0; return 0; > if (nfds < 0) { return nfds; > /* Error! */ for (fds to read on) { if (FD_ISSET(fd,&readmask)) { Receive (fd );
>
>
for (fds to write on) { if (FD_ISSET(fd,&writemask)) { Sender(fd,....);
}
> return 0;
} Abbildung Sockets
9.2:
Skizze
einer Testsome-Implementierung bei Verwendung
von select
mit
werden können, im Besonderen in skalierbarer Art und Weise. Um die Kernpunkte darzustellen, wollen wir hier einige Möglichkeiten zum Start von Prozessen beschreiben. Das Starten eines parallelen Programms besteht aus zwei Schritten: Starten der Prozesse und Herstellen der Verbindungen zwischen allen Prozessen. Dabei sind jeweils verschiedenartige Herangehensweisen denkbar. Eine der einfachsten Methoden besteht darin, dass der erste Prozess, der zum Beispiel durch mpiexec gestartet werden kann, alle anderen Prozesse mit einem Remote-ShellProgramm, wie ζ. B. rsh oder ssh, startet. Der Startprozess wird als Masterprozess bezeichnet. Bei dieser Technik kann ein Kommandozeilenargument verwendet werden, um den neuen Prozessen die Port-Nummer eines Sockets des ersten Prozesses mitzugeben, zu dem eine Verbindung hergestellt werden kann, um Informationen über alle Prozesse des MPI-Programms erhalten zu können. Dieser Zugang hat den Vorteil, dass die vorhandenen Möglichkeiten zum Starten von Prozessen und zur Steuerung der Ein- und Ausgabe sowie Fehlerbehandlung benutzt
251
9.1 Sockets
werden können. Nachteile aber gibt es viele. So ist zum Beispiel keine Skalierung möglich (alle Prozesse sind mit dem Masterprozess verbunden) und die Methode basiert auf der Remote-Shell (die als Sicherheitsrisiko angesehen wird) oder einer sicheren Variante dieser Shell. In Umgebungen mit einem für den Lastausgleich verantwortlichen Prozessmanager ist dieses Herangehen nicht möglich, da nur der Prozessmanager Tasks starten darf. Eine andere Methode sieht sogenannte Daemons auf den Prozessoren vor, die das Starten von Prozessen für MPI-Jobs ermöglichen. Ein MPI-Job wird gestartet, indem nach und nach Daemons kontaktiert und aufgefordert werden, jeweils einen MPIProzess zu starten. Das kann skalierbar organisiert werden, bürdet allerdings dem MPIImplementierer zusätzlichen Aufwand auf. So sind Dienste für eine zuverlässige, fehlertolerante und sichere Prozessverwaltung bereitzustellen. Zusätzlich müssen einige Mechanismen bereitgestellt werden, ζ. B. ein separater Datenserver, mit denen sich die MPI-Prozesse gegenseitig erkennen können. Wenn schon das Starten von Prozessen recht schwierig erscheint, so ist die Sicherstellung, dass alle Prozesse unter allen Bedingungen beendet werden, noch schwieriger. Dazu wollen wir uns ansehen, was passiert, wenn ein MPI-Prozess eines MPI-Jobs entweder durch ein Signal (wie SIGINT oder SIGFPE) oder durch MPIJVbort abgebrochen wird. In diesem Fall sollten alle Prozesse des MPI-Jobs beendet werden. Das zu gewährleisten kann sehr schwierig sein. Es wäre zum Beispiel falsch, sich auf das Abfangen eines Signals zu verlassen, da einige UNIX-Signale nicht abfangbar sind (z. B. SIGKILL and SIGSTOP). Die Einrichtung eines Wächterprozesses ist ebenfalls unzuverlässig, da der Wächter selbst abbrechen oder abgebrochen werden kann. (Einige Betriebssysteme brechen zum Beispiel Prozesse ab, wenn kritische Ressourcen wie Auslagerungsplatz (engl.: swap space) zu knapp werden, was von einem außer Kontrolle geratenen parallelen Programm verursacht werden könnte. Das Betriebssystem würde dann möglicherweise gerade diesen Prozess, also den Wächter, der die Lage retten könnte, abbrechen.)
9.1.2
Behandlung von Fehlern
Der MPI-Standard sagt wenig dazu aus, was passiert, wenn irgendetwas nicht korrekt abläuft. Das ist im Wesentlichen dem Umstand geschuldet, dass Standards in der Regel das Verhalten von Programmen im Fehlerfall oder die Konsequenzen aus Ereignissen außerhalb des Standards (etwa ein Stromausfall) nicht festlegen. Das passt gut mit hochskalierbaren Systemen zusammen, in denen die Kommunikation zwischen Prozessen durch die darunterliegende Betriebssystemumgebung garantiert wird und eine skalierbare und leistungsstarke Implementierung der kollektiven MPI-Routinen sehr wichtig ist. In einer eher lose gekoppelten Netzwerkumgebung hingegen, ζ. B. einer Ansammlung von Workstations, ist die Wahrscheinlichkeit dafür, dass die Verbindung zu einem anderen Prozess getrennt wird, sehr viel höher. In vielen Anwendungen, die mit Sockets (oder PVM) arbeiten, wird dieser Fall innerhalb des Programms berücksichtigt. Ein Grund, warum dieser Fall mit Sockets leichter als in MPI gelöst werden kann, ist die Besonderheit, dass es bei der Kommunikation über Sockets nur zwei Partner gibt: wenn einer ausfällt, ist klar, was zu tun ist. Das ist in MPI viel schwieriger, da nach dem Ausfall eines von tausend Prozessen während einer kollektiven MPI-Operation wie
252
9 MPI im Vergleich
MPI_Allreduce eine Wiederherstellung sehr schwierig ist (man kann zwar sicherstellen, dass die MPI_Allreduce-Operation bei allen Prozessen des Kommunikators erfolgreich gewesen sein muss, bevor das MPI-Programm weiter arbeiten darf, erkauft sich das aber mit signifikant höheren Laufzeitkosten). Eine MPI-Implementierung könnte das gleiche Niveau an Unterstützung bei Kommunikationsfehlern im Falle von Kommunikatoren mit nur zwei Prozessen bieten — das ist eine Frage der „Qualität der Implementierung". Es gibt also keinen Grund, dass eine MPI-Implementierung nicht die gleiche Güte für die Fehlerbehandlung bei der Kommunikation wie bei Sockets bieten könnte, solange es sich um Kommunikatoren mit nur zwei Partnern handelt. Das kann sogar auf Interkommunikatoren ausgedehnt werden, bei denen die zwei „Parteien" Gruppen von Prozessen sind.
9.2
PVM 3
PVM ist eine bedeutende Bibliothek von Message-Passing-Modellen, die am Oak Ridge National Laboratory, Universität von Tennessee, und an der Emory Universität von Vaidy Sunderam, Al Geist, Jack Dongarra, Robert Manchek und anderen (siehe [GS92]) entwickelt wurde. PVM steht für „Parallel Virtual Machine" und bietet Unterstützung für Prozessverwaltung, heterogene Cluster und Message-Passing. PVM 2 und ältere Versionen liefen auf heterogenen Workstation-Netzwerken und Vorrechnern (engl.: front ends) für Parallelmaschinen. PVM 3.x (zur Zeit der Entstehung dieses Buchs waren Version 3.4 mit Anwenderdokumentation 3.3 aktuell) ist die aktuelle Version der PVM-Bibliothek, die in [GBD + 94] beschrieben wird. Im Gegensatz zur Version 2 unterstützt PVM 3 spezielle Parallelrechner genauso wie heterogene WorkstationNetzwerke. Darüberhinaus stellt Version 3 eine vollständige Neugestaltung des Systems dar. Dennoch sind die Aspekte des Message-Passings in der Version 3 recht einfach geblieben und die Übersetzung nach MPI somit unkompliziert. Version 3 arbeitet mit einer systematischen Namenskonvention für Funktionen und Konstanten. In C wird das Präfix pvm_ allen Namen von Routinen vorangestellt, um die zahlreichen Namenskonflikte, die bei kurzen gebräuchlichen Namen leicht auftreten können, zu vermeiden. In Fortran wird entsprechend pvmf verwendet. Um eine Zusammenfassung der Ähnlichkeiten und Unterschiede zwischen MPI und PVM 3 zu erleichtern, werden die Themenkreise in sechs Unterabschnitten behandelt: Grundlagen Initialisieren und Aufräumen, eigene Identifikation und Ermittlung von Partnern, (blockierende) Grundoperationen zum Senden und Empfangen von Nachrichten Weitere Funktionen Nichtblockierende Sende- und Empfangsoperationen, Warten, Testen auf das Eintreffen einer Nachricht, Statusabfragen, Fehlerbehandlung Kollektive O p e r a t i o n e n Synchronisation von Prozessen, kollektive Datenübertragungen und Berechnungen, Prozessuntergruppen M P I - P e n d a n t s weiterer Funktionalitäten Spezielle Funktionalitäten der PVM-Bibliothek, die sich auch in MPI wiederfinden
253
9.2 P V M 3
F u n k t i o n a l i t ä t e n , d i e sich nicht in M P I w i e d e r f i n d e n Spezielle Funktionalitäten der PVM-Bibliothek, für die es keine Pendants in M P I gibt S t a r t e n v o n P r o z e s s e n Möglichkeiten, die über M P I hinaus gehen, aber einer Betrachtung und eines Vergleichs wert sind
9.2.1
Grundlagen
In Tabelle 9.1 sind die sich entsprechenden Grundfunktionen von PVM 3 und M P I zusammengestellt. Es sind die jeweiligen Fortran-Signaturen angegeben; die für C sind ähnlich. Wir zeigen nur bestimmte Parameter einer Routine; die Korrespondenz der übrigen Parameter kann leicht abgeleitet werden. Gruppenparameter in P V M 3 entsprechen dem Konzept der dynamischen Gruppen, das in Version 3 aufgenommen wurde. In M P I dagegen steht der Kommunikator für eine statische Gruppe. Das dynamische Hinzufügen oder Ausschließen von Prozessen nach der Erzeugung einer Startgruppe ist nicht Bestandteil des MPI-Modells und verlangt eine wesentlich kompliziertere Portierungsstrategie als die, die in Tabelle 9.1 angedeutet wird. Interkommunikatoren sind ein alternatives Hilfsmittel in MPI, um eine komplexe Gruppenverwaltung zu ermöglichen. (Der Rang einer Task und die Anzahl der Tasks können auch ohne die Verwendung der Gruppenoperationen von P V M zum Zeitpunkt des Starts der PVM-Prozesse ermittelt werden. Auf diesen Ansatz wollen wir aber hier nicht eingehen.) In P V M 3 wurden die Begriffe „Komponenten" und „Instanzen", die in der Version 2 noch verwendet wurden, durch TIDs (Task IDs) und dynamische Prozessgruppen abgelöst. Diese Änderung erleichtert die Portierung nach MPI ein wenig, solange nicht zu häufig mit dynamischen Gruppen gearbeitet wird. Weiterhin hat man in P V M 3 die Beschränkung auf einzelne Puffer zugunsten mehrfacher Puffer, wobei jeder mit einer „bufid" gekennzeichnet wird, aufgegeben. Puffer werden in P V M 3 erzeugt, gelöscht und ausgewählt — diese Operationen werden in M P I nicht benötigt. Der Parameter „buf-type" in den Funktionen MPI_SEND() und MPI_RECV() in Tabelle 9.1 bezieht sich entweder auf einen einfachen Typ wie MPI-BYTE oder MPI-INTEGER oder auf einen komplexeren Datentyp, wie er weiter unten behandelt wird. pvmfmytid(mytid)
MPI.INIK. . .)
pvmfexit(info)
MPI_FINALIZE(ierr)
pvmfgetinst(grp,mytid,me)
MPI-COMM JIANK (comm, me, i e r r )
pvmfgsize(grp,np)
MPI_COMM_SIZE(conim,np,ierr)
pvmfpack(...);pvmfsend(tid,tag,info)
MPI_BSEND(. . , buf.type, . . .) or MPI-SEND (. . , buf-type,. . .)
pvmfrecv(tid,tag,bufid);pvmfunpack(...)
MPI_RECV(. . , b u f . t y p e , . . . )
pvmfpsend(tid,tag,buf,len,datatype,info)
MPI_SEND(buf,len,datatype...)
pvmfprecv(tid,tag,buf.len,datatype,...)
MPI-RECV(buf,len,datatype...)
Tabelle 9.1: Übersetzung von PVM 3 nach MPI:
Grundfunktionen
254
9 MPI im Vergleich
pvmfnrecv(src,tag,bufid)
MPI_IPROBE(.. .) ; i f ( f l a g ) MPI_RECV(...);
pvmfprobe(src,tag,buf id)
MPI_IPROBE(src,tag, MP I _C0MM_W0RLD, f 1 ag, s t a t u s )
pvmfperror(str,infο)
MPI_ABORT(comm, v a l )
Tabelle
9.2: Übersetzung von PVM 3 nach MPI: weitere
Funktionen
Das PVM-Konzept stellt keine Bedingungen an die Pufferung, obwohl die am meisten verbreitete Implementierung eine erhebliche (aber nicht unbegrenzte) Pufferung bereitstellt. PVM-Anwender, für die die Pufferung in der Implementierung maßgeblich ist, können mit dem puffernden Senden von MPI (MPI_Bsend) arbeiten.
9.2.2
Weitere Funktionen
In PVM 3 gibt es sowohl eine Pro&e-Operation als auch nichtblockierendes Empfangen. Zu beachten ist, dass die nicht blockierende Empfangsoperation in PVM sich von MPI_Irecv in MPI unterscheidet. Sie vereint vielmehr die Wirkung von MPI_Iprobe und MPI Jlecv.
9.2.3
Kollektive Operationen
In PVM 3 gibt es keine Pendants zu der großen Vielfalt in MPI an globalen Routinen für den Transport von Daten. Um Zugriff auf die kollektiven Operationen von PVM 3 zu erhalten, muss man meist mit dynamischen Gruppen arbeiten. Die Broadcast-Operation pvmfmcast in PVM wird als mehrfaches Senden realisiert, wogegen mit MPIJ3CAST gesendete Nachrichten in MPI auch mit MPI_BCAST empfangen werden. Der Sender wird im root-Parameter festgelegt, alle anderen Prozesse empfangen. Die Semantik einer Barriere in PVM 3 unterscheidet sich deutlich von der in MPI. Während in MPI Barrieren eine bestimmte Gruppe betreffen und synchronisierend wirken, warten in PVM 3 an einer Barriere alle η Prozesse, um die Funktion, evtl. unter einer „Wettlaufsituation" (engl.: race condition), aufzurufen. In MPI gibt es keine nichtblockierenden kollektiven Operationen. Solche müssen mit Hilfe von Thread-Erweiterungen implementiert werden. Bei den dynamischen Gruppen tritt, im Gegensatz zu den statischen Gruppen in MPI, folgendes Problem auf. Beim „Betreten" oder „Verlassen" einer dynamischen Gruppe können Wettlaufsituationen eintreten, da eine dynamische Gruppe eine verteilte Datenstruktur ist, die in einigen Prozessen veraltet sein kann, wenn sie nicht richtig synchronisiert wird. Unter der Voraussetzung, dass Gruppen mit der erforderlichen Sorgfalt gebildet werden und dann mit Barrieren synchronisiert wird, gibt uns Tabelle 9.3 ein genaues Vorbild dafür, wie von Gruppen aus PVM 3 auf MPI-Kommunikatoren abzubilden ist. Hierbei ist zu beachten, dass es zum Parameter t a g im PVM-Broadcast keinen entsprechenden Parameter in MPI gibt.
255
9.2 P V M 3 pvmfmcast(ntask,tids,tag,info)
MPI_BCAST(buf,count.datatype, root,comm,ierr)
pvmfbeast(grp,tag,info)
MPI_BCAST(buf,count,datatype, root,comm,ierr)
pvmfbarrier(grp,count,info)
MPI_BARRIER(comm,ierr)
Tabelle 9.3: Übersetzung von PVM 3 nach MPI: kollektive
9.2.4
Operationen
MPI-Gegenparts weiterer PVM-Funktionalitäten
Die Kommunikation zwischen heterogenen Maschinen wird in P V M 3 mit Funktionen zum Packen und Entpacken, die in jedem Prozess auf die aktuellen Sende- bzw. spezifizierten Empfangspuffer angewendet werden, unterstützt. Vor dem ersten Kommando zum Packen wird in der Regel p v m f i n i t s e n d Q ausgeführt (falls pvmfmkbufO und pvmffreebuf ( ) verwendet werden, ist p v m f i n i t s e n d ( ) nicht unbedingt notwendig). Mehrere hängige Sendeoperationen sind möglich. In M P I gibt es in jedem send einen Datentyp-Parameter, was der Verwendung verschiedener Routinen für verschiedene Datentypen in P V M entspricht. Die meisten Grunddatentypen der verwendeten Sprache, ob Fortran oder C, werden von P V M 3 unterstützt. Wenn Operationen für das Übertragen einer Zeichenkette von P V M nach M P I „übersetzt" werden, ist die Übereinstimmung zwischen pvm_pkstr() (in C) und pvmf pack ( ) mit STRING als what-Argument (in Fortran) und der Verwendung von MPI .CHARACTER insofern nicht exakt, als die Sendeoperation entscheiden muss, ob sie den Null-Abschluss mit sendet oder nicht, wenn die Länge des Zeichenfelds bestimmt wird. (Die Einzelheiten zur Problematik von Zeichenketten in Fortran gegenüber Feldern von Zeichen und Teilzeichenketten sind schwierig, wie im MPI-Standard [Mes94a, Kapitel 2 und 3] beschrieben.) Die Methoden von P V M und M P I zum Umgang mit Daten, die aus unterschiedlichen Basisdatentypen bestehen, weichen etwas voneinander ab. Die MPI-Routinen MPI_Pack und MPI_Unpack (siehe Abschnitt 5.2.5) wurden in M P I aufgenommen, um eine bessere Übereinstimmung mit dem Ansatz von P V M , Daten schrittweise in Puffer zu packen, herzustellen. Wegen der Herangehensweise in MPI, die Verwendung der Puffer eher explizit zu gestalten, muss ein Puffer in M P I vom Anwender direkt zur Verfügung gestellt werden, d. h. Puffer werden nicht in der Weise vom System verwaltet wie in PVM. Wir müssen noch auf einen feinen Unterschied zwischen P V M und M P I aufmerksam machen. M P I wurde so entworfen, dass keine Datenpufferung innerhalb des Systems gefordert wird. Damit kann M P I auf vielen Plattformen und mit hoher Effizienz implementiert werden, da die Pufferung häufig leistungsminderndes Kopieren zur Folge hat. Andererseits kann die Bereitstellung von Puffertechniken die Programmierung sehr vereinfachen. P V M ermöglicht dem Programmierer eine extensive (wenn auch nicht unbegrenzte) Pufferung. Deshalb besteht die beste Übereinstimmung der PVM-Senderoutinen (ζ. B. pvmf send) tatsächlich zu MPI_Bsend und nicht zu MPI-Send. Jedoch gibt es, wenn
256
9 MPI im Vergleich
überhaupt, nur in wenigen MPI-Implementierungen ein effizientes MPI_Bsend, da MPIProgrammierer angehalten sind, ihren Speicher sorgfältiger zu verwalten. So ist die Portierung eines PVM-Programms nach MPI zwar relativ leicht, wenn Kombinationen aus MPI_Pack, MPIJJnpack und MPI_Bsend verwendet werden. Eine Portierung mit hoher Qualität aber erfordert eine sorgfältige Prüfung der Verwendung von MPI_Bsend und deren Ersetzen durch MPI_Send, MPI_Isend oder andere MPI-Senderoutinen.
9.2.5
Funktionalitäten, die sich in MPI nicht wiederfinden
Hauptmerkmale von PVM 3, über die MPI nicht verfügt, sind die folgenden: • Eine interaktive Prozessverwaltung, wie sie im folgenden Abschnitt beschrieben wird, gibt es in MPI-1 nicht. MPI-2 hat Erweiterungen für eine dynamische Prozessverwaltung definiert; sie unterscheiden sich aber etwas von den PVMRoutinen. • In PVM gibt es Routinen zum dynamischen Hinzufügen und Entfernen von Hosts zu bzw. aus der virtuellen Maschine (zusätzlich zur dynamischen Prozessverwaltung). Pendants hierfür gibt es in MPI-2 nicht, da das MPI-Forum nach Betrachtung der Möglichkeiten empfand, dass dies Teil der Betriebssystemumgebung, außerhalb von MPI, sein sollte. • Zeitüberschreitungen (engl.: timeouts) in Kommunikationsroutinen.· sie helfen bei der Erstellung fehlertoleranter Anwendungen. Eine ausführlichere Darstellung der Unterschiede zwischen MPI und PVM kann man in [GL97c, GL99a] finden.
9.2.6
Starten von Prozessen
Mit PVM 3 geschriebene parallele Programme werden gestartet, indem die erste Instanz des Programms (oder das ausführbare Masterprogramm, falls Slaves und Master verschiedene ausführbare Programme sind) in der Unix-Shell gestartet wird, nachdem der PVM-Master-Daemon als normaler Unix-Prozess gestartet wurde. Dieser erste Prozess startet dann die anderen Prozesse durch Aufruf von pvmf spawn(); diese Routine kann über Architektur- oder Maschinennamen festlegen, wo der Prozess laufen soll. Die virtuelle Maschine wird für PVM 3 durch eine „Hostdatei" in einem spezifischen Format (siehe [BGJ + 93]) festgelegt, die an einer Standardstelle in der Verzeichnisstruktur des Nutzers abgelegt wird. Es ist zu beachten, dass die Routine pvmfmytidO im Masterprogramm nicht den PVM-Master-Daemon startet. Der Daemon muss vorher gestartet werden (evtl. als separates Programm, das interaktiv in einem separaten Fenster läuft); er startet dann der Reihe nach Slave-Daemons. In der PVM-Konsole kann man interaktiv arbeiten und mit jedem Daemon interagieren. Diese Eigenschaft ist vor allem dann sehr nützlich, wenn man Passwörter braucht, um Prozesse auf anderen Knoten zu starten oder wenn der Anwender das Verhalten von Prozessen beobachten möchte (siehe [BGJ + 93]). Es gibt auch einen Gruppenserver für die dynamische Prozessverwaltung, der gegebenenfalls automatisch gestartet wird. Im Gegensatz dazu spezifiziert
257
9.3 Wo kann man noch mehr erfahren?
MPI-1 keinerlei analoge portierbare Mechanismen für den Prozessanlauf oder eine dynamische Verwaltung. Die dynamischen Prozessmechanismen in MPI-2 sind so angelegt, dass sie eine skalierbare Bildung neuer, immer noch statischer, Interkommunikatoren unterstützen. Es gibt zwei Gründe, warum MPI-1 den Prozessanlauf und die Prozessverwaltung nicht spezifiziert: • Bei Parallelrechnern waren die herstellerspezifischen Mechanismen derzeit zu verschieden, um sie in eine einzige Form zu zwingen. • In Workstation-Netzwerken waren die Probleme des Prozessstarts und der Prozessverwaltung mit den Schwierigkeiten der Workstation-Verwaltung und der JobVerwaltung verflochten. Darauf zielt eine Reihe sowohl proprietärer als auch freier Systeme ab. (DQS ist ein Beispiel für ein solches System.) Derartige Systeme werden als Schnittstelle zu verschiedensten auf Netzwerken laufenden parallelen Programmiersystemen, so auch zu MPI, entwickelt. In MPI-2 sieht das MPI-Forum eine Methode zum Start neuer Prozesse und zur Verbindung existierender Prozesse vor, vermeidet aber eine direkte Behandlung vieler Punkte der Ressourcenverwaltung. Einige der Gründe hierfür kann man [GL97c] entnehmen.
9.2.7
Zu MPI und PVM ähnliche Werkzeuge
Einige Projekte befassten sich mit der Möglichkeit, MPI- und PVM-Programme zusammen arbeiten zu lassen, das heißt, dass innerhalb eines einzelnen Programms sowohl auf die PVM- als auch die MPI-Bibliothek zugegriffen werden kann. Ein Beispiel hierfür ist das Projekt von Fei Cheng von der Mississippi State University, das eine große Teilmenge von Routinen aus MPI und PVM im Rahmen einer einzelnen Implementierung bereitstellt [Che94], Das PVMPI-Projekt [FDG97] und dessen Nachfolger MPI.Connect [FLD98] verwendet PVM, um verschiedene MPI-Implementierungen miteinander zu verbinden und es zudem einer Anwendung zu ermöglichen, die PVM-Werkzeuge für Prozesskontrolle und Ressourcenverwaltung zu nutzen. PHIS [MC98] erlaubt es MPIund PVM-Anwendungen, untereinander zu kommunizieren.
9.3
Wo kann man noch mehr erfahren?
In diesem Kapitel haben wir uns mit zwei Message-Passing-Systemen befasst, sie miteinander verglichen und ihre Eigenheiten und Möglichkeiten denen von MPI gegenübergestellt. Für weitergehende Informationen zur Portierung dieser Systeme und zu Details der Fähigkeiten der anderen Systeme kann man verschiedene Quellen nutzen. So bieten die I n t e r n e t - D i s k u s s i o n s f o r e n comp.parallel,
comp.parallel.pvm
u n d comp.parallel,
mpi
Zugang zu einem jeweils sehr aktuellen Austausch über Message-Passing-Systeme und zugehörige Details zu praktischen Erfahrungen bei Portierungen. Im Anhang D werden weitere Hinweise gegeben, um über das Internet an Informationen zu MPI und zu den anderen in diesem Kapitel beschriebenen Systemen zu gelangen.
10
Über Message-Passing hinaus
In allen bisherigen Kapiteln haben wir MPI als Spezifikation (und Notation) einer Standardbibliothek zur Unterstützung des Message-Passing-Modells der Parallelverarbeitung behandelt. Das MPI-Forum interpretierte seine Aufgabe als allgemein (nicht ohne erhebliche Diskussionen) — so gehören zum „Message-Passing" in MPI nicht nur viele Varianten der Punkt-zu-Punkt-Kommunikation, sondern auch Kontexte, Gruppen, kollektive Operationen, Programmanalyse und Funktionen zur Ermittlung von Umgebungseigenschaften. Mit dem Wachsen von MPI zu einer umfangreichen Spezifikation erweiterte sich auch die Anwendungsbreite. Andererseits experimentieren Programmentwickler bereits mit Hilfsmitteln und Möglichkeiten, die über das Message-Passing-Modell hinausgehen. Hersteller beginnen damit, solche Mittel, die bisher nur in Forschungssystemen enthalten waren, als Teil ihrer Betriebssystemumgebung bereitzustellen. Das hierfür bekannteste Beispiel sind Threads, auf die wir weiter unten noch eingehen werden. Wie im Vorwort erwähnt, hat das MPI-Forum den MPI-Standard erweitert, um entsprechende Funktionalitäten aufzunehmen. Auch wenn wir [GLT99] unsere Diskussion zum MPI-2-Standard [GHLL+98, Mes97] überlassen wollen, ist es zweckmäßig, die Richtungen, die außerhalb des MessagePassing-Modells untersucht werden, hier kurz anzureißen. In diesem Kapitel richten wir die Aufmerksamkeit auf mehrere umfassende Themenkreise: • Trennung von der statischen Ausprägung von MPI_C0MM_W0RLD. Das Prozessmodell in MPI(-l), das wir in diesem Buch beschrieben haben, setzt voraus, dass die Anzahl der an der parallelen Berechnung beteiligten Prozesse unveränderlich festgelegt ist, bevor die Programmabarbeitung beginnt. Eine dynamischere Herangehensweise würde es einer Berechnung ermöglichen, während des Programmlaufs neue Prozesse zu erzeugen oder eine MPI-Kommunikation mit Prozessen aufzubauen, die bereits in einer separaten „Welt" MPI_C0MM_W0RLD laufen. • Verallgemeinerung des Prozessbegriffs. Das MPI-Prozessmodell kennzeichnet die MPI-Prozesse implizit als statische Prozesse mit voneinander getrennten Adressräumen. Das Thread-Modell postuliert „Leichtgewichtsprozesse" (engl.: lightweight process), die (innerhalb eines Prozesses) schnell erzeugt und entfernt werden können und sich einen gemeinsamen Adressraum teilen. Dieses Modell hat beträchtliche Auswirkungen auf das Message-Passing und auf die Leistungsfähigkeit paralleler Programme. • Verallgemeinerung des Message-Passing-Begriffs. Diese Idee besteht darin, Wege zu finden, mit denen ein Prozess Ereignisse bei einem anderen Prozess oder
260
10 Über Message-Passing hinaus Prozessor auslösen kann, ohne eine Nachricht zu senden, die der andere Prozess explizit empfangen muss, also etwa wie ein Fernsignal (engl.: remote signal) oder ein Kopieren im entfernten Speicher (engl.: remote memory copy). • Verallgemeinerung des Berechnungsbegriffs. Mit der fortschreitenden Vergrößerung der Parallelrechner entstand für viele Anwendungen, deren wesentliche Berechnungen auf skalierbaren Algorithmen basieren, eine Verschiebung der Engpässe von den Berechnungen und Interprozesskommunikationen hin zur Einund Ausgabe von Daten. Neue Parallelrechner sind zwar mit paralleler Hardware für das Dateisystem ausgestattet, aber was eine „parallele Datei" oder parallele Ein- und Ausgabe wirklich sein sollte, sind offene Forschungsfragen.
Wir wollen uns diesen Problemkreisen im Folgenden zuwenden.
10.1
Dynamische Prozessverwaltung
Die MPI-Spezifikation beschreibt die Effekte der Aufrufe von MPI-Subroutinen nach dem Aufruf von MPI_Init und vor dem Aufruf von MPI_Finalize durch jeden Prozess. Doch speziell dazu, wie Prozesse überhaupt entstehen, sagt die MPI-Spezifikation nichts aus. In aktuellen Systemen gibt es hierfür eine breite Vielfalt an Mechanismen: spezialisierte Programme und Skripte für den Programmanlauf, spezifische Daemons auf anderen Prozessoren, Remote Shells und andere Mechanismen. MPI behandelt diese Problematik nicht, weil sie recht kompliziert ist und weil, vor allem bei den Herstellern, kein Konsens zu gemeinsamen Lösungsmethoden besteht. Um einen Prozess zu starten, muss mindestens eine ausführbare Datei und ein Prozessor für deren Ausführung festgelegt werden. Wenn auch die Beispiele in diesem Buch typischerweise so gestaltet sind, dass von allen Prozessen das gleiche Programm (ausführbare Datei) ausgeführt wird, was man mit SPMD-Modell (single program multiple data) bezeichnet, wird dies von MPI nicht gefordert. Die Prozesse können durchaus unterschiedliche Programmcodes ausführen. Das Erzeugen neuer Prozesse ist aber nur der erste Teil dessen, was zu tun ist. Es müssen auch Kommunikationsverbindungen zwischen neuen und schon vorhandenen Prozessen aufgebaut werden. In der Terminologie von MPI muss also ein Kommunikator bereitgestellt werden, zu dem mindestens einer der „alten" und einer der „neuen" Prozesse gehört. Ein verwandtes Problem entsteht, wenn man zwischen zwei bereits laufenden MPI-Programmen eine Kommunikation herstellen möchte. Es sei bemerkt, dass MPI diese Problematik derzeit durch • Verzicht auf eine Definition, wie Prozesse zu erzeugen sind, • Verbot des Erzeugens oder Beendens von Prozessen nach einem Aufruf der Routine MPI_Init, ® Bereitstellung des Kommunikators MPI_C0MM_W0RLD und
10.2 Threads
261
• Verbot der Kommunikation zwischen Prozessen aus separaten MPI_C0MM_W0RLDKommunikatoren behandelt. Alle diese Beschränkungen werden im MPI-2-Standard aufgehoben. Interkommunikatoren, die in MPI-1 nur von geringer Bedeutung sind, spielen in MPI-2 eine größere Rolle. MPI-2 führt die MPI_Spawn-Funktion ein, die bezüglich eines Kommunikators kollektiv ist und einen neuen Interkommunikator zurückgibt (siehe Abschnitt 7.4 zur Definition eines Interkommunikators), der die neuen Prozesse als entfernte Gruppe enthält. Die neuen Prozesse besitzen ihren eigenen Kommunikator MPI_C0MM_W0RLD und können mit dem Aufruf von MPI_Comm_get-.parent auf den neuen Interkommunikator zugreifen. Innerhalb dieses Interkommunikators ist die Kommunikation zwischen Eltern und Kindern möglich. Der Interkommunikator kann mit MPI_Intercomm_merge in einen gewöhnlichen Kommunikator (Intrakommunikator) umgewandelt werden. Interkommunikatoren sind auch von Bedeutung, wenn zwei separate Kommunikatoren, die keine Teilmengen eines vorhandenen Kommunikators sind, verbunden werden sollen. Die paarweise kollektiven Aufrufe von MPIJVccept und MPI_Connect erzeugen einen Interkommunikator, wodurch zwei unabhängig voneinander gestartete MPI-Programme miteinander kommunizieren können.
10.2
Threads
Durch das gesamte Buch hindurch haben wir uns nur auf Prozesse als die Instanzen bezogen, die miteinander kommunizieren. Etwas vereinfacht kann ein Prozess als ein Adressraum mit einem aktuellen Zustand, bestehend aus Programmzähler, Registerbelegungen und einem Stack für Unterprogrammaufrufe, definiert werden. Dass ein Prozess nur über einen Programmzähler verfügt, bedeutet, dass er zu jedem Zeitpunkt nur eine Aktion ausführen kann. Einen Prozess dieser Art bezeichnen wir als einfädig (engl.: single threaded). Mehrere Prozesse können auf einem einzelnen Prozessor per Zeitteilverfahren (engl.: timesharing) ausgeführt werden, sodass der Prozessor in einem gewissen Sinn mehrere Aktivitäten gleichzeitig ausführt, was aber für den einzelnen Prozess nicht gilt. Eine bedeutende Verallgemeinerung des Prozessbegriffs führt mehrere Programmzähler (und Aufruf-Stacks) innerhalb eines Adressraums ein. Jeder Programmzähler und Aufruf-Stack definiert einen Thread. Das MPI-Forum war sich der Problematik von Threads sehr wohl bewusst und arbeitete sehr sorgfältig, um MPI Thread-sicher (engl.: thread safe) zu gestalten. Das heißt, es gibt nichts in der MPI-Schnittstelle (mit einer Ausnahme, siehe dazu Abschnitt 5.2.3), was die Arbeit mit MPI in einem Programm mit mehreren Threads stören könnte. Als MPI-1 definiert wurde, gab es jedoch noch keine klare Thread-Schnittstelle. Zum Beispiel hatten viele Hersteller ihre eigenen speziellen Thread-Schnittstellen. Zudem gab es zahlreiche experimentelle Systeme. Die Schnittstelle POSIX (pthreads) wurde gerade entwickelt. Deshalb entwarf man MPI-1 einfach für eine Zukunft, in der es Threads gibt, ohne eine spezifische Implementierung oder Anwendung vor Augen zu haben.
262
10 Über Message-Passing hinaus
Seitdem ist das Bild zu Threads etwas klarer geworden. Der Begriff des Threads ist inzwischen geläufiger, und viele Systeme unterstützen POSIX-Threads. Außerdem wurden einige Thread-sichere MPI-Implementierungen erstellt und Erfahrungen bei der Anwendung von Prozessen mit mehreren Threads in einem MPI-Rahmen gewonnen. Als Ergebnis entstanden einige zusätzliche Punktionen, definiert in MPI-2, um die Unterstützung von Thread-parallelen Programmen zu verbessern. Da diese Erweiterungen und konzeptionellen Klärungen Teil der Erarbeitung von MPI-2 waren, werden sie in unserem zugehörigen Band Using MPI-2 behandelt.
10.3
Ausführung aus der Ferne
Die Tatsache, dass ein Prozess auf den Speicher eines anderen Prozesses nur mit dessen ausdrücklicher Einwilligung zugreifen kann, ist die bezeichnendste Eigenschaft des Message-Passing-Modells: Das Senden hat ohne ein passendes Empfangen keine Wirkung. Diese Trennung der Prozesse verbessert die Modularität und erleichtert wohl auch das Entdecken von Fehlern in Message-Passing-Programmen gegenüber Programmen, in denen Prozesse direkt auf den Speicher eines anderen Prozesses zugreifen können. Auf der anderen Seite besteht aber mitunter der Wunsch, ein Ereignis bei einem anderen Prozessor auszulösen (typisch hierfür ist ein Speicherzugriff), ohne eine Mitwirkung des anderen Prozesses zu benötigen. In einem Rechner mit echtem gemeinsamen Speicher ist der Vorgang des Zugriffs auf fernen Speicher transparent. Ein Prozess muss also nicht wissen, welche Zugriffe „lokal" und welche „fern" sind. Eine der ersten Erweiterungen für mit Message-Passing arbeitende Rechner mit verteiltem Speicher war das Empfangen per Interrupt (interrupt-driven receive), das durch Intel auf den Weg gebracht wurde. Bei dieser Idee ist eine Unterbrechungsbehandlung mit einem Nachrichtenetikett verknüpft: Trifft eine passende Nachricht ein, wird die vom Anwender definierte Unterbrechungsbehandlung aufgerufen. Wurde die Unterbrechung bedient, kehrt die Steuerung zu der Stelle zurück, an der unterbrochen wurde. Auf diese Weise können die Methoden aus Abschnitt 7.1.3 angewendet werden, ohne dass ein Polling erforderlich wäre. Der Quellcode für das Empfangen und Antworten auf eine Anfrage zu einem Zählerwert oder allgemein für einen Speicherzugriff kann in einer Unterbrechungsbehandlung (engl.: interrupt handler) platziert werden. Man kann sich das auch so vorstellen, als würde der Quellcode zur Unter brechungsbehandlung in einem separaten Threads ausgeführt. Einen besser durchdachten Mechanismus bieten aktive Nachrichten, beschrieben in [vECGS92], Aktive Nachrichten bildeten den Kommunikationsmechanismus der TMC CM-5 auf der unteren Ebene. Eine aktive Nachricht veranlasst durch ihr Eintreffen die Ausführung eines bestimmten Unterprogramms beim Zielprozess. Mitunter werden aktive Nachrichten benutzt, um ein Kopieren im fernen Speicher anzustoßen, wodurch Daten aus dem Adressraum eines Prozesses zum Adressraum eines anderen Prozesses übertragen werden, woran aber nur ein Prozess aktiv beteiligt ist. Bei der CM-5 wurde dies unter der Bedingung möglich, dass jeder Prozess das exakt gleiche Programm ausführte, sodass sich die Adressen auf allen Prozessoren auf dieselben Objekte bezogen. Da aktive Nachrichten die Ausführung von Programmcode auf dem Zielprozessor veranlassen, können sie, sofern ein Kontextwechsel erforderlich ist, teuer sein, im Besonderen
10.4 Parallele Ein- und Ausgabe
263
bei Parallelrechnern mit handelsüblichen RISC-Prozessoren. Eine andere Komplikation, der ein Programmierer gegenübersteht, ist die Notwendigkeit, die aktiven Nachrichten zu koordinieren. In datenparallelen Anwendungen können aktive Nachrichten jedoch ein sehr attraktiver Ansatz sein. Das MPI-Forum hat Operationen MPI_Put und MPI_Get in der Weise definiert, dass sie ohne Hardware-Unterstützung für gemeinsamen Speicher implementiert werden können, also sogar für heterogene Netzwerke. Zu Details verweisen wir auf [Mes97, GHLL + 98, GLT99].
10.4
Parallele Ein- und Ausgabe
Unter paralleler Ein- und Ausgabe (E/A) verstehen wir die Möglichkeit des Zugriffs auf externe Geräte durch eine Menge von Prozessen. Üblicherweise handelt es sich dabei um eine externe Festplatte mit einem Dateisystem oder auch um Bandsysteme, graphische Ausgabegeräte und Echtzeitsignalquellen. Viele dieser externen Geräte arbeiten selbst parallel, oft aus demselben Grund, aus dem ein Parallelrechner verwendet wird. So erfordert zum Beispiel die Geschwindigkeit, mit der Daten auf bzw. von einer sich drehenden Platte geschrieben bzw. gelesen werden können, die Verwendung einer ganzen Anordnung von Platten (disk array), um Übertragungsgeschwindigkeiten von wesentlich mehr als 10 M B / s zu erreichen. Diese Situation führt zu einem die Dateien betreffenden Problem. Viele Programmierer (und das Unix-Betriebssystem) fassen Dateien als Byteströme auf, wodurch eine parallele Verarbeitung schwierig wird. Deshalb wurden in zahlreichen aktuellen Untersuchungen vielfältige Layouts für Dateien betrachtet, die durch parallele Objekte organisiert werden. [Cro89, OD89, dRC94, GGL93]. Existierende E/A-Systeme für Parallelrechner konzentrierten sich vor allem auf die Bereitstellung eines systemnahen Zugriffs auf das Dateisystem. Anwender mussten häufig über detaillierte Kenntnisse zum logischen Aufbau einer Festplatte und zu Lese/Schreib-Strategien beim Caching verfügen, um akzeptable Leistungen erzielen zu können. Zum Glück sind die MPI-Datentypen ein leistungsstarkes Konzept, um Datenanordnungen zu beschreiben. Die MPI-Kommunikatoren bieten zudem effektive Mittel zur Organisation von Prozessen, die für Ein- und Ausgabe zuständig sind. Die Threadsicherheit in MPI hilft bei der Bereitstellung von nichtblockierenden (oft asynchron genannten) E/A-Operationen. Wie MPI angemessen zu erweitern ist (dazu gehört insbesondere die Einführung neuer Datentypen und Konstruktoren der Datentypen) oder wie mit der Inhomogenität beim Zugriff auf externe Geräte umzugehen ist, sind anspruchsvolle Forschungsfragen. Die in MPI-2 gewählte Methodik besteht in der Ausnutzung der Analogie zwischen Message-Passing und E / A (das Schreiben einer Datei ist dem Senden einer Nachricht recht ähnlich). Die parallele Ein- und Ausgabe ist ein Hauptbestandteil des MPI-2-Standards. Es nutzt MPI-Datentypen und Kommunikatoren und bietet nichtblockierende und kollektive MPI ähnliche Operationen sowie andere geeignete Ansätze, beispielsweise ein portierbares Dateiformat.
264
10.5
10 Über Message-Passing hinaus
MPI-2
Das MPI-Forum schränkte sein Entscheidungsfeld zum Message-Passing-Modell ganz bewusst ein, um zum einen die MPI-Spezifikation schnell fertigstellen zu können und weil es zum anderen bereits sehr viele Erfahrungen mit dem Message-Passing-Modell gab. Die erste Auflage des Buchs schloss mit dem Abschnitt „Wird es eine Version MPI-2 geben?". Es gibt sie. In den Jahren 1995-1997 kam das MPI-Forum wieder zusammen und debattierte Standards zur dynamischen Prozessverwaltung, zu paralleler E / A und zu Operationen im entfernten Speicher, die dann auch in den MPI-Standard aufgenommen wurden. Desgleichen beriet das Forum über eine vollständige ThreadSchnittstelle, aktive Nachrichten und durch Unterbrechungen veranlasstes Empfangen, was allerdings nicht Bestandteil des Standards wurde (obwohl nutzerdefinierte Anfragen einige verwandte Eigenschaften besitzen). Einzelheiten zu den vom MPI-2-Forum erarbeiteten Erweiterungen von M P I kann man im Standard selbst [Mes97], in der Buchversion des Standards [GHLL + 98] oder in dem zu diesem Buch gehörenden Band Using MPI-2 [GLT99] finden.
10.6
Wird es MPI-3 geben?
MPI-2 brachte eine Menge an neuen, anspruchsvollen und ausgefeilten Technologien in M P I ein. Es wird wohl einige Zeit dauern, bis mit entsprechenden Anwendungen genug Erfahrungen gesammelt wurden, um feststellen zu können, welche Lücken beim nächsten Mal zu schließen sind. Themenstellungen, die sich abzeichnen, sind neue Sprachanbindungen (z.B. Java), Erweiterungen von MPI für Echtzeitberechnungen und Weitverkehrsnetzwerke, die Zusammenarbeit von MPI-Anwendungen untereinander und das Zusammenspiel von M P I mit neu entstehenden Programmiermodellen unter Verwendung gemeinsamen Speichers.
10.7
Schlusswort
In diesem Buch haben wir die Arbeit mit M P I an einer großen Vielfalt paralleler Programme veranschaulicht, angefangen bei elementaren Beispielen bis hin zu Bibliotheken und aufwändigen Anwendungen. Dabei sind wir auf einige subtile Aspekte des MessagePassing-Zugangs gestoßen und konnten sehen, wie M P I mit solchen Herausforderungen umgeht. Wir haben Teile einer parallelen Programmierumgebung eingeführt, die die Arbeit mit M P I fördert. Auch wurden Anleitungen sowohl für die Implementierung von M P I als auch für das Portieren vorhandener Programme nach M P I gegeben. M P I hat das Potential für ein deutliches Wachstum im Bereich paralleler Software. Gerade hier lag das Haupthindernis dafür, dass sich die Technologie des parallelen Rechnens ausreichend verbreiten konnte. Die Vereinigung von Effizienz, Portierbarkeit und Funktionalität, die mit M P I sowohl für Parallelrechner als auch Rechnernetzwerke möglich ist, bildet die Grundlage der Parallelverarbeitung für die nächste Zeit.
Glossar In diesem Glossar werden die im Buch am häufigsten verwendeten Fachbegriffe erläutert. Auch wenn keinesfalls vollständig, soll es dazu dienen, eine Reihe der wichtigsten Begriffsdefinitionen an einer Stelle zusammenzufassen. Aktive Nachrichten Eine aktive Nachricht ist in der Regel eine kurze Nachricht, mit der die Ausführung eines bestimmten Codestücks beim empfangenden Prozess ausgelöst wird. Dabei werden die Nutzdaten der aktiven Nachricht an diesen Programmcode übermittelt. Ahnlich wie bei den Unterprogrammfernaufrufen der meisten Unix-Systeme ist das Paradigma der aktiven Nachrichten ein Mittel sowohl zur Implementierung von Protokollen auf höheren Schichten, so wie MPI, als auch zur direkten Programmierung in bestimmten Situationen. Aktive Nachrichten bilden eine Erweiterung des Message-Passing-Modells, so wie man es sich am Beispiel von MPI vorstellen kann. Anfrageobjekt Ein Anfrageobjekt (engl.: request object) wird durch MPI als Antwort auf eine Operation zurückgegeben, die erst nach einer nachträglichen „Warteoperation" abgeschlossen wird. Ein gutes Beispiel hierfür ist MPI.Irecv. Das Anfrageobjekt wird in MPI_Wait und verwandten Aufrufen verwendet, um festzustellen, ob die eingeleitete Routine abgeschlossen worden ist. Anwenderdefinierte Topologie Wenn die in MPI verfügbaren virtuellen Topologien dem Anwender nicht ausreichen, so kann er recht leicht eigene Topologien aufbauen. Solche Topologien orientieren sich meist an der jeweiligen Anwendung. Anwendungstopologie Anwendungstopologien sind die den parallelen Algorithmen innewohnenden Prozessverknüpfungen. MPI unterstützt die Abbildung von Anwendungstopologien auf virtuelle Topologien sowie die Abbildung von virtuellen Topologien auf physikalische Hardware-Topologien. Asynchrone Kommunikation Der Begriff der asynchronen Kommunikation wird oft als Synonym für die nichtblockierende Kommunikation verwendet. Dabei handelt es sich allgemein um eine Kommunikation, bei der sich Sender und Empfänger gegenseitig keine Bedingungen hinsichtlich ihrer Terminierung stellen und die sich soweit wie möglich auch mit den Berechnungen überlappen sollte. Der Begriff der asynchronen Kommunikation wird in MPI nicht verwendet. Attribute Attribute sind in MPI ganze Zahlen (Fortran) oder Zeiger (C), die einem Kommunikator mittels eines Schlüsselwerts (engl.: key value) zugewiesen werden können.
266
Glossar
Attribut-Caching Hierunter versteht man den Vorgang, einem Kommunikator in MPI Attribute zuzuweisen (siehe Attribute). Bandbreite Die Bandbreite einer Nachrichtenübertragung ist der Kehrwert der Zeit, die erforderlich ist, um ein Byte zu übertragen (die inkrementellen Kosten pro Byte). Blockierende Kommunikation Eine Kommunikation wird als blockierend bezeichnet, wenn der Aufruf erst dann terminiert, wenn der Puffer (im Fall des Sendens) wieder neu verwendet oder (im Fall des Empfangens) gelesen werden kann (siehe Blockierendes Senden und Blockierendes Empfangen). Blockierendes Empfangen Eine Empfangsoperation, die blockiert, bis der Datenpuffer die ausgewählte Nachricht enthält (siehe Nichtblockierendes Empfangen). Blockierendes Senden Eine Sendeoperation, die blockiert, bis der Datenpuffer wieder verwendet werden kann. Hierbei ist es nicht zwingend, dass der Adressat das Empfangen gestartet hat. Die Details des Blockierens hängen von der Implementierung und von der Größe der Puffer ab, die das System wählt bzw. über die es verfügen kann. Datentypen Die MPI-Objekte, die ein allgemeines Einsammeln und Verteilen von komplexen Datenstrukturen unterstützen, werden als Datentypen bezeichnet. Die einfachen Datentypen sind Bestandteile von MPI, vom Anwender definierbare werden abgeleitete (oder erweiterte) Datentypen genannt. Deadlock siehe Verklemmung Ereignis Eine Protokollierungseinheit (engl.: logging unit) bei der Programmanalyse. Es wird angenommen, dass Ereignisse keine zeitliche Dauer haben. Event siehe Ereignis Graph-Topologie Eine Graph-Topologie ist eine virtuelle Topologie, die es erlaubt, allgemeine Verbindungen zwischen Prozessen abzubilden. Prozesse werden als Knoten des Graphen dargestellt. Gruppe Eine Gruppe (oder Prozessgruppe) ist eine geordnete Menge von Prozessen. Jeder Prozess der Gruppe hat einen eindeutigen Rang (von 0 bis zur um eins verminderten Anzahl der Prozesse). Gruppensicherheit Gruppensicherheit ist die Art des Schutzes beim Message-Passing, die durch Kommunikationskontexte in MPI erreicht wird. Jeder Kommunikator ist einer Gruppe zugeordnet, und diese Gruppe kommuniziert innerhalb eines spezifischen Kontexts unabhängig von allen anderen Kommunikationen dieser Gruppe (mit anderen Kommunikatoren) und unabhängig von allen anderen Gruppen. Heterogene Verarbeitung Eine Umgebung mit unterschiedlichen Datenformaten und/oder unterschiedlichen Verarbeitungseigenschaften wird als heterogen betrachtet.
Glossar
267
Interkommunikator Interkommunikatoren unterstützen die Umsetzung der Konzepte sowohl „lokaler" als auch „entfernter" Gruppen. Sie sind nutzbar in Anwendungen mit Punkt-zu-Punkt-Kommunikationen in Client-Server-Umgebungen. Intrakommunikator Intrakommunikatoren (gewöhnlich nur Kommunikator genannt) sind nicht so allgemein wie Interkommunikatoren, da sie nur eine „lokale" Sicht auf Prozesse haben. Sie unterstützen aber sowohl Punkt-zu-Punkt- als auch kollektive Kommunikationen. Kartesische Topologie Eine kartesische Topologie ist eine virtuelle Topologie, mit der eine reguläre Abbildung von Prozessen in einen N-dimensionalen Namensraum unterstützt wird. So gibt es zum Beispiel zwei- und dreidimensionale logische Prozesstopologien, die in Berechnungen der linearen Algebra und zur Lösung partieller Differentialgleichungen Anwendung finden. K e y Value siehe Schlüsselwert K n o t e n Die Bezeichnung „Knoten" wird in diesem Buch in zwei verschiedenen Bedeutungen verwendet. Zum einen steht sie als Synonym für einen Prozessor, zum anderen für die Knoten in einem Graph, einer Standarddatenstruktur in der Informatik. Kollektive K o m m u n i k a t i o n Kollektive Kommunikationen umfassen Operationen wie „Broadcast" (MPI_Bcast) und „Allreduce" (MPI Jlllreduce), an denen die gesamte Gruppe des Kommunikators beteiligt ist, also nicht nur zwei Gruppenmitglieder wie in einer Punkt-zu-Punkt-Kommunikation. K o m m u n i k a t i o n s m o d u s MPI unterstützt folgende Kommunikationsarten für die Punkt-zu-Punkt-Kommunikation: Puffer-, Ready-, Standard- und synchroner Modus. Kommunikationsprozessor Allgemein ist ein Kommunikationsprozessor die Hardwarekomponente, die für den lokalen Zugriff eines Prozessors zum Verbindungsnetzwerk des Parallelrechners zuständig ist. Derartige Prozessoren werden auch als „Routerchips" oder in manchen Systemen als Netzwerk-Routing-Chips (engl.: mesh-routing chip, MRC) bezeichnet. Mit der fortschreitenden Entwicklung erhielt der Kommunikationsprozessor und seine Verbindungskomponenten (engl.: glue chips) über den einfachen Transfer zur und von der CPU hinaus weitere Fähigkeiten, wie zum Beispiel Operationen des Typs „Gather/Scatter" und andere Hochgeschwindigkeitszugriffe auf den Speicher ohne direkte Beteiligung der CPU. Kommunikator Ein Kommunikator besteht aus einer Gruppe von Prozessen, verbunden mit der Idee eines sicheren Kommunikationskontextes. In MPI gibt es zwei Typen von Kommunikatoren: /nirakommuriikatoren (das ist der Standard) und /nierkommunikatoren. Kommunikatoren garantieren, dass die Kommunikation getrennt von den anderen Kommunikationen im System abläuft und dass die kollektive Kommunikation unabhängig von der Punkt-zu-Punkt-Kommunikation arbeitet.
268
Glossar
Kontext Ein Kontext in MPI ist keine für den Anwender sichtbare Größe, sondern der interne Mechanismus, mit dem ein Kommunikator eine Gruppe mit einem sicheren Kommunikationsraum ausstattet. Damit wird die Trennung einer Kommunikation von allen anderen Kommunikationen im System sowie die Trennung der Punktzu-Punkt-Kommunikationen von den kollektiven Kommunikationen einer Gruppe eines Kommunikators gesichert. Langlebige Anfragen Langlebige Anfragen (engl.: persistent requests) werden eingesetzt, wenn mehrere Kommunikationen gestartet oder beendet werden sollen oder auf deren Ende getestet werden soll, um so die Kosten zu verringern und dem Anwender die Möglichkeit zu geben, eine bessere Leistung zu erzielen. Latenz Latenz im Rahmen eines Message-Passing-Systems bezieht sich auf den Aufwand zum Aufbau einer Nachrichtenübertragung. Sie steht für die Kosten zur „Vorbereitung" (engl.: startup) einer Übertragung, also für den Aufwand, der erforderlich ist, bevor das erste Byte gesendet werden kann. Hohe Latenz bedeutet, dass Nachrichten vor dem eigentlichen Versenden hohe Kosten verursachen, lange Nachrichten aber relativ kostengünstig sein können, wenn die Ubertragungszeit pro Byte klein (bzw. die Bandbreite groß) ist. Message-Passing-Interface-Forum (MPIF) Das MPI-Forum fand sich zusammen, um, unabhängig von Standardisierungsorganisationen, einen allgemeinen Standard für das Message-Passing zu erarbeiten. Die Gruppe bestand aus Vertretern von Herstellern, Universitäten und nationalen Forschungseinrichtungen. Im Forum waren Wissenschaftler sowohl aus den USA als auch aus Europa vereint. M P E MPE ist eine Zusatzsoftware für Mehrprozessumgebungen, die mit diesem Buch zur Verfügung gestellt wird. Mit ihren Hilfsmitteln, vor allem zur graphischen Veranschaulichung und zur Protokollierung eines Programmablaufs, kann die MessagePassing-Programmierung erleichtert bzw. verbessert werden. Multicomputer Eine Bezeichnung, die mitunter für einen Parallelrechner verwendet wird, dessen Prozessoren über eigenen privaten Speicher verfügen und über ein Netzwerk miteinander verbunden sind. Nichtblockierende Kommunikation Bei einer nichtblockierenden Kommunikation wartet die Kommunikationsroutine nicht, bis die Kommunikation abgeschlossen wird (siehe Nichtblockierendes Senden und Nichtblockierendes Empfangen). Nichtblockierendes Empfangen Eine Empfangsoperation, die terminieren kann, bevor der Datenpuffer eine eingetroffene Nachricht enthält. Diese Art des Empfangens wird häufig genutzt, um Platz und Anordnung des Datenpuffers für die Message-Passing-Hardware bereit zu stellen, um so eine bessere Leistung zu ermöglichen. Nichtblockierendes Senden Die Sendeoperation kann beendet werden, bevor auf den Datenpuffer für eine erneute Benutzung zugegriffen werden kann. Die Idee besteht darin, die Senderoutine unverzüglich abzuschliessen. Die nichtblockierende Sendeoperation kann abgeschlossen werden, auch wenn noch keine passende Empfangsoperation gestartet wurde.
269
Glossar
Objektbasierte Bibliothek Bibliotheken (so wie in Kapitel 6 definiert) werden als objektbasiert bezeichnet, wenn sie sich hierarchische Datenstrukturen, einheitliche Aufrufsequenzen und das Verbergen von Informationen (engl.: information hiding) zunutze machen, jedoch nicht das Konzept der Vererbung verwenden, um komplexen Quellcode auf Basis von einfachem Quellcode zu erstellen. Die entstehenden Programme sind funktionalen Dekompositionen recht ähnlich. Obwohl die Arbeit mit Datenobjekten eine große Hilfe hinsichtlich Aufbau und Verständnis ist, wird das Potential zu Optimierungen und Wiederverwendung von Programmcode nicht voll ausgenutzt. Objektorientierte Bibliothek Objektorientierte Bibliotheken (angesprochen in Kapitel 6) gehen über objektbasierte Bibliotheken hinaus, indem sie das Konzept der Vererbung nutzen. Durch Vererbung ist es möglich, komplexe Zusammenhänge aus einfachen aufzubauen. Aus einfacheren Klassenstrukturen werden komplexere gebildet, was einerseits zur Wiederverwendung von Programmcode führt und andererseits, was noch wichtiger ist, Optimierungen für die Art von Bibliotheken, wie sie in diesem Buch betrachtet werden, erlaubt. Paarweise Reihung Die Eigenschaft der „paarweisen Reihung" in einem MessagePassing-System ist wesentlich für das Message-Passing-Programmierparadigma von M P I und dessen Vorgängersystemen. Diese Eigenschaft garantiert, dass zwei Nachrichten, die von ein und demselben Prozess an einen anderen Prozess gesendet werden, auch in der gleichen Reihenfolge, in der sie gesendet wurden, eintreffen. Mit der Einführung von „Kontexten" wird diese Forderung dahingehend abgeschwächt, dass dies nur bezüglich desselben Kommunikators gelten muss. M P I schwächt diese Forderung noch weiter ab, so dass eine Änderung der Reihenfolge bei unterschiedlichen Etiketten bezüglich desselben Prozesspaares zulässig ist. Parallele Bibliothek Eine Bibliothek ist eine „Kapselung" eines Algorithmus derart, dass eine mehrfache Nutzung in einer einzelnen Anwendung und/oder eine Wiederverwendung in mehreren Anwendungen recht bequem und komfortabel möglich ist. Entwickler von Bibliotheken für Parallelrechner sind in der Lage, die Bibliotheken robust zu schreiben, da M P I Funktionen speziell dafür bereit stellt, dass Bibliotheken ihre eigenen Kommunikationen von denen des Anwenders und anderer Bibliotheken isolieren können. Bibliotheken sind eine der Hauptstützen beim wissenschaftlichen Rechnen in vielen Anwendungsgebieten, aber ihre Komplexität hat bisher einen breiten Einsatz in der Welt der Parallelverarbeitung verhindert. M P I wird die Entwicklung eines beträchtlichen Stamms an Bibliothekscode befördern, da M P I eine komfortable und portierbare Grundlage für robuste Bibliotheken bietet. Persistent Requests siehe Langlebige
Anfragen
Physikalische Topologie Die physikalische Topologie ist gegeben durch die Topologie des Verbindungsnetzwerks des Parallelrechners, sie kann ζ. B. ein Gitter oder ein Hypercube sein. Portabilität Unter Portabilität versteht man, wie gut ein Programm von einer Umgebung in eine andere übertragen werden kann. Der Grad der Portabilität gibt
270
Glossar den Aufwand an, der aufgebracht werden muss, um die Lauffähigkeit des Programms in der neuen Umgebung zu erreichen. Hochwertige Portabilität (auch Leistungsportabilität oder Transportabilität) bedeutet, dass nach der Portierung eine angemessene Leistung erhalten bleibt. MPI wurde so entworfen, dass eine hohe Leistungsfähigkeit auf vielen unterschiedlichen Plattformen möglich ist.
Prozess Ein Prozess ist die kleinste aufrufbare Berechnungseinheit im MPI-Modell. Ein Prozess ist einem Prozessor (oder Knoten) zugeordnet. MPI beschäftigt sich nicht mit der Entstehung, dem Beenden oder anderweitiger Verwaltung von Prozessen. Prozessor Ein Prozessor besteht (vereinfacht) aus CPU, Speicher und E/A-Ressourcen als Teil eines Parallelrechners (oder ist ein Rechner eines Workstation-Clusters). Ein Prozessor ermöglicht die Ausführung eines oder mehrerer Prozesse des MPIModells. Verbindungen zwischen Prozessen und Prozessoren stellt MPI aber nur eingeschränkt in Routinen zur Umgebungsabfrage und in zwei Abbildungsroutinen für Topologien her. Puffernde Kommunikation Eine Kommunikation, bei der die Sendeoperation (sie kann blockierend oder nichtblockierend sein) vom Anwender bereitgestellten Pufferplatz verwenden kann, um sicherzustellen, dass das Senden nicht blockiert, weil auf Speicherplatz für die Nachricht des Anwenders gewartet werden muss. Diese Art der Kommunikation wird vor allem mit blockierendem Senden angewendet, weil so verhindert wird, dass die zugehörige Empfangsroutine gestartet sein muss, damit das blockierende puffernde Senden terminieren kann. Die Pufferung kann allerdings mit zusätzlichem Kopieraufwand für die Daten verbunden sein, was sich nachteilig auf die Leistungsfähigkeit auswirken kann. Pufferung Die Pufferung bezieht sich auf den Umfang oder den Vorgang des Kopierens oder die Größe des zur Verfügung gestellten Speichers, mit der das System im Rahmen seines Übertragungsprotokolls arbeitet. Im Allgemeinen hilft das Puffern, Verklemmungen zu vermeiden, wodurch das Programmieren in speziellen Fällen zwar vereinfacht wird, Portierbarkeit und Vorhersagbarkeit zur Leistungsfähigkeit aber leiden. Punkt-zu-Punkt-Kommunikation Eine Nachrichtenübertragung zwischen zwei Mitgliedern eines Kommunikators (allgemein ein Aufruf vom Typ Senden-Empfangen). Quiescence siehe Stille Race Condition siehe
Wettlaufsituation
Ready-Kommunikationsmodus Im Kommunikationsmodus „Ready" (auch als Ready Send bekannt) kann das System davon ausgehen, dass der Empfänger die Empfangsoperation vor dem Starten der Sendeoperation angestoßen hat. Ist zum Zeitpunkt, an dem das Senden erfolgt, das Empfangen noch nicht eingeleitet worden, verläuft das Senden fehlerhaft mit Undefiniertem Verhalten (die Fehlererkennung in MPI wird zwar empfohlen, aber nicht gefordert). Der Readymodus des Sendens kann, wie oben angegeben, blockierend oder nichtblockierend sein. Reduce siehe Reduktion
Glossar
271
Reduktion Eine Operation, die mehrere Datenelemente zu einem Ergebnis kombiniert. Request-Objekt siehe Anfrageobjekt Schlüsselwert Ein Schlüsselwert (engl.: key value) ist ein von MPI definierter ganzzahliger Wert, der benutzt wird, um ein bestimmtes Attribut, das zwischenzuspeichern ist, zu bezeichnen. Diese Bezeichnungen sind lokal bezüglich der Prozesse, sodass alle Kommunikatoren eines Prozesses in einfacher Weise gleiche Attribute gemeinsam verwenden können. Sichere Programme In diesem Buch und im MPI-Standard wird auf „sichere" und „unsichere" Programme, unabhängig vom Konzept der Threadsicherheit, verwiesen. Ein Programm ist sicher, wenn es für jede Art der Pufferung korrekt ausgeführt wird. Standardmodus Der Standardkommunikationsmodus in MPI kommt der heute üblichen Praxis bei der Kommunikation am nächsten. Status-Objekt Das Status-Objekt ist das Mittel von MPI, um Informationen zu einer Operation, vor allem zur Empfangsoperation, zu erhalten. Um Threadsicherheit zu gewährleisten, wird dieses Objekt zu dem Zeitpunkt zurückgegeben, an dem das Empfangen abgeschlossen wird. Aus Gründen der Effizienz ist ein Status-Objekt in Fortran ein Feld und in C eine Struktur. Stille Diese Eigenschaft (engl.: quiescence) fordert von den Programmierern die Garantie, dass während einer bestimmten Ausführungsphase eine Gruppe von Prozessen durch keine Kommunikation von außen beeinflusst werden kann, um einer gegebenen Operation die korrekte Ausführung zu sichern. Die Stille-Eigenschaft fordert, dass keine noch nicht beendete Punkt-zu-Punkt-Kommunikation aktiv ist und dass zwischen zwei Synchronisationspunkten einer Gruppe keine anderen Prozesse Nachrichten senden. Diese Strategie wird in MPI größtenteils nicht gefordert — eine wichtige Ausnahme ist hier die Forderung einer Stille-Garantie für zwei „Führungsprozesse" in den „Partnerkommunikatoren" während der Erzeugung eines Inter kommunikators. Synchroner Kommunikationsmodus Im synchronen Kommunikationsmodus kann der Sender seine Operation nicht abschließen, bevor die zugehörige Empfangsoperation beim Zielprozess gestartet wurde. Synchrones Senden kann blockierend oder nichtblockierend sein. Dies hängt davon ab, ob der Datenpuffer beim Sender wieder verwendet werden kann, wenn der Sendeaufruf die Kontrolle an den Prozess zurück gibt. Synchronisation Eine Synchronisation ist eine Operation, die alle Prozesse einer Gruppe zwingt, einen kritischen Bereich im Programm zu durchlaufen, bevor auch nur einer von ihnen seine Arbeit fortsetzen kann. Viele kollektive MPI-Operationen können potentiell synchronisierend wirken, aber nicht für alle wird eine Implementierung mit dieser Eigenschaft gefordert (außer MPIJ3arrier, das speziell zur Synchronisation vorgesehen ist). Siehe auch Synchroner Kommunikationsmodus.
272
Glossar
Thread Ein Thread ist die atomare Ausführungseinheit in einem MPI-Prozess. Jeder MPI-Prozess besitzt einen Haupt-Thread und kann über weitere Threads verfügen, sofern eine Thread-sichere Programmierumgebung und ein Threadpaket genutzt werden können. Die MPI-Schnittstelle ist zwar Thread-sicher, bindet MPI aber nicht spezifisch an ein Threadparadigma oder einen Threadstandard. MPI-Implementierungen können Thread-sicher sein oder auch nicht Thread-sicher sein. Das genaue Niveau der Threadsicherheit, die eine MPI-Implementierung bietet, kann in einem MPI-Programm mit Hilfe von Funktionen, die neu in MPI-2 eingeführt wurden, festgestellt werden. Threadsicherheit Die Threadsicherheit ist die Qualität der Softwaresemantik, die garantiert, dass voneinander unabhängig ausgeführte Threads sich beim Zugriff auf Daten, die einem anderen Thread zugeordnet sind, gegenseitig nicht stören. Eine Implementierung von Threadsicherheit erfordert, dass auf einen globalen Zustand soweit wie möglich verzichtet wird und dass der „Rest" des globalen Zustande, der nicht eliminiert werden kann, explizit verwaltet wird. MPI wurde so entworfen, dass Threadsicherheit möglich ist. Topologie siehe Virtuelle Topologie Typsignatur Die Typsignatur ist das MPI-Konzept, das die Folge von Basisdatentypen (ohne die Verschiebungen) aus einer Typtabelle extrahiert. T y p t a b e l l e Eine Typtabelle ist eine Folge von Paaren, die jeweils aus einem MPIBasisdatentyp und einer Verschiebung bestehen. Eine solche Folge definiert einen abgeleiteten Datentyp. U n t e r g r u p p e MPI arbeitet mit Kommunikatoren, die auf Gruppen von Prozessen basieren. Kommunikatoren, die aus Untergruppen der „Weltgruppe" bestehen, sind ebenso möglich. Untergruppen sind genauso verwendbar wie die Gruppen, aus denen sie gebildet wurden. Hierbei ist keine Unterscheidung notwendig, außer der, dass es bei der Darstellung von Algorithmen zweckmäßig ist, bestimmte Gruppen als „Untergruppen" und bestimmte Kommunikatoren als „Unterkommunikatoren" zu bezeichnen. Verbindungsnetzwerk Die Hardware, die Prozessoren zur Bildung eines Parallelrechners miteinander verbindet. V e r k l e m m u n g Eine Verklemmung (engl.: deadlock) ist ein Zustand in der Programmausführung, in dem die Berechnung nicht weitergeführt werden kann, weil ein („toter") Punkt erreicht wurde, an dem zwei oder mehr Prozesse direkt oder indirekt gegenseitig auf ein Resultat warten müssen, bevor sie jeweils weiter arbeiten können. Ein gutes Beispiel hierfür sind zwei Prozesse, die versuchen, gegenseitig voneinander zu empfangen und dann gegenseitig im blockierenden Kommunikationsmodus zu senden. Virtual Shared M e m o r y siehe Virtuell gemeinsamer
Speicher
Virtuelle Topologie Virtuelle Topologien stehen für andere Benennungen der Prozesse eines Kommunikators als die übliche Bezeichnung über Ränge. Hierbei sind
Glossar
273
Graphen, kartesische Gitter und vom Anwender definierte Anordnungen möglich. Virtuelle Topologien binden Anwendungen enger an Kommunikatoren, da die Bezeichnungen der Prozesse die in der Anwendung benötigten Kommunikationsmuster wiedergeben (siehe auch Anwendungstopologie). Virtuell g e m e i n s a m e r Speicher Dieser Begriff bezeichnet ein Software- und/oder Hardware-Modell für die Programmierung, mit dem den Anwendern die Sicht auf einen einzigen Adressraum vermittelt wird. W e t t l a u f s i t u a t i o n Eine Wettlaufsituation (engl.: race condition) besteht, wenn sich zwei oder mehr Prozesse oder Threads um eine Ressource bemühen und der Gewinner nicht vorhersagbar ist. Solche Wettlaufsituationen haben häufig den Effekt, dass ein Programm mal korrekt arbeiten, mal abbrechen kann, also das Ergebnis vom Ausgang des „Wettlaufs" abhängig sein kann. Z u s a m m e n h ä n g e n d e D a t e n Die einfachsten Nachrichtenpuffer bestehen aus Daten, die nicht verteilt im Speicher liegen, sondern zusammenhängend sind. MPI arbeitet hier, ebenso wie in den allgemeineren Fällen mit nicht zusammenhängenden Daten, mit Datentypen.
Α
Zusammenfassung der MPI-l-Routinen
Dieser Anhang enthält die Signaturen der MPI-l-Routinen in C, Fortran und C++. Bei den veralteten Routinen geben wir, mit einem Hinweis der Art „Veraltet! Zu ersetzen durch MPLGet.address", die neue MPI-2-Routine an. Wir haben die MPI-2Funktionen, die die veralteten MPI-l-Funktionen ersetzen, mit in die Liste aufgenommen.
A.l
C- Funktionen
In diesem Abschnitt sind die C-Funktionen nach [Mes94c] beschrieben. int MPI_Abort(MPI_Comm comm, int errorcode)
beendet die Μ PI-Ausführung
sumgebung
int MPI_Address(void* location, MPI_Aint »address)
liefert die Adresse eines gegebenen
Speicherplatzes
Veraltet! Zu ersetzen durch MPI_Get_address int MPI_Allgather(void* sendbuf, int sendcount, MPI_Datatype sendtype, void* recvbuf, int recvcount, MPI_Datatype recvtype, MPI_Comm comm)
sammelt Daten von allen beteiligten Prozessen eines gegebenen Kommunikators verteilt sie an alle diese Prozesse
una
int MPI_Allgatherv(void* sendbuf, int sendcount, MPI_Datatype sendtype, void* recvbuf, int *recvcounts, int *displs, MPI_Datatype recvtype, MPI_Comm comm)
sammelt Daten von allen beteiligten Prozessen eines gegebenen Kommunikators una übergibt sie an alle diese Prozesse (im Unterschied zu MPLAllgather dürfen unterschiedlich viele Daten von den einzelnen Prozessen aufgesammelt werden) int MPI_Allreduce(void* sendbuf, void* recvbuf, int count, MPI_Datatype datatype, MPI_0p op, MPI_Comm comm)
kombiniert Werte von allen beteiligten Prozessen eines gegebenen Kommunikators verteilt das Ergebnis an alle diese Prozesse
una
276
Α Zusammenfassung der MPI-l-Routinen
int MPI_Alltoall(void* sendbuf, int sendcount, MPI_Datatype sendtype, void* recvbuf, int recvcount, MPI_Datatype recvtype, MPI_Comm comm)
sendet Daten von allen beteiligten Prozessen diese Prozesse
eines gegebenen Kommunikators
an alle
int MPI_Alltoallv(void* sendbuf, int *sendcounts, int *sdispls, MPI_Datatype sendtype, void* recvbuf, int *recvcounts, int *rdispls, MPI_Datatype recvtype, MPI_Comm comm)
sendet Daten, zusammen mit einer Verschiebung, von allen beteiligten Prozessen gegebenen Kommunikators an alle diese Prozesse
eines
int MPI_Attr_delete(MPI_Comm comm, int keyval)
löscht den mit einem gegebenen Schlüssel assoziierten
Attributwert
Veraltet! Zu ersetzen durch MPI_Comm_delete_attr int MPI_Attr_get(MPI_Comm comm, int keyval, void *attribute_val, int *flag)
gibt den zu einem gegebenen Schlüssel gehörenden Veraltet! Zu ersetzen durch
Attributwert
zurück
MPI_Comm_get_attr
int MPI_Attr_put(MPI_Comm comm, int keyval, void* attribute_val)
speichert
den zu einem gegebenen Schlüssel gehörenden
Attributwert
Veraltet! Zu ersetzen durch MPI_Comm_set_attr int MPI_Barrier(MPI_Comm comm)
blockiert den aufrufenden Prozess, bis alle Prozesse des gegebenen Kommunikators Routine aufgerufen haben
diese
int MPI_Bcast(void* buffer, int count, MPI_Datatype datatype, int root, MPI_Comm comm)
schickt eine Nachricht Gruppe des gegebenen
vom Prozess mit Rang „root" an alle anderen Kommunikators
Prozesse
int MPI_Bsend(void* buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm)
grundlegende
Senderoutine
mit anwenderdefinierter
Pufferung
int MPI_Bsend_init(void* buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm, MPI_Request »request)
erstellt ein Handle für das Senden einer Nachricht
mit
Pufferung
einer
A.l C-Funktionen
277
int MPI_Buffer_attach(void* buffer, int size)
legt einen anwenderdefinierten
Puffer für eine Sendeoperation
an
int MPI_Buffer_detach(void* buffer, int* size)
gibt einen vorhandenen Puffer frei (zur Verwendung in MPI_Bsend
u. a.)
int MPI_Cancel(MPI_Request ^request)
bricht eine Kommunikations
anfrage ab
int MPI_Cart_coords(MPI_Comm comm, int rank, int maxdims, int *coords)
ermittelt zu einem gegebenen Rang eines Prozesses in einem Kommunikator die Koordinaten dieses Prozesses in der zu dem Kommunikator gehörenden kartesischen Topologie int MPI_Cart_create(MPI_Comm comm_old, int ndims, int *dims, int »periods, int reorder, MPI_Comm *comm_cart)
erzeugt aus den gegebenen Topologiedaten einen neuen
Kommunikator
int MPI_Cart_get(MPI_Comm comm, int maxdims, int *dims, int »periods, int *coords)
gibt die mit dem gegebenen Kommunikator sischen Topologie zurück
assoziierten Informationen
zu seiner karte-
int MPI_Cart_map(MPI_Comm comm, int ndims, int *dims, int *periods, int *newrank)
bestimmt einen neuen Rang für den aufrufenden Prozess bezüglich der gegebenen kartesischen Topologie unter Berücksichtigung der zugrunde liegenden Hardware int MPI_Cart_rank(MPI_Comm comm, int *coords, int *rank)
ermittelt zu einer gegebenen Position in der kariesischen zesses im gegebenen Kommunikator
Topologie den Rang des Pro-
int MPI_Cart_shift(MPI_Coimn comm, int direction, int disp, int *rank_source, int *rank_dest)
gibt zu einer gegebenen Dimension (und Verschiebung), in der Daten innerhalb der zum Kommunikator gehörenden kartesischen Topologie zu verschicken sind, den Rang des Prozesses, von dem empfangen werden soll, und den Rang des Prozesses, an den gesendet werden soll, zurück int MPI_Cart_sub(MPI_Comm comm, int *remain_dims, MPI_Comm *newcomm)
teilt einen gegebenen, mit kartesischer Topologie unterlegten Kommunikator in Untergruppen, die kartesische Untergitter mit kleinerer Dimension bilden, und gibt den neuen Kommunikator, in dem der Prozess enthalten ist, zurück
278
Α Zusammenfassung der MPI-l-Routinen
int MPI_Cartdim_get(MPI_Comm comm, int *ndims)
gibt die Anzahl der Dimensionen kartesischen Topologie zurück
der zu einem gegebenen Kommunikator
gehörenden
int MPI_Comm_compare(MPI_Comm comml, MPI_Comm comm2, int *result)
vergleicht zwei gegebene
Kommunikatoren
int MPI_Comm_create(MPI_Comm comm, MPI_Group group, MPI_Comin *newcomm)
erzeugt zu einer gegebenen Gruppe, die Untermenge der Gruppe des gegebenen Kommunikators ist, einen neuen Kommunikator int MPI_Comm_create_errhandler(MPI_Comm_errhandler_fn MPI_Errhandler *errhandler)
MPI-2: erzeugt eine Fehlerbehandlungsroutine
*function,
für MPI
int MPI_Comm_create_keyval(MPI_Comm_copy_attr_function *copy_fn, MPI_Comm_delete_attr.function *delete_fn, int *keyval, void* extra_state)
MPI-2: generiert einen neuen
Attributschlüssel
int MPI_Comm_delete_attr(MPI_Comm comm, int keyval)
MPI-2: löscht den mit einem gegebenen Schlüssel assoziierten
Attributwert
int MPI_Comm_dup(MPI_Comm comm, MPI_Comm *newcomm)
dupliziert einen gegebenen Kommunikator formationen
mit allen seinen zwischengespeicherten
In-
int MPI_Comm_free(MPI_Comm *comm)
gibt ein gegebenes Kommunikatorobjekt
(zum Löschen) frei
int MPI_Comm_free_keyval(int *keyval)
MPI-2: gibt den Attributschlüssel frei
eines zwischengespeicherten
Kommunikatorattributs
int MPI_Comm_get_attr(MPI_Comm comm, int keyval, void *attribute_val, int *flag)
MPI-2: gibt den zu einem gegebenen Schlüssel gehörenden Attributwert
zurück
int MPI_Comm_get_errhandler(MPI_Comm comm, MPI_Errhandler *errhandler)
MPI-2: liefert die zu einem gegebenen Kommunikator tine
gehörende
Fehlerbehandlungsrou-
279
A. 1 C-Funktionen
int MPI_Comm_group(MPI_Comm comm, MPI_Group *group)
gibt die mit dem gegebenen Kommunikator
assoziierte
Gruppe zurück
int MPI_Comm_rank(MPI_Comm comm, int *rank)
ermittelt den Rang des aufrufenden Prozesses im gegebenen
Kommunikator
int MPI_Comm_remote_group(MPI_Comm comm, MPI_Group *group)
gibt die entfernte Gruppe eines gegebenen Interkommunikators
zurück
int MPI_Comm_remote_size(MPI_Comm comm, int *size)
gibt die Anzahl der Prozesse in der entfernten Gruppe eines gegebenen kators zurück
Interkommuni-
int MPI_Comm_set_attr(MPI_Comm comm, int keyval, void* attribute_val)
MPI-2: speichert den zu einem gegebenen Schlüssel gehörenden
Attributwert
int MPI_Comm_set_errhandler(MPI_Comm comm, MPI_Errhandler errhandler)
MPI-2: legt eine Fehlerbehandlungsroutine
für einen gegebenen Kommunikator
fest
int MPI_Comm_size(MPI_Comm comm, int *size)
ermittelt die Größe der mit einem gegebenen Kommunikator
assoziierten
Gruppe
int MPI_Comm_split(MPI_Comm comm, int color, int key, MPI_Comm *newcomm)
erzeugt neue Kommunikatoren
in Abhängigkeit von Farbwerten und Schlüsseln
int MPI_Comm_test_inter(MPI_Comm comm, int *flag)
testet, ob ein Kommunikator
ein Interkommunikator
ist
int MPI_Dims_create(int nnodes, int ndims, int *dims)
erzeugt eine Aufteilung einer gegebenen Anzahl von Prozessen auf ein gegebenes sisches Gitter int MPI_Errhandler_create(MPI_Handler_function *function, MPI_Errhandler *errhandler)
erzeugt eine Fehlerbehandlungsroutine
für MPI
Veraltet! Zu ersetzen durch MPI_Comm_create_errhandler int MPI_Errhandler_free(MPI_Errh.andler *errhandler )
gibt eine MPI-Fehlerbehandlungsroutine
frei
karte-
Α
280
Zusammenfassung der MPI-l-Routinen
int MPI_Errhandler_get(MPI_Comm comm, MPI_Errhandler *errhandler)
liefert die zu einem gegebenen Kommunikator Veraltet! Zu ersetzen durch
gehörende
Fehlerbehandlungsroutine
MPI_Comm_get_errhandler
int MPI_Errhandler_set(MPI_Comm comm, MPI_Errhandler errhandler)
legt eine Fehlerbehandlungsroutine
für einen gegebenen Kommunikator
fest
Veraltet! Zu ersetzen durch MPI_Comm_set_errhandler int MPI_Error_class(int errorcode, int *errorclass)
gibt die zu einem gegebenen Fehlercode gehörende Fehlerklasse
zurück
int MPI_Error_string(int errorcode, char *string, int *resultlen)
gibt den zu einem gegebenen Fehlercode gehörenden Text als String zurück int MPI_Finalize(void)
beendet die
MPI-Ausführungsumgebung
int MPI_Gather(void* sendbuf, int sendcount, MPI_Datatype sendtype, void* recvbuf, int recvcount, MPI_Datatype recvtype, int root, MPI_Comm comm)
sammelt Daten von allen beteiligten Prozessen eines gegebenen Kommunikators speichert sie beim Prozess mit Rang 'root'
und
int MPI_Gatherv(void* sendbuf, int sendcount, MPI_Datatype sendtype, void* recvbuf, int *recvcounts, int *displs, MPI_Datatype recvtype, int root, MPI_Comm comm)
sammelt Daten von allen beteiligten Prozessen eines gegebenen Kommunikators und speichert sie beim Prozess mit Rang 'root'; es können, anders als mit MPLGather, unterschiedlich viele Daten von jedem Prozess gespeichert werden int MPI_Get_address(void* location, MPI_Aint *address)
MPI-2: liefert die Adresse eines gegebenen
Speicherplatzes
int MPI_Get_count(MPI_Status *status, MPI_Datatype datatype, int *count)
gibt die Anzahl der empfangenen Elemente vom gegebenen Datentyp aus der gegebenen Status- Variablen einer Empfangsoperation zurück (für die vordefininierten Datentypen gleiches Ergebnis wie MPI_ Get_ elements)
281
A.l C-Funktionen
int MPI_Get_elements(MPI_Status *status, MPI_Datatype datatype, int »elements)
gibt die Gesamtanzahl der empfangenen Basiselemente bezüglich des gegebenen Datentyps und der gegebenen Status- Variablen einer Empfangs operation zurück (für die vordefininierten Datentypen gleiches Ergebnis wie MPLGeLcount) int MPI_Get_processor_name(char »name, int *resultlen)
liefert den
Prozessornamen
int MPI_Get_version(int *version, int »subversion)
gibt die MPI-Version
zurück
int MPI_Graph_create(MPI_Comm comm_old, int nnodes, int *index, int »edges, int reorder, MPI_Comm *comm_graph)
erzeugt aus den Eigenschaften kator
einer gegebenen Graphtopologie einen neuen
Kommuni-
int MPI_Graph_get(MPI_Comm comm, int maxindex, int maxedges, int »index, int »edges)
liefert Informationen
zur Graphtopologie des gegebenen
Kommunikators
int MPI_Graph_map(MPI_Comm comm, int nnodes, int »index, int »edges, int »newrank)
bestimmt einen neuen Rang für den aufrufenden Prozess bezüglich der Graphtopologie unter Berücksichtigung der zugrunde liegenden Hardware
gegebenenen
int MPI_Graph_neighbors(MPI_Comm comm, int rank, int »maxneighbors, int »neighbors)
gibt die Nachbarn eines gegebenen Knotens in der Graphtopologie
zurück
int MPI_Graph_neighbors_count(MPI_Comm comm, int rank, int »nneighbors)
gibt die Anzahl der Nachbarn eines gegebenen Knotens in der Graphtopologie
zurück
int MPI_Graphdims_get(MPI_Comm comm, int »nnodes, int »nedges)
gibt die Anzahl der Knoten und der Kanten der zum gegebenen Kommunikator den Graphtopologie zurück
gehören-
int MPI_Group_compare(MPI_Group groupl, MPI_Group group2, int »result)
vergleicht zwei gegebene
Gruppen
int MPI_Group_difference(MPI_Group groupl, MPI_Group group2, MPI_Group »newgroup)
erzeugt aus der Differenz zweier gegebener Gruppen eine neue Gruppe
Α Zusammenfassung der MPI-l-Routinen
282
int MPI_Group_excl(MPI_Group group, int n, int *ranks, MPI_Group *newgroup) erzeugt eine neue Gruppe aus den nicht aufgelisteten
Mitgliedern
der gegebenen
Gruppe
int MPI_Group_free(MPI_Group *group) gibt eine Gruppe
frei
int MPI_Group_incl(MPI_Group group, int n, int *ranks, MPI_Group *newgroup) erzeugt eine neue Gruppe aus den aufgelisteten
Mitgliedern
der gegebenen
Gruppe
int MPI_Group_intersection(MPI_Group groupl, MPI_Group group2, MPI_Group *newgroup) erzeugt eine neue Gruppe aus dem Durchschnitt
zweier gegebener
Gruppen
int MPI_Group_range_excl(MPI_Group group, int n, int ranges[][3], MPI_Group *newgroup) erzeugt eine neue Gruppe durch Ausschluss
von Rangbereichen
einer gegebenen
Gruppe
int MPI_Group_range_incl(MPI_Group group, int n, int ranges [] [3], MPI_Group *newgroup) erzeugt eine neue Gruppe, einer gegebenen Gruppe
bestehend
aus den Elementen
der aufgelisteten
Rangbereiche
int MPI_Group_rank(MPI_Group group, int *rank) gibt den Rang des aufrufenden
Prozesses
in der gegebenen
Gruppe
zurück
int MPI_Group_size(MPI_Group group, int *size) gibt die Größe
einer gegebenen
Gruppe
zurück
int MPI_Group_translate_ranks (MPI_Group groupl, int n, int *ranksl, MPI_Group group2, int *ranks2) liefert für die über ihre Ränge aufgelisteten Prozesse einer gegebenen spondierenden Ränge in einer anderen gegebenen Gruppe
Gruppe die
korre-
int MPI_Group_union(MPI_Group groupl, MPI_Group group2, MPI_Group *newgroup) bildet eine Gruppe durch Vereinigung
zweier gegebener
Gruppen
int MPI_Ibsend(void* buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm, MPI_Request *request) startet
ein nichtblockierendes
Senden
mit
Pufferung
283
A.l C-Funktionen
int MPI_Init(int *argc, char *»*argv) initialisiert die
MPI-Ausführungsumgebung
int MPI_Initialized(int *flag) stellt fest, ob MPI_Init
aufgerufen
wurde
int MPI_Intercomm_create(MPI_Comm local_comm, int local_leader, MPI_Comm peer_comm, int remote_leader, int tag, MPI_Comm *newintercomm) erzeugt aus zwei gegebenen
Intrakommunikatoren
einen
Interkommunikator
int MPI_Intercomm_merge(MPI_Comm intercomm, int high, MPI_Comm *newintracomm) erzeugt aus einem gegebenen
Interkommunikator
einen
Intrakommunikator
int MPI_Iprobe(int source, int tag, MPI_Comm comm, int »flag, MPI_Status *status) nichtblockierende
Anfrage,
ob eine Nachricht vorhanden
ist
int MPI_Irecv(void* buf, int count, MPI_Datatype datatype, int source, int tag, MPI_Comm comm, MPI_Request »request) startet ein nichtblockierendes
Empfangen
einer
Nachricht
int MPI_Irsend(void* buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm, MPI_Request »request) startet ein nichtblockierendes
Senden
einer Nachricht im
Readymodus
int MPI_Isend(void* buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm, MPI_Request »request) startet ein nichtblockierendes
Senden
einer Nachricht
int MPI_Issend(void* buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm, MPI_Request »request) startet ein nichtblockierendes
synchrones
Senden
einer
Nachricht
int MPI_Keyval_create(MPI_Copy_function *copy_fn, MPI_Delete_function »delete_fn, int »keyval, void* extra_state) generiert
einen neuen
Attributschlüssel
Veraltet! Zu ersetzen durch
MPI_Comm_create_keyval
Α Zusammenfassung der MPI-l-Routinen
284
int MPI_Keyval_free(int *keyval)
gibt den Attributschlüssel
eines zwischengespeicherten
Veraltet! Zu ersetzen durch
Kommunikatorattributs
frei
MPI_Comm_free_keyval
int MPI_üp_create(MPI_Uop function, int commute, MPI_0p *op)
erzeugt ein Handle für eine anwenderdefinierte
Kombinationsfunktion
int MPI_0p_free(MPI_0p *op)
gibt ein Handle für eine anwenderdefinierte
Kombinationsfunktion
frei
int MPI_Pack(void *inbuf, int incount, MPI_Datatype datatype, void *outbuf, int outcount, int *position, MPI_Comm comm)
packt Daten in einen zusammenhängenden
Puffer
int MPI_Pack_size(int incount, MPI_Datatype datatype, MPI_Comm comm, int *size)
gibt die Größe zurück, die für das Packen eines Datentyps benötigt wird int MPI_Pcontrol(const int level, ...)
steuert die
Programmanalyse
int MPI_Probe(int source, int tag, MPI_Comm comm, MPI_Status *status)
blockierende
Anfrage, ob eine Nachricht
vorhanden
ist
int MPI_Recv(void* buf, int count, MPI_Datatype datatype, int source, int tag, MPI_Comm comm, MPI_Status *status)
grundlegende
Empfangsroutine
int MPI_Recv_init(void* buf, int count, MPI_Datatype datatype, int source, int tag, MPI_Comm comm, MPI_Request *request)
erzeugt ein Handle für das Empfangen
einer
Nachricht
int MPI_Reduce(void* sendbuf, void* recvbuf, int count, MPI_Datatype datatype, MPI_0p op, int root, MPI_Comm comm)
kombiniert (reduziert) die Werte aller beteiligten Prozesse eines gegebenen kators zu einem einzigen Wert, den der Prozess mit Rang 'root' erhält
Kommuni-
int MPI_Reduce_scatter(void* sendbuf, void* recvbuf, int *recvcounts, MPI_Datatype datatype, MPI_0p op, MPI_Comm comm)
kombiniert (reduziert) die Werte aller beteiligten Prozesse kators zu einem Ergebnisvektor und verteilt die Ergebnisse
eines gegebenen Kommunian alle diese Prozesse
285
A. 1 C-Funktionen
int MPI_Request_free(MPI_Request *request) gibt das Anfrageobjekt
einer Kommunikation
frei
int MPI_Rsend(void* buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm) grundlegende
Senderoutine
im
Readymodus
int MPI_Rsend_init(void* buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm, MPI_Request »request) erstellt ein Handle für das Senden
einer Nachricht
im
Readymodus
int MPI_Scan(void* sendbuf, void* recvbuf, int count, MPI_Datatype datatype, MPI_0p op, MPI_Comm comm) berechnet einen „Scan" (partielle Datenreduktionen)
auf einer Menge von
Prozessen
int MPI_Scatter(void* sendbuf, int sendcount, MPI_Datatype sendtype, void* recvbuf, int recvcount, MPI_Datatype recvtype, int root, MPI_Comm comm) verteilt Daten vom Prozess mit Rang 'root' an alle beteiligten Prozesse eines Kommunikators
gegebenen
int MPI_Scatterv(void* sendbuf, int *sendcounts, int *displs, MPI_Datatype sendtype, void* recvbuf, int recvcount, MPI_Datatype recvtype, int root, MPI_Comm comm) verteilt Daten vom Prozess mit Rang 'root' an alle beteiligten Prozesse eines gegebenen Kommunikators; es können, anders als bei MPI_Scatter, unterschiedlich viele Daten je Prozess verteilt werden int MPI_Send(void* buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm) grundlegende
Senderoutine
int MPI_Send_init(void* buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm, MPI_Request *request) erstellt ein Handle für das Senden einer Nachricht im
Standardmodus
int MPI_Sendrecv(void *sendbuf, int sendcount, MPI_Datatype sendtype, int dest, int sendtag, void *recvbuf, int recvcount, MPI_Datatype recvtype, int source, int recvtag, MPI_Comm comm, MPI_Status *status) sendet und empfängt eine
Nachricht
Α Zusammenfassung der MPI-l-Routinen
286
int MPI_Sendrecv_replace(void* buf, int count, MPI_Datatype datatype, int dest, int sendtag, int source, int recvtag, MPI_Comm comm, MPI_Status *status) sendet und empfängt eine Nachricht unter Verwendung nur eines
Puffers
int MPI_Ssend(void* buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm) grundlegende
synchrone
Senderoutine
int MPI_Ssend_init(void* buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm, MPI_Request *request) erstellt ein Handle für ein synchrones Senden einer
Nachricht
int MPI_Start(MPI_Request *request) startet eine Kommunikation
mit einem langlebigen
Anfrageobjekt
int MPI_Startall(int count, MPI_Request *array_of„requests) startet eine Menge von Kommunikationen
mit langlebigen
Anfrageobjekten
int MPI_Test(MPI_Request *request, int *flag, MPI_Status *status) testet auf das Ende einer Sende- oder
Empfangsroutine
int MPI_Testall(int count, MPI_Request *array_of.requests, int *flag, MPI_Status *array_of.statuses) testet auf das Ende aller vorher gestarteten
Kommunikationen
int MPI_Testany(int count, MPI_Request *array_of.requests, int *index, int *flag, MPI_Status *status) testet auf das Ende einer beliebigen vorher gestarteten
Kommunikation
int MPI_Testsome(int incount, MPI_Request *array_of.requests, int *outcount, int *array_of„indices, MPI_Status *array_of„statuses) testet auf das Ende mehrerer gegebener
Kommunikationen
int MPI_Test_cancelled(MPI_Status *status, int *flag) testet, ob eine Anfrage abgebrochen wurde int MPI_Topo_test(MPI_Comm comm, int *top_type) ermittelt den Typ einer zum gegebenen Kommunikator eine gibt)
gehörenden
Topologie (falls ei
A. 1 C-Funktionen
287
int MPI_Type_commit(MPI_Datatype »datatype)
gibt dem System einen Datentyp
bekannt
int MPI_Type_contiguous(int count, MPI_Datatype oldtype, MPI_Datatype *newtype)
erzeugt einen neuen Datentyp, der aus einer gegebenen Anzahl von aneinander Elementen des gegebenen Datentyps besteht
gefügten
int MPI_Type_create_hindexed(int count, int *array_of_blocklengths, MPI_Aint *array_of.displacements, MPI_Datatype oldtype, MPI_Datatype *newtype)
MPI-2: erzeugt einen indizierten Datentyp mit in Bytes angegebenen
Verschiebungen
int MPI_Type_create_hvector(int count, int blocklength, MPI_Aint stride, MPI_Datatype oldtype, MPI_Datatype *newtype)
MPI-2: erzeugt einen („strided") bungen
Vektordatentyp
mit in Bytes angegebenen
Verschie-
int MPI_Type_create_struct(int count, int *array_of_blocklengths, MPI_Aint *array_of.displacements, MPI_Datatype *array_of_types, MPI_Datatype *newtype)
MPI-2: erzeugt einen
Struktur-Datentyp
int MPI_Type_extent(MPI_Datatype datatype, MPI_Aint *extent)
gibt die tatsächlich zur Speicherung benötigte Größe des gegebenen Datentyps Veraltet! Zu ersetzen durch
zurück
MPI_Type_get_extent
int MPI_Type_free(MPI_Datatype »datatype)
kennzeichnet
das Datentypobjekt
als freigebbar
int MPI_Type_get_extent(MPI_Datatype datatype, MPI.Aint *lb, MPI.Aint *extent)
MPI-2: gibt die untere Schranke und die tatsächlich zur Speicherung benötigte Größe des gegebenen Datentyps zurück int MPI_Type_hindexed(int count, int *array_of_blocklengths, MPI_Aint *array_of.displacements, MPI_Datatype oldtype, • MPI_Datatype *newtype)
erzeugt einen indizierten Datentyp mit in Bytes angegebenen Veraltet! Zu ersetzen durch
MPI_Type_create_hindexed
Verschiebungen
288
Α Zusammenfassung der MPI-l-Routinen
int MPI_Type_hvector(int count, int blocklength, MPI_Aint stride, MPI_Datatype oldtype, MPI_Datatype *newtype) erzeugt
einen („strided")
Veraltet! Zu ersetzen
Vektordatentyp durch MPI_
mit in Bytes
angegebenen
Verschiebungen
Type_create_hvector
int MPI_Type_indexed(int count, int *array_of_blocklengths, int *array_of.displacements, MPI_Datatype oldtype, MPI_Datatype *newtype) erzeugt
einen indizierten
Datentyp
int MPI_Type_lb(MPI_Datatype datatype, MPI_Aint »displacement) gibt die untere Schranke Veraltet! Zu ersetzen
eines Datentyps durch
zurück
MPI_Type_get_extent
int MPI_Type_size(MPI_Datatype datatype, int »size) gibt die Anzahl der Bytes zurück, die im Datentyp der Verschiebungen) tatsächlich belegt sind
durch Elemente
(ohne
Aufrechnung
int MPI_Type_struct(int count, int *array_of_blocklengths, MPI_Aint *array_of_displacements, MPI_Datatype *array_of_types, MPI_Datatype *newtype) erzeugt
einen
Struktur-Datentyp
Veraltet! Zu ersetzen
durch
MPI_Type_create_struct
int MPI_Type_ub(MPI_Datatype datatype, MPI_Aint »displacement) gibt die obere Schranke Veraltet! Zu ersetzen
eines Datentyps durch
zurück
MPI_Type_get_extent
int MPI_Type_vector(int count, int blocklength, int stride, MPI_Datatype oldtype, MPI_Datatype *newtype) erzeugt
einen („strided")
Vektordatentyp
int MPI_Unpack(void *inbuf, int insize, int »position, void »outbuf, int outcount, MPI_Datatype datatype, MPI_Comm comm) entpackt
Daten
aus einem zusammenhängenden
Puffer
int MPI_Wait(MPI_Request »request, MPI_Status »status) wartet
auf das Ende einer Sende-
oder
Empfangsoperation
Α.2
289
Fortran-Routinen
int MPI_Waitall(int count, MPI_Request *array_of.requests, MPI_Status *array_of.statuses) wartet auf das Ende aller aufgelisteten
Kommunikations
Operationen
int MPI_Waitany(int count, MPI_Request *array_of.requests, int *index, MPI.Status *status) wartet auf das Ende einer der spezifizierten
Kommunikations
Operationen
int MPI_Waitsome(int incount, MPI_Request *array_of.requests, int *outcount, int *array_of.indices, MPI.Status *array_of.statuses) wartet auf das Ende einer oder mehrerer
gegebener
Kommunikations
Operationen
double MPI.Wtick(void) liefert die Genauigkeit (Taktdauer der Uhr) von MPI_Wtime
in
Sekunden
double MPI.Wtime(void) gibt eine auf dem aufrufenden
A.2
Prozessor vergangene Zeit zurück
Fortran-Routinen
In diesem Abschnitt sind die Fortran-Routinen nach [Mes94c] beschrieben. MPI.Abort(comm, errorcode, ierror) integer comm, errorcode, ierror beendet die
MPI-Ausführungsumgebung
MPI.Address(location, address, ierror) location integer address, ierror liefert die Adresse eines gegebenen
Speicherplatzes
Veraltet! Zu ersetzen durch MPI_Get_address MPI.Allgather(sendbuf, sendcount, sendtype, recvbuf, recvcount, recvtype, comm, ierror) sendbuf(*), recvbuf(*) integer sendcount, sendtype, recvcount, recvtype, comm, ierror sammelt Daten von allen beteiligten Prozessen verteilt sie an alle diese Prozesse
eines gegebenen
Kommunikators
una
290
Α
Zusammenfassung der MPI-l-Routinen
MPI_Allgatherv(sendbuf, sendcount, sendtype, recvbuf, recvcounts, displs, recvtype, comm, ierror) sendbufC*), recvbuf(*) integer sendcount, sendtype, recvcounts(*), displs(*), recvtype, comm, ierror
sammelt Daten von allen beteiligten Prozessen eines gegebenen Kommunikators una übergibt sie an alle diese Prozesse (im Unterschied zu MPLAllgather dürfen unterschiedlich viele Daten von den einzelnen Prozessen aufgesammelt werden) MPI_Allreduce(sendbuf, recvbuf, count, datatype, op, comm, ierror) sendbufC*), recvbuf(*) integer count, datatype, op, comm, ierror
kombiniert Werte von allen beteiligten Prozessen eines gegebenen Kommunikators verteilt das Ergebnis an alle diese Prozesse
una
MPI_Alltoall(sendbuf, sendcount, sendtype, recvbuf, recvcount, recvtype, comm, ierror) sendbufC*), recvbufC*) integer sendcount, sendtype, recvcount, recvtype, comm, ierror
sendet Daten von allen beteiligten Prozessen eines gegebenen Kommunikators diese Prozesse
an alle
MPI_Alltoallv(sendbuf, sendcounts, sdispls, sendtype, recvbuf, recvcounts, rdispls, recvtype, comm, ierror) sendbufC*), recvbufC*) integer sendcounts(*), sdisplsC*), sendtype, recvcounts(*), rdispls(*), recvtype, comm, ierror
sendet Daten, zusammen mit einer Verschiebung, von allen beteiligten Prozessen gegebenen Kommunikators an alle diese Prozesse MPI_Attr_delete(comm, keyval, ierror) integer comm, keyval, ierror
löscht den mit einem gegebenen Schlüssel assoziierten
Attributwert
Veraltet! Zu ersetzen durch MPI_Comm_delete_attr MPI_Attr_get(comm, keyval, attribute_val, flag, ierror) integer comm, keyval, attribute_val, ierror logical flag
gibt den zu einem gegebenen Schlüssel gehörenden Attributwert Veraltet! Zu ersetzen durch
MPI_Comm_get_attr
zurück
eines
291
Α.2 Fortran-Routinen
MPI_Attr_put(comm, keyval, attribute_val, ierror) integer comm, keyval, attribute_val, ierror
speichert den zu einem gegebenen Schlüssel gehörenden
Attributwert
Veraltet! Zu ersetzen durch MPI_Comm_set_attr MPI_Barrier(comm, ierror) integer comm, ierror
blockiert den aufrufenden Prozess, bis alle Prozesse des gegebenen Kommunikators Routine aufgerufen haben
diese
MPI_Bcast(buffer, count, datatype, root, comm, ierror) buffer(*) integer count, datatype, root, comm, ierror
schickt eine Nachricht vom Prozess mit Rang „root" an alle anderen Prozesse einer Gruppe des gegebenen Kommunikators MPI_Bsend(buf, count, datatype, dest, tag, comm, ierror) buf(*) integer count, datatype, dest, tag, comm, ierror
grundlegende Senderoutine
mit anwenderdefinierter
Pufferung
MPI_Bsend_init(buf, count, datatype, dest, tag, comm, request, ierror) buf(*) integer count, datatype, dest, tag, comm, request, ierror
erstellt ein Handle für das Senden einer Nachricht mit
Pufferung
MPI_Buffer_attach(buffer, size, ierror) buffer(*) integer size, ierror
legt einen anwenderdefinierten
Puffer für eine Sendeoperation
an
MPI_Buffer_detach(buffer, size, ierror) buffer(*) integer size, ierror
gibt einen vorhandenen Puffer frei (zur Verwendung in MPI_Bsend MPI_Cancel(request, ierror) integer request, ierror
bricht eine Kommunikationsanfrage
ab
u. a.)
292
Α Zusammenfassung der MPI-l-Routinen
MPI_Cart_coords(comm, rank, maxdims, coords, ierror) · integer comm, rank, maxdims, coords(*), ierror ermittelt zu einem gegebenen Rang eines Prozesses in einem Kommunikator die Koordinaten dieses Prozesses in der zu dem Kommunikator gehörenden kartesischen Topologie MPI_Cart_create(comm_old, ndims, dims, periods, reorder, comm_cart, ierror) integer comm_old, ndims, dims(*), comm_cart, ierror logical periods(*), reorder erzeugt aus den gegebenen
Topologiedaten
einen neuen
Kommunikator
MPI_Cart_get(comm, maxdims, dims, periods, coords, ierror) integer comm, maxdims, dims(*), coords(*), ierror logical periods(*) gibt die mit dem gegebenen sischen Topologie zurück
Kommunikator
assoziierten
Informationen
zu seiner
karte-
bestimmt einen neuen Rang für den aufrufenden Prozess bezüglich der gegebenen sischen Topologie unter Berücksichtigung der zugrunde liegenden Hardware
karte-
MPI_Cart_map(comm, ndims, dims, periods, newrank, ierror) integer comm, ndims, dims(*), newrank, ierror logical periods(*)
MPI_Cart_rank(comm, coords, rank, ierror) integer comm, coords(*), rank, ierror ermittelt zu einer gegebenen Position zesses im gegebenen Kommunikator
in der kartesischen
Topologie
den Rang des
Pro-
MPI_Cart_shift(comm, direction, disp, rank_source, rank_dest, ierror) integer comm, direction, disp, rank_source, rank_dest, ierror gibt zu einer gegebenen Dimension (und Verschiebung), in der Daten innerhalb der zum Kommunikator gehörenden kartesischen Topologie zu verschicken sind, den Rang des Prozesses, von dem empfangen werden soll, und den Rang des Prozesses, an den gesendet werden soll, zurück MPI_Cart_sub(comm, remain_dims, newcomm, ierror) integer comm, newcomm, ierror logical remain_dims(*) teilt einen gegebenen, mit kartesischer Topologie unterlegten gruppen, die kartesische Untergitter mit kleinerer Dimension Kommunikator, in dem der Prozess enthalten ist, zurück
Kommunikator in Unterbilden, und gibt den neuen
Α.2 Fortran-Routinen
293
MPI_Cartdim_get(comm, ndims, ierror) integer comm, ndims, ierror
gibt die Anzahl der Dimensionen kartesischen Topologie zurück
der zu einem gegebenen Kommunikator
gehörenden
MPI_Comm_compare(comml, comm2, result, ierror) integer comm, group, newcomm, ierror
vergleicht zwei gegebene
Kommunikatoren
MPI_Comm_create(comm, group, newcomm, ierror) integer comm, group, newcomm, ierror
erzeugt zu einer gegebenen Gruppe, die Untermenge der Gruppe des gegebenen Kommunikators ist, einen neuen Kommunikator MPI_Comm_create_errhandler(function, errhandler, ierror) external function integer errhandler, ierror
MPI-2: erzeugt eine Fehlerbehandlungsroutine
für MPI
MPI_Comm_create_keyval(copy_fn, delete_fn, keyval, extra_state, ierror) external copy_fn, delete_fn integer keyval, ierror integer (kind=MPI_ADDRESS_KIND) extra_state
MPI-2: generiert einen neuen
Attributschlüssel
MPI_Comm_delete_attr(comm, keyval, ierror) integer comm, keyval, ierror
MPI-2: löscht den mit einem gegebenen Schlüssel assoziierten
Attributwert
MPI_Comm_dup(comm, newcomm, ierror) integer comm, newcomm, ierror
dupliziert einen gegebenen Kommunikator formationen
mit allen seinen zwischengespeicherten
In-
MPI_Comm_free(comm, ierror) integer comm, ierror
gibt ein gegebenes Kommunikatorobjekt
(zum Löschen) frei
MPI_Comm_free_keyval(keyval, ierror) integer keyval, ierror
MPI-2: gibt den Attributschlüssel frei
eines zwischengespeicherten
Kommunikatorattributs
294
Α Zusammenfassung der MPI-l-Routinen
MPI_Comm_get_attr(comm, keyval, attribute_val, flag, ierror) integer comm, keyval, ierror integer (kind=MPI_ADDRESS_KIND) attribute_val logical flag
MPI-2: gibt den zu einem gegebenen Schlüssel gehörenden
Attributwert
zurück
MPI_Comm_get_errhandler(comm, errhandler, ierror) integer comm, errhandler, ierror
MPI-2: liefert die zu einem gegebenen Kommunikator tine
gehörende
Fehlerbehandlungsrou-
MPI_Comm_group(comm, group, ierror) integer comm, group, ierror
gibt die mit dem gegebenen Kommunikator
assoziierte
Gruppe zurück
MPI_Comm_rank(comm, rank, ierror) integer comm, rank, ierror
ermittelt den Rang des aufrufenden
Prozesses
im gegebenen
Kommunikator
MPI_Comm_remote_group(comm, group, ierror) integer comm, group, error
gibt die entfernte
Gruppe eines gegebenen Interkommunikators
zurück
MPI_Comm_remote_size(comm, size, ierror) integer comm, size, ierror
gibt die Anzahl der Prozesse in der entfernten kators zurück
Gruppe eines gegebenen
Interkommuni-
MPI_Comm_set_attr(comm, keyval, attribute_val, ierror) integer comm, keyval, ierror integer (kind=MPI_ADDRESS_KIND) attribute.val
MPI-2: speichert
den zu einem gegebenen Schlüssel gehörenden
Attributwert
MPI_Comm_set_errhandler(comm, errhandler, ierror) integer comm, errhandler, ierror
MPI-2: legt eine Fehlerbehandlungsroutine
für einen gegebenen Kommunikator
fest
MPI_Comm_size(comm, size, ierror) integer comm, size, ierror
ermittelt die Größe der mit einem gegebenen Kommunikator
assoziierten
Gruppe
MPI_Comm_split(comm, color, key, newcomm, ierror) integer comm, color, key, newcomm, ierror
erzeugt neue Kommunikatoren
in Abhängigkeit
von Farbwerten
und
Schlüsseln
Α.2 Fortran-Routinen
295
MPI_Comm_test_inter(comm, flag, ierror) integer comm, ierror logical flag
testet, ob ein Kommunikator
ein Interkommunikator
ist
MPI_Dims_create(nnodes, ndims, dims, ierror) integer nnodes, ndims, dims(*), ierror
erzeugt eine Aufteilung einer gegebenen Anzahl von Prozessen auf ein gegebenes kartesisches Gitter MPI_Errhandler_create(function, errhandler, ierror) external function integer errhandler, ierror
erzeugt eine Fehlerbehandlungsroutine
für MPI
Veraltet! Zu ersetzen durch MPI_Comm_create_errhandler MPI_Errhandler_free(errhandler, ierror) integer errhandler, ierror
gibt eine ΜPI-Fehlerbehandlungsroutine
frei
MPI_Errhandler_get(comm, errhandler, ierror) integer comm, errhandler, ierror
gibt die Fehlerbehandlungsroutine Veraltet! Zu ersetzen durch
eines gegebenen Kommunikators
zurück
MPI_Comm_get_errhandler
MPI_Errhandler_set(comm, errhandler, ierror) integer comm, errhandler, ierror
legt eine Fehlerbehandlungsroutine Veraltet! Zu ersetzen durch
für einen gegebenen Kommunikator
fest
MPI_Comm_set_errhandler
MPI_Error_class(errorcode, errorclass, ierror) integer errorcode, errorclass, ierror
gibt die zu einem gegebenen Fehlercode gehörende Fehlerklasse
zurück
MPI_Error_string(errorcode, string, resultlen, ierror) integer errorcode, resultlen, ierror character*(MPI_MAX_ERROR_STRING) string
gibt den zu einem gegebenen Fehlercode gehörenden Text als String zurück MPI_Finalize(ierror) integer ierror
beendet die
MPI-Ausführungsumgebung
Α Zusammenfassung der MPI-l-Routinen
296
MPI_Gather(sendbuf, sendcount, sendtype, recvbuf, recvcount, recvtype, root, comm, ierror) sendbuf(*), recvbuf(*) integer sendcount, sendtype, recvcount, recvtype, root, comm, ierror
sammelt Daten von allen beteiligten Prozessen eines gegebenen Kommunikators speichert sie beim Prozess mit Rang 'root'
una
MPI_Gatherv(sendbuf, sendcount, sendtype, recvbuf, recvcounts, displs, recvtype, root, comm, ierror) sendbuf(*), recvbuf(*) integer sendcount, sendtype, recvcounts(*), displsC*), recvtype, root, comm, ierror
sammelt Daten von allen beteiligten Prozessen eines gegebenen Kommunikators una speichert sie beim Prozess mit Rang 'root'; es können, anders als mit MPLGather, unterschiedlich viele Daten von jedem Prozess gespeichert werden MPI_Get.address(location, address, ierror) location(*) integer (kind=MPI_ADDRESS_KIND) address integer ierror
MPI-2: liefert die Adresse eines Speicherplatzes MPI_Get_count(status, datatype, count, ierror) integer status(*), datatype, count, ierror
gibt die Anzahl der empfangenen Elemente vom gegebenen Datentyp aus der gegebenen Status- Variablen einer Empfangsoperation zurück (für die vordefininierten Datentypen gleiches Ergebnis wie MPI_Get_elements) MPI_Get_elements(status, datatype, elements, ierror) integer status(*), datatype, elements, ierror
gibt die Gesamtanzahl der empfangenen Basiselemente bezüglich des gegebenen Datentyps und der gegebenen Status- Variablen einer Empfangs operation zurück (für die vordefininierten Datentypen gleiches Ergebnis wie MPLGeLcount) MPI_Get_processor_name(name, resultlen, ierror) character*(MPI_MAX_PROCESSOR_NAME) name integer resultlen, ierror
liefert den
Prozessornamen
MPI_Get_version(version, subversion, ierror) integer version, subversion, ierror
gibt die MPI-Version
zurück
297
Α.2 Fortran-Routinen MPI_Graph_create(comm_old, nnodes, index, edges, reorder, comm_graph, ierror) integer comm_old, nnodes, index(*), edges(*), comm_graph, ierror logical reorder erzeugt kator
aus den Eigenschaften
einer gegebenen
Graphtopologie
einen neuen
Kommuni-
MPI_Graph_get(comm, maxindex, maxedges, index, edges, ierror) integer comm, maxindex, maxedges, index(*), edges(*), ierror liefert Informationen
zur Graphtopologie
des gegebenen
Kommunikators
MPI_Graph_map(comm, nnodes, index, edges, newrank, ierror) integer comm, nnodes, index(*), edges(*), newrank, ierror bestimmt einen neuen Rang für den aufrufenden Prozess bezüglich der Graphtopologie unter Berücksichtigung der zugrunde liegenden Hardware
gegebenenen
MPI_Graph_neighbors(comm, rank, maxneighbors, neighbors, ierror) integer comm, rank, maxneighbors, neighbors(*), ierror gibt die Nachbarn
eines gegebenen
Knotens
in der Graphtopologie
zurück
MPI_Graph_neighbors_count(comm, rank, nneighbors, ierror) integer comm, rank, nneighbors, ierror gibt die Anzahl
der Nachbarn
eines gegebenen
Knotens
in der Graphtopologie
zurück
MPI_Graphdims_get(comm, nnodes, nedges, ierror) integer comm, nnodes, nedges, ierror gibt die Anzahl der Knoten und der Kanten den Graphtopologie zurück
der zum gegebenen Kommunikator
gehören-
MPI_Group_compare(groupl, group2, result, ierror) integer groupl, group2, result, ierror vergleicht
zwei gegebene
Gruppen
MPI_Group_difference(groupl, group2, newgroup, ierror) integer groupl, group2, newgroup, ierror erzeugt
aus der Differenz
zweier
gegebener
Gruppen
eine neue
Gruppe
MPI_Group_excl(group, η, ranks, newgroup, ierror) integer group, η, ranks(*), newgroup, ierror erzeugt eine neue Gruppe aus den nicht aufgelisteten MPI_Group_free(group, ierror) integer group, ierror gibt eine Gruppe
frei
Mitgliedern
der gegebenen
Gruppe
Α Zusammenfassung der MPI-l-Routinen
298
MPI_Group_incl(group, η, ranks, newgroup, ierror) integer group, n, ranks(*), newgroup, ierror erzeugt eine neue Gruppe aus den aufgelisteten Mitgliedern der gegebenen
Gruppe
MPI_Group_intersection(groupl, group2, newgroup, ierror) integer groupl, group2, newgroup, ierror erzeugt eine neue Gruppe aus dem Durchschnitt zweier gegebener
Gruppen
MPI_Group_range_excl(group, η, ranges, newgroup, ierror) integer group, η, ranges(3,*), newgroup, ierror erzeugt eine neue Gruppe durch Ausschluss von Rangbereichen
einer gegebenen
Gruppe
MPI_Group_range_incl(group, η, ranges, newgroup, ierror) integer group, η, ranges(3,*), newgroup, ierror erzeugt eine neue Gruppe, bestehend aus den Elementen einer gegebenen Gruppe
der aufgelisteten
Rangbereiche
MPI_Group_rank(group, rank, ierror) integer group, rank, ierror gibt den Rang des aufrufenden
Prozesses in der gegebenen Gruppe zurück
MPI_Group_size(group, size, ierror) integer group, size, ierror gibt die Größe einer gegebenen Gruppe zurück MPI_Group_translate_ranks(groupl, n, ranks1, group2, ranks2, ierror) integer groupl, n, ranksl(*), group2, ranks2(*), ierror liefert für die über ihre Ränge aufgelisteten Prozesse einer gegebenen Gruppe die korrespondierenden Ränge in einer anderen gegebenen Gruppe MPI_Group_union(groupl, group2, newgroup, ierror) integer groupl, group2, newgroup, ierror bildet eine Gruppe durch Vereinigung zweier gegebener
Gruppen
MPI_Ibsend(buf, count, datatype, dest, tag, comm, request, ierror) buf(*) integer count, datatype, dest, tag, comm, request, ierror startet ein nichtblockierendes
Senden mit
Pufferung
MPI_Init(ierror) integer ierror initialisiert die
MPI-Ausführungsumgebung
Α.2 Fortran-Routinen
299
MPI_Initialized(flag, ierror) logical flag integer ierror stellt fest, ob MPI_Init
aufgerufen
wurde
MPI_Intercomm_create(local_comm, local_leader, peer_comm, remote_leader, tag, newintercomm, ierror) integer local_comm, local_leader, peer_comm, remote_leader, tag, newintercomm, ierror erzeugt aus zwei gegebenen
Intrakommunikatoren
einen
Interkommunikator
MPI_Intercomm-merge(intercomm, high, intracomm, ierror) integer intercomm, intracomm, ierror logical high erzeugt aus einem gegebenen
Interkommunikator
einen
Intrakommunikator
MPI_Iprobe(source, tag, comm, flag, status, ierror) integer source, tag, comm, status(*), ierror logical flag nichtblockierende
Anfrage,
ob eine Nachricht
vorhanden
ist
MPI_Irecv(buf, count, datatype, source, tag, comm, request, ierror) buf(*) integer count, datatype, source, tag, comm, request, ierror startet ein nichtblockierendes
Empfangen
einer
Nachricht
MPI_Irsend(buf, count, datatype, dest, tag, comm, request, ierror) buf(*) integer count, datatype, dest, tag, comm, request, ierror startet ein nichtblockierendes
Senden
einer Nachricht
im
Readymodus
MPI_Isend(buf, count, datatype, dest, tag, comm, request, ierror) buf(*) integer count, datatype, dest, tag, comm, request, ierror startet ein nichtblockierendes
Senden
einer Nachricht
MPI_Issend(buf, count, datatype, dest, tag, comm, request, ierror) buf(*) integer count, datatype, dest, tag, comm, request, ierror startet ein nichtblockierendes
synchrones
Senden
einer
Nachricht
300
Α Zusammenfassung der MPI-l-Routineii
MPI_Keyval_create(copy_fn, delete_fn, keyval, extra_state, ierror) external copy_fn, delete_fn integer keyval, extra_state, ierror generiert
einen neuen
Attributschlüssel
Veraltet! Zu ersetzen durch
MPI_Comm_create_keyval
MPI_Keyval_free(keyval, ierror) integer keyval, ierror gibt den Attributschlüssel
eines zwischengespeicherten
Veraltet! Zu ersetzen durch
Kommunikatorattributs
frei
MPI_Comm_free_keyval
MPI_0p_create(function, commute, op, ierror) external function logical commute integer op, ierror erzeugt ein Handle für eine anwenderdefinierte
Kombinationsfunktion
MPI_0p_free(op, ierror) integer op, ierror gibt ein Handle für eine anwenderdefinierte
Kombinationsfunktion
frei
MPI_Pack(inbuf, incount, datatype, outbuf, outcount, position, comm, ierror) inbuf(*), outbuf(*) integer incount, datatype, outcount, position, comm, ierror packt Daten in einen zusammenhängenden
Puffer
MPI_Pack_size(incount, datatype, size, ierror) integer incount, datatype, size, ierror gibt die Größe zurück, die für das Packen eines Datentyps benötigt wird MPI.Pcontrol(level) integer level steuert die Programmanalyse
(kein Parameter
ierror; siehe A.14 in
MPI_Probe(source, tag, comm, status, ierror) integer source, tag, comm, status(*), ierror blockierende Anfrage,
ob eine Nachricht vorhanden ist
[Mes94a])
Α.2
Fortran-Routinen
301
MPI_Recv(buf, count, datatype, source, tag, comm, status, ierror) buf(*) integer count, datatype, source, tag, comm, status(*), ierror grundlegende
Empfangsroutine
MPI_Recv_init(buf, count, datatype, source, tag, comm, request, ierror) buf(*) integer count, datatype, source, tag, comm, request, ierror erzeugt
ein Handle
für das Empfangen
einer
Nachricht
MPI_Reduce(sendbuf, recvbuf, count, datatype, op, root, comm, ierror) sendbuf(*), recvbuf(*) integer count, datatype, op, root, comm, ierror kombiniert (reduziert) die Werte aller beteiligten Prozesse kators zu einem einzigen Wert, den der Prozess mit Rang
eines gegebenen 'root' erhält
Kommuni-
MPI_Reduce_scatter(sendbuf, recvbuf, recvcounts, datatype, op, comm, ierror) sendbuf(*), recvbuf(*) integer recvcounts(*), datatype, op, comm, ierror kombiniert (reduziert) die Werte aller beteiligten Prozesse kators zu einem Ergebnisvektor und verteilt die Ergebnisse
eines gegebenen Kommunian alle diese Prozesse
MPI_Request_free(request, ierror) integer request, ierror gibt das Anfrageobjekt
einer Kommunikation
frei
MPI_Rsend(buf, count, datatype, dest, tag, comm, ierror) buf(*) integer count, datatype, dest, tag, comm, ierror grundlegende
Senderoutine
im
Readymodus
MPI_Rsend_init(buf, count, datatype, dest, tag, comm, request, ierror) buf(*) integer count, datatype, dest, tag, comm, request, ierror erstellt
ein Handle
für das Senden
einer Nachricht
im
Readymodus
MPI_Scan(sendbuf, recvbuf, count, datatype, op, comm, ierror) sendbuf(*), recvbuf(*) integer count, datatype, op, comm, ierror berechnet
einen
„Scan"
(partielle
Datenreduktionen)
auf einer Menge
von
Prozessen
Α Zusammenfassung der MPI-l-Routinen
302
MPI_Scatter(sendbuf, sendcount, sendtype, recvbuf, recvcount, recvtype, root, comm, ierror) sendbuf(*), recvbuf(*) integer sendcount, sendtype, recvcount, recvtype, root, comm, ierror verteilt Daten vom Prozess mit Rang 'root' an alle beteiligten Prozesse eines Kommunikators
gegebenen
MPI_Scatterv(sendbuf, sendcounts, displs, sendtype, recvbuf, recvcount, recvtype, root, comm, ierror) sendbuf(*), recvbuf(*) integer sendcounts(*), displs(*), sendtype, recvcount, recvtype, root, comm, ierror verteilt Daten vom Prozess mit Rang 'root' an alle beteiligten Prozesse eines gegebenen Kommunikators; es können, anders als bei MPI_Scatter, unterschiedlich viele Daten je Prozess verteilt werden MPI_Send(buf, count, datatype, dest, tag, comm, ierror) buf(*) integer count, datatype, dest, tag, comm, ierror grundlegende
Senderoutine
MPI_Send_init(buf, count, datatype, dest, tag, comm, request, ierror) buf(*) integer count, datatype, dest, tag, comm, request, ierror erstellt ein Handle für das Senden einer Nachricht im
Standardmodus
MPI_Sendrecv(sendbuf, sendcount, sendtype, dest, sendtag, recvbuf, recvcount, recvtype, source, recvtag, comm, status, ierror) sendbuf(*), recvbuf(*) integer sendcount, sendtype, dest, sendtag, recvcount, recvtype, source, recvtag, comm, status(*), ierror sendet und empfängt eine
Nachricht
MPI_Sendrecv_replace(buf, count, datatype, dest, sendtag, source, recvtag, comm, status, ierror) buf(*) integer count, datatype, dest, sendtag, source, recvtag, comm, status(*), ierror sendet und empfängt eine Nachricht unter Verwendung nur eines
Puffers
MPI_Ssend(buf, count, datatype, dest, tag, comm, ierror) buf(*) integer count, datatype, dest, tag, comm, ierror grundlegende
synchrone
Senderoutine
303
Α.2 Fortran-Routinen
MPI_Ssend_in.it(buf, count, datatype, dest, tag, comm, request, ierror) buf(*) integer count, datatype, dest, tag, comm, request, ierror erstellt ein Handle für ein synchrones
Senden
einer
Nachricht
MPI_Start(request, ierror) integer request, ierror startet eine Kommunikation
mit einem langlebigen
Anfrageobjekt
MPI_Startall(count, array_of.requests, ierror) integer count, array.of.requests(*), ierror startet eine Menge von Kommunikationen
mit langlebigen
Anfrageobjekten
MPI_Test(request, flag, status, ierror) integer request, status(*), ierror logical flag testet auf das Ende einer Sende- oder
Empfangsroutine
MPI_Testall(count, array_of.requests, flag, array.of.statuses, ierror) integer count, array.of.requests(*), array.of.statuses(MPI.STATUS.SIZE,*), ierror logical flag testet auf das Ende aller vorher gestarteten
Kommunikationen
MPI.Testany(count, array.of.requests, index, flag, status, ierror) integer count, array.of.requests(*), index, status(*), ierror logical flag testet auf das Ende einer beliebigen vorher gestarteten
Kommunikation
MPI.Testsome(incount, array.of.requests, outcount, array.of.indices, array.of.statuses, ierror) integer incount, array.of.requests(*), outcount, array.of.indices(*), array.of.statuses(MPI.STATUS.SIZE,*), ierror testet auf das Ende mehrerer
gegebener
Kommunikationen
MPI.Test.cancelled(status, flag, ierror) integer status(*), ierror logical flag testet, ob eine Anfrage abgebrochen
wurde
MPI.Topo.test(comm, top.type, ierror) integer comm, top.type, ierror ermittelt den Typ einer zum gegebenen eine gibt)
Kommunikator
gehörenden
Topologie (falls es
304
Α Zusammenfassung der MPI-l-Routinen
MPI_Type_commit(datatype, ierror) integer datatype, ierror gibt dem System einen Datentyp bekannt MPI.Type.contiguous(count, oldtype, newtype, ierror) integer count, oldtype, newtype, ierror erzeugt einen neuen Datentyp, der aus einer gegebenen Anzahl von aneinander Elementen des gegebenen Datentyps besteht
gefügten
MPI_Type_create_hindexed(count, array_of_blocklengths, array_of.displacements, oldtype, newtype, ierror) integer count, array_of_blocklengths(*), oldtype, newtype, ierror integer (kind=MPI_ADDRESS_KIND) array.of.displacements(*) MPI-2: erzeugt einen indizierten Datentyp mit in Bytes angegebenen
Verschiebungen
MPI_Type_create_hvector(count, blocklength, stride, oldtype, newtype, ierror) integer count, blocklength, oldtype, newtype, ierror integer (kind=MPI_ADDRESS_KIND) stride MPI-2: erzeugt einen („strided") bungen
Vektordatentyp mit in Bytes angegebenen
Verschie-
MPI_Type_create_struct(count, array_of_blocklengths, array_of.displacements, array.of.types, newtype, ierror) integer count, array.of_blocklengths(*), array_of_types(*), newtype, ierror integer (kind=MPI_ADDRESS_KIND) array.of.displacements(*) MPI-2: erzeugt einen
Struktur-Datentyp
MPI_Type_extent(datatype, extent, ierror) integer datatype, extent, ierror gibt die tatsächlich zur Speicherung Veraltet! Zu ersetzen durch
benötigte Größe des gegebenen Datentyps
zurück
MPI_Type_get_extent
MPI.Type.free(datatype, ierror) integer datatype, ierror kennzeichnet
das Datentypobjekt als freigebbar
MPI_Type_get_extent(datatype, lb, extent, ierror) integer datatype, ierror integer (kind=MPI_ADDRESS_KIND) lb, extent MPI-2: gibt die untere Schranke und die tatsächlich zur Speicherung des gegebenen Datentyps zurück
benötigte
Größe
305
Α. 2 Fortran-Routinen
MPI_Type_hindexed(count, array_of_blocklengths, array_of.displacements, oldtype, newtype, ierror) integer count, array_of_blocklengths(*), array_of.displacements(*), oldtype, newtype, ierror erzeugt einen indizierten Datentyp mit in Bytes angegebenen Veraltet! Zu ersetzen durch
Verschiebungen
MPI_Type_create_hindexed
MPI_Type_hvector(count, blocklength, stride, oldtype, newtype, ierror) integer count, blocklength, stride, oldtype, newtype, ierror erzeugt einen („strided")
Vektordatentyp mit in Bytes angegebenen
Veraltet! Zu ersetzen durch
Verschiebungen
MPI_Type_create_hvector
MPI_Type_indexed(count, array_of_blocklengths, array_of.displacements, oldtype, newtype, ierror) integer count, array_of_blocklengths(*), array_of_displacements(*), oldtype, newtype, ierror erzeugt einen indizierten
Datentyp
MPI_Type_lb(datatype, displacement, ierror) integer datatype, displacement, ierror gibt die untere Schranke eines Datentyps Veraltet! Zu ersetzen durch
zurück
MPI_Type_get_extent
MPI_Type_size(datatype, size, ierror) integer datatype, size, ierror gibt die Anzahl der Bytes zurück, die im Datentyp durch Elemente der Verschiebungen) tatsächlich belegt sind
(ohne
Aufrechnung
MPI_Type_struct(count, array_of_blocklengths, array_of.displacements, array_of_types, newtype, ierror) integer count, array_of_blocklengths(*), array_of„displacements(*), array_of_types(*), newtype, ierror erzeugt einen
Struktur-Datentyp
Veraltet! Zu ersetzen durch
MPI_Type_create_struct
MPI_Type_ub(datatype, displacement, ierror) integer datatype, displacement, ierror gibt die obere Schranke eines Datentyps Veraltet! Zu ersetzen durch
zurück
MPI_Type_get_extent
306
Α Zusammenfassung der MPI-l-Routinen
MPI_Type_vector(count, blocklength, stride, oldtype, newtype, ierror) integer count, blocklength, stride, oldtype, newtype, ierror erzeugt einen
(„strided")
Vektordatentyp
MPI_Unpack(inbuf, insize, position, outbuf, outcount, datatype, comm, ierror) inbuf(*), outbuf(*) integer insize, position, outcount, datatype, comm, ierror entpackt
Daten aus einem zusammenhängenden
Puffer
MPI.Wait(request, status, ierror) integer request, status(*), ierror wartet auf das Ende einer Sende-
oder
Empfangsoperation
MPI_Waitall(count, array_of.requests, array_of_statuses, ierror) integer count, array.of.requests(*), array_of.statuses(MPI_STATUS_SIZE,*), ierror wartet auf das Ende aller aufgelisteten
Kommunikationsoperationen
MPI_Waitany(count, array_of.requests, index, status, ierror) integer count, array_of_requests(*), index, status(*), ierror wartet auf das Ende einer der spezifizierten
Kommunikations
Operationen
MPI_Waitsome(incount, array_of.requests, outcount, array_of.indices, array_of.statuses, ierror) integer incount, array_of.requests(*), outcount, array.of.indices(*), array.of.statuses(MPI_STATUS_SIZE,*), ierror wartet auf das Ende einer oder mehrerer
gegebener
Kommunikationsoperationen
double precision MPI.WtickQ liefert
die Genauigkeit
(Taktdauer
der Uhr) von MPI_Wtime
in
double precision M P I . W t i m e O gibt eine auf dem aufrufenden
Prozessor
vergangene
Zeit
zurück
Sekunden
307
Α.3 C++-Methoden
Α.3
C++-Methoden
In diesem Abschnitt sind die C++-Methoden nach [Mes98] beschrieben. Um Platz zu sparen, wird der Bezeichner MPI:: für den Namensraum weggelassen. void Comm::Abort(int errorcode)
beendet die
MPI-Ausführungsumgebung
void Intracomm::Allgather(const void* sendbuf, int sendcount, const Datatype& sendtype, void* recvbuf, int recvcount, const Datatypefe recvtype) const
sammelt Daten von allen beteiligten Prozessen des Kommunikators alle diese Prozesse
und verteilt sie an
void Intracomm::Allgatherv(const void* sendbuf, int sendcount, const Datatype& sendtype, void* recvbuf, const int recvcounts[], const int displs[], const Datatypefe recvtype) const
sammelt Daten von allen beteiligten Prozessen des Kommunikators und übergibt sie an alle diese Prozesse (im Unterschied zu Intracomm::Allgather dürfen unterschiedlich viele Daten von den einzelnen Prozessen aufgesammelt werden) void Intracomm::Allreduce(const void* sendbuf, void* recvbuf, int count, const Datatypefe datatype, const 0p& op) const
kombiniert Werte von allen beteiligten Prozessen des Kommunikators Ergebnis an alle diese Prozesse
und verteilt das
void Intracomm::Alltoall(const void* sendbuf, int sendcount, const Datatypefe sendtype, void* recvbuf, int recvcount, const Datatype& recvtype) const
sendet Daten von allen beteiligten Prozessen des Kommunikators
an alle diese Prozesse
void Intracomm::Alltoallv(const void* sendbuf, const int sendcounts[], const int sdispls[], const Datatypefe sendtype, void* recvbuf, const int recvcounts[], const int rdispls[], const Datatypefe recvtype) const
sendet Daten, zusammen mit einer Verschiebung, von allen beteiligten Prozessen des Kommunikators an alle diese Prozesse void Attach_buffer(void* buffer, int size)
legt einen anwenderdefinierten
Puffer für eine Sendeoperation
an
void Intracomm::Barrier() const
blockiert den aufrufenden aufgerufen haben
Prozess, bis alle Prozesse des Kommunikators
diese Routine
Α Zusammenfassung der MPI-l-Routinen
308
void Intracomm::Beast(void* buffer, int count, const Datatype& datatype, int root) const
schickt eine Nachricht vom Prozess mit Rang „root" an alle anderen Prozesse Gruppe des Kommunikators
einer
void Comm::Bsend(const void* buf, int count, const Datatypefe datatype, int dest, int tag) const
grundlegende
Senderoutine
mit anwenderdefinierter
Pufferung
Prequest Comm::Bsend_init(const void* buf, int count, const Datatypefe datatype, int dest, int tag) const
erstellt ein Handle für das Senden einer Nachricht
mit
Pufferung
void Request::Cancel() const
bricht eine Kommunikationsanfrage
ab
Comm& Comm::Clone() const
wie Dup(), aber Rückgabe per
Referenz
void Datatype::Commit()
gibt dem System einen Datentyp
bekannt
int Comm::Compare(const Comm& comml, const Comm& comm2)
vergleicht zwei gegebene
Kommunikatoren
int Group::Compare(const Groupft groupl, const Group& group2)
vergleicht zwei gegebene
Gruppen
void Compute_dims(int nnodes, int ndims, int dims[])
erzeugt eine Aufteilung einer gegebenen Anzahl von Prozessen sisches Gitter
auf ein gegebenes
karte-
Intracomm Intracomm::Create(const Groupft group) const
erzeugt zu einer gegebenen Gruppe, die Untermenge ist, einen neuen Kommunikator
der Gruppe des
Kommunikators
Cartcomm Intracomm::Create_cart(int ndims, const int dims[], const bool periods [], bool reorder) const
erzeugt aus den gegebenen
Topologiedaten
einen neuen
Kommunikator
Datatype Datatype: :Create.contiguous(int count) const
erzeugt einen neuen Datentyp, der aus einer gegebenen Anzahl von aneinander Elementen des Datentyps besteht
gefügten
Α.3
309
C++-Methoden
Graphcomm Intracomm: :Create_graph(int rmod.es, const int index [] , const int edges [], bool reorder) const erzeugt aus den Eigenschaften kator
einer gegebenen
Graphtopologie
einen neuen
Kommuni-
Datatype Datatype: :Create_hindexed(int count, const int array_of _blocklengths [] , const Aint array_of-displacements [] ) const erzeugt einen indizierten
Datentyp
mit in Bytes angegebenen
Verschiebungen
Datatype Datatype: :Create_hvector(int count, int blocklength, Aint stride) const erzeugt einen („strided")
Vektordatentyp
mit in Bytes
angegebenen
Verschiebungen
Datatype Datatype: :Create_Lndexed(int count, const int array_of _blocklengths [] , const int array_of-displacements [] ) const erzeugt einen indizierten
Datentyp
Intercomm Intracomm: :Create-intercomm(int localJLeader, const Comm& peer_comm, int remote JLeader, int tag) const erzeugt aus zwei gegebenen
Intrakommunikatoren
einen
Interkommunikator
int Create_keyval (const Copy J u n c t i o n * copyjfn, const DeleteJunction* deletejfη, void* extra_state) generiert
einen neuen
Attributschlüssel
Datatype Datatype: :Create_struct(int count, const int array_of_blocklengths [] , const Aint array_of-displacements [] , const Datatype array_of-types [] ) erzeugt einen
Struktur-Datentyp
Datatype Datatype: :Create_vector(int count, int blocklength, int stride) const erzeugt einen („strided")
Vektordatentyp
void Comm::Deletej.ttr(int keyval) const löscht den mit einem gegebenen
Schlüssel
assoziierten
Attributwert
int Detach-buffer(void*& buffer) gibt einen vorhandenen
Puffer frei (zur Verwendung
in Comm::Bsend
u. a.)
Α Zusammenfassung der MPI-l-Routinen
310
Group Group::Difference(const Groupfc groupl, const Groupfe group2) erzeugt aus der Differenz
zweier gegebener
Gruppen
eine neue
Gruppe
Intercomm Intercomm::Dup() const dupliziert nen
den Interkommunikator
mit allen seinen
zwischengespeicherten
Informatio-
zwischengespeicherten
Informatio-
Intracomm Intracomm::Dup() const dupliziert nen
den Intrakommunikator
mit allen seinen
Group Group::Excl(int n, const int ranks[]) const erzeugt eine neue Gruppe aus den nicht aufgelisteten
Mitgliedern
der
Gruppe
void Finalize() beendet
die
MPI-Ausführungsumgebung
void Comm::Free() gibt das Kommunikatorobjekt
(zum Löschen)
frei
void Datatype::Free() kennzeichnet
das Datentypobjekt
als
freigebbar
void Errhandler::Free Ο gibt die MPI-Fehlerbehandlungsroutine
frei
void Group::Free() gibt die Gruppe
frei
void Op::Free() gibt ein Handle für eine anwenderdefinierte
Kombinationsfunktion
frei
void Request::Free() gibt das Anfrageobjekt
einer Kommunikation
frei
void Free_keyval(int& keyval) gibt den Attributschlüssel
eines zwischengespeicherten
Kommunikatorattributs
frei
void Intracomm::Gather(const void* sendbuf, int sendcount, const Datatype& sendtype, void* recvbuf, int recvcount, const DatatypeÄ recvtype, int root) const sammelt Daten von allen beteiligten beim Prozess mit Rang 'root'
Prozessen
des Kommunikators
und speichert
sie
311
A.3 C++-Methoden
void Intracomm::Gatherv(const void* sendbuf, int sendcount, const Datatype& sendtype, void* recvbuf, const int recvcounts[], const int displs[], const Datatype& recvtype, int root) const
sammelt Daten von allen beteiligten Prozessen des Kommunikators und speichert sie beim Prozess mit Rang 'root'; es können, anders als mit Intracomm:.-Gather, unterschiedlich viele Daten von jedem Prozess gespeichert werden Aint Get_address(const void* location)
liefert die Adresse eines gegebenen Speicherplatzes bool Comm::Get_attr(int keyval, void* attribute_val) const
gibt den zu einem gegebenen Schlüssel gehörenden Attributwert
zurück
int Cartcomm: :Get_cart_j:ank(const int coords []) const
ermittelt zu einer gegebenen Position in der kartesischen Topologie den Rang des Prozesses im Kommunikator void Cartcomm: :Get_coords(int rank, int maxdims, int coords[]) const
ermittelt zu einem gegebenen Rang eines Prozesses in dem Kommunikator die Koordinaten dieses Prozesses in der zu dem Kommunikator gehörenden kartesischen Topologie int Status: :Get_count(const Datatypeft datatype) const
gibt die Anzahl der empfangenen Elemente vom gegebenen Datentyp aus der gegebenen Status- Variablen einer Empfangsoperation zurück (für die vordefininierten Datentypen gleiches Ergebnis wie Status::Get_elements) int Cartcomm::Get_dim() const
gibt die Anzahl der Dimensionen Topologie zurück
der zu dem Kommunikator
gehörenden
kartesischen
void Graphcomm: :Get_dims(int nnodes[], int nedges[]) const
gibt die Anzahl der Knoten und der Kanten der zum Kommunikator topologie zurück
gehörenden Graph-
int Status: :Get_elements(const Datatypefc datatype) const
gibt die Gesamtanzahl der empfangenen Basiselemente bezüglich des gegebenen Datentyps und der Status- Variablen einer Empfangsoperation zurück (für die vordefininierten Datentypen gleiches Ergebnis wie Status::GeLcount) Errhandler Comm::Get_errhandler() const
liefert die zum Kommunikator
gehörende
Fehlerbehandlungsroutine
int Status::Get_error() const
liefert die Fehlerinformationen
aus dem
Status-Objekt
312
Α Zusammenfassung der MPI-l-Routinen
int Exception: :Get_error_class() const gibt die Fehlerklasse
der MPI-Ausnahme
zurück
int Get_error_class(int errorcode) gibt die zu einem gegebenen
Fehlercode
gehörende
Fehlerklasse
zurück
int Exception: :Get_error_code() const gibt den Fehlercode
der MPI-Ausnahme
zurück
const char* Exception: :Get_error_string() const gibt den zu einer gegebenen
MPI-Ausnahme
gehörenden
Text als String
zurück
void Get_error_string(int errorcode, char* name, int& resultlen) gibt den zu einem gegebenen
Fehlercode
gehörenden
Text als String
zurück
des Datentyps
zurück
Aint Datatype: :Get_extent() const gibt die tatsächlich
zur Speicherung
benötigte
Größe
Group Comm::Get-group() const gibt die mit dem Kommunikator
assoziierte
Grippe
zurück
Aint Datatype::Get_lb() const gibt die untere Schranke
des Datentyps
zurück
void Graphcomm: : Get-neighbors (int rank, int maxneighbors, int neighbors []) const gibt die Nachbarn
eines gegebenen
Knotens
in der Graphtopologie
zurück
int Graphcomm: :Get_neighbors_count(int rank) const gibt die Anzahl der Nachbarn
eines gegebenen
Knotens
in der Graphtopologie
void Get-processor_name(char* name, int& resultlen) liefert
den
Prozessornamen
int Comm::Getjrank() const ermittelt
den Rang des aufrufenden
Prozesses
im
Kommunikator
int Group::Get_rank() const gibt den Rang des aufrufenden
Prozesses
in der Gruppe
zurück
Group Intercomm: :Getjremote_group() const gibt die entfernte
Gruppe des Interkommunikators
zurück
zurück
Α.3
C++-Methoden
313
int Intercomm: :Getjremote_size() const
gibt die Anzahl der Prozesse in der entfernten
Gruppe des Interkommunikators
zurück
int Comm::Get_size() const
ermittelt die Größe der mit dem Kommunikator
assoziierten
Gruppe
int Datatype: :Get_size() const
gibt die Anzahl der Bytes zurück, die im Datentyp durch Elemente (ohne der Verschiebungen) tatsächlich belegt sind
Aufrechnung
int Group::Get_size() const
gibt die Größe der Gruppe zurück int Status: :Get_source() const
liefert die Information
zum Absender der Nachricht aus dem
Status-Objekt
int Status::Get_tag() const
liefert die Information
zum Etikett aus dem
Status-Objekt
void Cartcomm: : Get_topo (int maxdims, int dims[], bool periods [] , int coords[]) const
gibt die mit dem Kommunikator pologie zurück
assoziierten Informationen
zu seiner kartesischen
void Graphcomm: :Get_topo(int maxindex, int maxedges, int index[] , int edges[]) const
liefert Informationen
zur Graphtopologie des
Kommunikators
int Comm::Get_topology() const
ermittelt den Typ der zum Kommunikator
gehörenden Topologie (falls es eine gibt)
Aint Datatype::Get_ub() const
gibt die obere Schranke des Datentyps
zurück
void Get_version(int& version, int& subversion)
gibt die MPI-Version
zurück
Request Comm::Ibsend(const void* buf, int count, const Datatypefe datatype, int dest, int tag) const
startet ein nichtblockierendes
Senden mit
Pufferung
Group Group::Incl(int n, const int ranks[]) const
erzeugt eine neue Gruppe aus den aufgelisteten Mitgliedern der Gruppe
To-
Α Zusammenfassung der MPI-l-Routinen
314
void Errhandler::Init(const Handler Junction* function) erzeugt eine Fehlerbehandlungsroutine
für
MPI
void I n i t Ο initialisiert
die
MPI-Ausführungsumgebung
void Init(int& arge, char**& argv) initialisiert
die Μ
PI-Ausführungsumgebung
void Op: : Init (User Junction* function, bool commute) erzeugt ein Handle für die anwenderdefinierte
Kombinationsfunktion
Group Group::Intersect(const Groupft groupl, const Groupfe group2) erzeugt eine neue Gruppe aus dem Durchschnitt
zweier gegebener
Gruppen
bool Comm::Iprobe(int source, int tag) const nichtblockierende
Anfrage,
ob eine Nachricht
vorhanden
ist
bool Comm::Iprobe(int source, int tag, Statusfe status) const nichtblockierende
Anfrage,
ob eine Nachricht
vorhanden
ist
Request Comm::Irecv(void* buf, int count, const Datatypefe datatype, int source, int tag) const startet
ein nichtblockierendes
Empfangen
einer
Nachricht
Request Comm::Irsend(const void* buf, int count, const Datatypeft datatype, int dest, int tag) const startet
ein nichtblockierendes
Senden
einer Nachricht
im
Readymodus
bool Status::Is_cancelled() const testet,
ob eine Anfrage
abgebrochen
wurde
bool Is_initialized() stellt fest,
ob MPI_Init
aufgerufen
wurde
bool Comm::Is_inter() const testet,
ob ein Kommunikator
ein Interkommunikator
ist
Request Comm::Isend(const void* buf, int count, const Datatype& datatype, int dest, int tag) const startet
ein nichtblockierendes
Senden
einer
Nachricht
315
Α.3 C++-Methoden
Request Comm::Issend(const void* buf, int count, const Datatypefe datatype, int dest, int tag) const
startet ein nichtblockierendes synchrones Senden einer Nachricht int Cartcomm: :Map(int ndims, const int dims[], const bool p e r i o d s ü ) const
bestimmt einen neuen Rang für den aufrufenden Prozess bezüglich der kartesischen Topologie unter Berücksichtigung der zugrunde liegenden Hardware int Graphcomm::Map(int nnodes, const int indext], const int e d g e s G ) const
bestimmt einen neuen Rang für den aufrufenden Prozess bezüglich der Graphtopologie unter Berücksichtigung der zugrunde liegenden Hardware Intracomm Intercomm::Merge(bool high) const
erzeugt aus dem Interkommunikator
einen
Intrakommunikator
void Datatype::Pack(const void* inbuf, int incount, void *outbuf, int outsize, int& position, const Comm &comm) const
packt Daten in einen zusammenhängenden
Puffer
int Datatype::Pack_size(int incount, const Commft comm) const
gibt die Größe zurück, die für das Packen des Datentyps benötigt wird void Pcontrol(const int level, ...)
steuert die Programmanalyse void Comm::Probe(int source, int tag) const
blockierende Anfrage, ob eine Nachricht vorhanden ist void Comm::Probe(int source, int tag, Status& status) const
blockierende Anfrage, ob eine Nachricht vorhanden ist Group Group: :Range_excl(int n, const int rängest] [3]) const
erzeugt eine neue Gruppe durch Ausschluss von Rangbereichen der Gruppe Group Group: :RangeJ.ncl(int n, const int rängest] [3]) const
erzeugt eine neue Gruppe, bestehend aus den Elementen der aufgelisteten Rangbereiche der Gruppe void Comm::Recv(void* buf, int count, const Datatypefe datatype, int source, int tag) const
grundlegende
Empfangsroutine
Α Zusammenfassung der MPI-l-Routinen
316
void Comm::Recv(void* buf, int count, const Datatypefe datatype, int source, int tag, Statusfe status) const grundlegende
Empfangsroutine
Prequest Comm::Recv_init(void* buf, int count, const Datatype& datatype, int source, int tag) const erzeugt ein Handle für das Empfangen
einer
Nachricht
void Intracomm::Reduce(const void* sendbuf, void* recvbuf, int count, const Datatype& datatype, const 0p& op, int root) const kombiniert (reduziert) die Werte aller beteiligten Prozesse des Kommunikators einzigen Wert, den der Prozess mit Rang 'root' erhält
zu einem
void Intracomm: :Reduce_scatter(const void* sendbuf, void* recvbuf, int recvcounts [], const Datatypefe datatype, const Opfe op) const kombiniert (reduziert) die Werte aller beteiligten Prozesse des Kommunikators Ergebnisvektor und verteilt die Ergebnisse an alle diese Prozesse
zu einem
void Comm::Rsend(const void* buf, int count, const Datatypefe datatype, int dest, int tag) const grundlegende
Senderoutine
im Readymodus
Prequest Comm: :RsendJ.nit(const void* buf, int count, const Datatypefe datatype, int dest, int tag) const erstellt ein Handle für das Senden einer Nachricht im Readymodus void Intracomm::Scan(const void* sendbuf, void* recvbuf, int count, const Datatypefe datatype, const 0p& op) const berechnet einen „Scan" (partielle Datenreduktionen)
auf einer Menge von Prozessen
void Intracomm::Scatter(const void* sendbuf, int sendcount, const Datatypefe sendtype, void* recvbuf, int recvcount, const Datatypefe recvtype, int root) const verteilt Daten vom Prozess mit Rang 'root' an alle beteiligten Prozesse des kators
Kommuni-
void Intracomm::Scatterv(const void* sendbuf, const int sendcounts [], const int displs[], const Datatypefe sendtype, void* recvbuf, int recvcount, const Datatype& recvtype, int root) const verteilt Daten vom Prozess mit Rang 'root' an alle beteiligten Prozesse des Kommunikators; es können, anders als bei Intracomm::Scatter, unterschiedlich viele Daten je Prozess verteilt werden
317
A.3 C++-Methoden void Comm::Send(const void* buf, int count, const Datatypeft datatype, int dest, int tag) const grundlegende
Senderoutine
Prequest Comm: :Send_init(const void* buf, int count, const Datatypeft datatype, int dest, int tag) const erstellt ein Handle für das Senden einer Nachricht im
Standardmodus
void Comm::Sendrecv(const void *sendbuf, int sendcount, const Datatypeft sendtype, int dest, int sendtag, void *recvbuf, int recvcount, const Datatypeft recvtype, int source, int recvtag) const sendet und empfängt eine
Nachricht
void Comm::Sendrecv(const void *sendbuf, int sendcount, const Datatypeft sendtype, int dest, int sendtag, void *recvbuf, int recvcount, const Datatypeft recvtype, int source, int recvtag, Statusft status) const sendet und empfängt eine
Nachricht
void Comm::Sendrecv_replace(void* buf, int count, const Datatypeft datatype, int dest, int sendtag, int source, int recvtag) const sendet und empfängt eine Nachricht unter Verwendung nur eines
Puffers
void Comm: :Sendrecvjreplace(void* buf, int count, const Datatypeft datatype, int dest, int sendtag, int source, int recvtag, Statusft status) const sendet und empfängt eine Nachricht unter Verwendung nur eines
Puffers
void Comm: :Set_attr(int keyval, const void* attributejval) const speichert den zu einem gegebenen Schlüssel gehörenden
Attributwert
void Comm::Set_errhandler(const Errhandlerft errhandler) legt eine Fehlerbehandlungsroutine
für den Kommunikator
void Status::Set_error(int error) setzt die Fehlerinformation
im
Status-Objekt
void Status::Set_source(int source) setzt den Absender im
Status-Objekt
void Status::Set_tag(int tag) setzt den Wert für das Etikett im
Status-Objekt
fest
Α Zusammenfassung der MPI-l-Routinen
318
void Cartcomm::Shift(int direction, int disp, int& rank_source, intfe rank-dest) const gibt zu einer gegebenen Dimension (und Verschiebung), in der Daten innerhalb der zum Kommunikator gehörenden kartesischen Topologie zu verschicken sind, den Rang des Prozesses, von dem empfangen werden soll, und den Rang des Prozesses, an den gesendet werden soll, zurück Intracomm Intracomm::Split(int color, int key) const erzeugt neue Kommunikatoren
in Abhängigkeit
von Farbwerten
und
Schlüsseln
void Comm::Ssend(const void* buf, int count, const Datatypefc datatype, int dest, int tag) const grundlegende
synchrone
Senderoutine
Prequest Comm: :SsendJ.nit(const void* buf, int count, const Datatype& datatype, int dest, int tag) const erstellt ein Handle für ein synchrones
Senden einer
Nachricht
void Prequest::Start() startet eine Kommunikation
mit einem langlebigen
Anfrageobjekt
void Prequest: : St art all (int count, Prequest array.of-requests [] ) startet eine Menge von Kommunikationen
mit langlebigen
Anfrageobjekten
Cartcomm Cartcomm: : Sub (const bool remain_dims [] ) const teilt den mit kartesischer Topologie unterlegten Kommunikator in Untergruppen, die kartesische Untergitter mit kleinerer Dimension bilden, und gibt den neuen Kommunikator, in dem der Prozess enthalten ist, zurück bool Request::Test() testet auf das Ende einer Sende- oder
Empfangsroutine
bool Request::Test(Status& status) testet auf das Ende einer Sende- oder
Empfangsroutine
bool Request:: Testall (int count, Request array _of .requests [] ) testet auf das Ende aller vorher gestarteten
Kommunikationen
bool Request: :Testall(int count, Request array_of-requests[] , Status array_of .statuses [] ) testet auf das Ende aller vorher gestarteten
Kommunikationen
319
A.3 C++-Methoden
bool Request::Testany(int count, Request array_of.requests[] , int& index)
testet auf das Ende einer beliebigen vorher gestarteten
Kommunikation
bool Request: :Testany (int count, Request arr ay .of .requests Π , int& index, Statusft status)
testet auf das Ende einer beliebigen vorher gestarteten
Kommunikation
int Request: :Testsome(int incount, Request array.of jrequests[] , int array_of-indices [] )
testet auf das Ende mehrerer gegebener
Kommunikationen
int Request::Testsome(int incount, Request array .of ^requests [] , int array_of -indices [] , Status array .of .statuses [] )
testet auf das Ende mehrerer gegebener
Kommunikationen
void Group: : Translate .ranks (const Groupft groupl, int n, const int r a n k s l G , const Groupft group2, int ranks2[])
liefert für die über ihre Ränge aufgelisteten Prozesse einer gegebenen Gruppe die korrespondierenden Ränge in einer anderen gegebenen Gruppe Group Group::Union(const Groupft groupl, const Group& group2)
bildet eine Gruppe durch Vereinigung zweier gegebener Gruppen void Datatype::Unpack(const void* inbuf, int insize, void *outbuf, int outcount, intft position, const Commfe comm) const
entpackt Daten aus einem zusammenhängenden
Puffer
void Request::Wait()
wartet auf das Ende einer Sende- oder
Empfangsoperation
void Request::Wait(Statusfe status)
wartet auf das Ende einer Sende- oder
Empfangsoperation
void Request: :Waitall(int count, Request array.of-requests[] )
wartet auf das Ende aller aufgelisteten
Kommunikationsoperationen
void Request: :Waitall(int count, Request array.of-requests[] , Status array .of .statuses [] )
wartet auf das Ende aller aufgelisteten
Kommunikationsoperationen
int Request: :Waitany( int count, Request arr ay .of -requests [] )
wartet auf das Ende einer der spezifizierten
Kommunikationsoperationen
Α Zusammenfassung der MPI-l-Routinen
320
int Request: :Waitany(int count, Request array_of^requests[] , Statusft status)
wartet auf das Ende einer der spezifizierten
Kommunikationsoperationen
int Request::Waitsome(int incount, Request array_of.requests[], int array_of .indices [] )
wartet auf das Ende einer oder mehrerer gegebener
Kommunikationsoperationen
int Request::Waitsome(int incount, Request array_of^requests[], int array_of-indices [] , Status array_of-statuses [])
wartet auf das Ende einer oder mehrerer gegebener
Kommunikationsoperationen
double W t i c k O
liefert die Genauigkeit
(Taktdauer
der Uhr) von Wtime in
Sekunden
double W t i m e O
gibt eine auf dem aufrufenden
Prozessor
vergangene Zeit zurück
Β
Die MPI-Implementierung MPICH
Im Kapitel 8 haben wir einige Methoden vorgestellt, mit deren Hilfe eine Implementierung von MPI über einer einfachen Kommunikationsschicht erfolgen kann. Wir wollen nun eine konkrete MPI-Implementierung beschreiben, die oberhalb verschiedener Kommunikationsschichten aufsetzt und weitere Aspekte in Bezug auf eine komfortable parallele Programmierumgebung berücksichtigt. MPICH ist eine vollständige Implementierung von MPI-1 und ist frei zugänglich über www-new.mcs.anl.gov/mpi/mpich und f t p : / / f t p . m c s . a n l . g o v / p u b / m p i . Der Name wurde aus MPI und Chamäleon gebildet. Chamäleon steht zum einen dafür, dass MPICH in einer Vielzahl verschiedener Umgebungen lauffähig ist (es kann „seine Farbe anpassen") und zum anderen, weil für die Erstimplementierung von MPICH das Message-Passing-Portierungssystem Chameleon [GS93a] benutzt wurde. MPICH wird „Em Pea Eye See Aych", nicht „Emm Pitch", ausgesprochen.
B.l
Eigenschaften der Modellimplementierung
Obwohl der MPI-Standard festlegt, wie Anwenderprogramme aussehen sollen, unterscheiden sich reale Implementierungen der MPI-Bibliothek in vielfältiger Weise. In diesem Abschnitt wollen wir einige der charakteristischen Eigenschaften der MPICHImplementierung beschreiben. MPICH wurde ursprünglich am Argonne National Laboratory und an der Mississippi State University erarbeitet und wird am Argonne National Laboratory weiter entwickelt.
B.l.l
Besonderheiten für den Anwender
Die Modellimplementierung ist eine vollständige Umsetzung des MPI-1.2 Standards und über das Internet frei erhältlich (http://www-new.mcs.anl.gov/mpi/mpich). Die Autoren von MPICH waren Mitglieder des MPI-Forums und behielten die Entwicklung des Standards im Auge [GL92a], Auch wurden zahlreiche Merkmale von MPI-2 mit aufgenommen. Die Autoren haben die Absicht, in gleicher Weise eine vollständige Implementierung von MPI-2 zur Verfügung zu stellen. MPICH wird mit dem kompletten Quellcode ausgegeben. Sowohl die C- als auch die Fortran-Versionen der Routinen sind Bestandteil von MPICH, ebenso die an der Universität von Notre Dame [not99] entwickelten C++-Versionen. Fortran90-Module werden ebenfalls unterstützt. Eine vollständige Dokumentation wird in Form von Unix-Manualseiten ('nroff' unter Benutzung der 'man'-Makros) mit ausgeliefert, auf die über das
322
Β Die MPI-Implementierung MPICH
Kommando man oder andere man-Seiten-Betrachter zugegriffen werden kann. Darüber hinaus gibt es die Dokumentation in HTML für den Zugriff mit einem Webbrowser sowie im Postscript-Format, um sie ausdrucken zu können. Ein Anwendermanual [GL96b] enthält detailliertere Informationen zur Arbeit mit MPICH und den zugehörigen Werkzeugen. MPICH kann auf eine große Vielfalt von Parallelrechnern und Workstation-Netzwerken portiert werden. Berechnungen in heterogenen Umgebungen werden so unterstützt, dass bei der Ausführung in einer homogenen Umgebung kein Overhead entsteht. Die Implementierung enthält alle in diesem Buch beschriebenen Werkzeuge, eingeschlossen die MPE-Bibliothek, die Familie der Werkzeuge upshot-jumpshot zur Leistungsvisualisierung sowie die Werkzeuge zum Aufbau von Bibliotheken für die Programmanalyse. Die Konfiguration wird mit Hilfe des GNU-Programms a u t o c o n f , das eine hohe Flexibilität bei der Installation bietet, ausgeführt. Das zum Paket gehörende Installationshandbuch [GL96a] enthält genaue Anweisungen für die Installation. Zusätzlich zu den Quellen der MPI-Implementierung selbst enthält das MPICH-Paket zahlreiche MPI-Programme. Auch gibt es einen umfangreichen Satz an Tests, mit dem überprüft werden kann, ob die Installation korrekt arbeitet. Mit Programmen für Leistungstests [GL99b] kann der Anwender die Leistung jeder MPI-Implementierung auf den verwendeten Rechnern messen. Schließlich findet man auch einfache Beispielprogramme, wie zur Berechnung von π, sowie komplexere Anwendungen, ζ. B. zur Berechnung der Mandelbrotmenge (mit einer interaktiven graphischen Anzeige für ausgewählte Teilgebiete).
B.1.2
Portierbarkeit
Die Portierbarkeit der MPICH-Implementierung basiert auf dem Konzept einer abstrakten Geräteschnittstelle (engl.: Abstract Device Interface ADI), die Basisdienste, einschließlich Interprozesskommunikation, Prozessstart und -stop, sowie einige weitere Funktionen (ζ. B. Unterstützung für MPI.Wtime) bereit stellt. Dieses ADI hat sich zwar im Verlauf der Zeit weiter entwickelt (siehe Abschnitt B.3), blieb aber stets eine „dünne" Schicht zwischen den MPI-Routinen, wie ζ. B. MPI_Send, und dem Kommunikationsmechanismus des Parallelrechners. Eine Portierung von MPICH auf einen neuen Parallelrechner erfordert lediglich die Implementierung der wenigen vom ADI definierten Routinen. Das ADI selbst ist in Schichten aufgebaut: für eine einfache funktionsfähige Portierung von MPICH auf eine neue Plattform sind nur wenige Routinen zu implementieren. Eine solche einfache Portierung kann verbessert werden (hinsichtlich ihrer Leistungsfähigkeit), indem zusätzliche Routinen implementiert werden, für die das ADI funktionale Versionen bereit stellt. Dieser Teil der Gestaltung von MPICH hat viele Arbeitsgruppen in die Lage versetzt, MPICH als Basis für ihre eigenen MPIImplementierungen zu nutzen und wird ein wesentlicher Teil des MPICH-Designs bleiben.
B.l Eigenschaften der Modellimplementierung
Β. 1.3
323
Effizienz
Die portierbare, frei erhältliche Implementierung MPICH muss natürlich einer proprietären Implementierung einen leichten Leistungsvorsprung überlassen, da sie nicht auf die proprietären maschinenorientierten Systemelemente zugreifen kann. Wir konnten jedoch von Anfang an eine recht beachtenswerte Leistungsfähigkeit erreichen, wie in der Abbildung B.l zu sehen ist. Hier wird die Leistung von MPICH mit einer frühen MPIImplementierung von IBM verglichen, wobei MPICH auf der Grundlage des früheren IBM-Message-Passing-Systems MPL (vorher bekannt als EUI) implementiert wurde. Diese Graphik entstand kurz nach der Freigabe des MPI-Standards 1993. In manchen Fällen kann die MPICH-Implementierung tatsächlich schneller als MPIImplementierungen von Herstellern sein. So ist die MPICH-Implementierung zum Beispiel bei der Verarbeitung von bestimmten abgeleiteten Datentypen schneller, was in der Abbildung B.2 illustriert wird. Diese Erkenntnis ist die Grundlage für unsere Empfehlung am Ende des Kapitels 5, MPI-Datentypen einzusetzen. Die Herangehensweise in MPICH, die Leistungsstärke von MPI-Datentypen zu verbessern, wird in [GLS99b] ausführlicher besprochen. Sie ist ein Beispiel dafür, wie MPICH als Plattform dient, Forschungsergebnisse in effiziente Implementierungen von MPI einfließen zu lassen.
B.1.4
Zusätzliche Programme
Im MPICH-Paket sind zahlreiche nützliche Programme enthalten, von denen viele in diesem Buch eingeführt wurden. Die MPE-Bibliothek mit der Ereignisprotokollierung und einer einfachen parallelen Graphikschnittstelle gehört ebenso dazu (siehe Anhang C) wie das weiter unten beschriebene Programm upshot zur Auswertung von Protokolldateien und dessen Nachfolger jumpshot. Zur Demonstration der Fähigkeiten der MPI-Schnittstelle zur Programmanalyse sind auch drei verschiedene Analysebibliotheken enthalten: zum einfachen Zählen und Aufsummieren der Zeit, zur Visualisierung der Leistung mit jumpshot und zur graphischen Echtzeitanzeige des Nachrichtenverkehrs. Schließlich gibt ein Programm, das „wrapper generator" (deutsch: Hüllengenerator) genannt wird, dem Anwender Unterstützung bei der Erstellung einfacher Analysebibliotheken. Es enthält eine einfache Beschreibung des Codes, der vor und nach der PMPI-Version des Aufrufs auszuführen ist, als Eingabe und generiert den Rest der MPI-„Hülle". Da MPICH von sehr vielen Anwendern eingesetzt wurde, haben etliche Arbeitsgruppen Werkzeuge entwickelt, die mit MPICH arbeiten. Ein Beispiel hierfür ist die Anzeige von Nachrichtenschlangen und die Prozessverwaltung im TotalView-Debugger [CG99] (TotalView kann nun auch in einigen MPI-Herstellerimplementierungen Nachrichtenschlangen anzeigen).
B.l.5
MPICH in der Forschung
Die MPICH-Implementierung wurde so gestaltet, dass sie leicht in andere parallele Rechenumgebungen überführt werden kann. Sowohl in Forschungsprojekten als auch für Rechnerhersteller diente MPICH als Grundlage zur Entwicklung von MPI-Implementierungen. Viele Forschergruppen haben MPICH zum Beispiel dafür verwendet, eine
324
Β Die M P I - I m p l e m e n t i e r u n g M P I C H
Bandwidth
7 ο
10000
20000
30000
40000
50000
Size (bytes)
Abbildung
B.l:
Vergleich der Bandbreite
auf einer IBM
SP1
60000
70000
B . l Eigenschaften der Modellimplementierung
Comm
Perf
for
325
MPI—Type—vector
Abbildung B.2: Vergleich der Bandbreite auf einer SGI Origin 2000 für MPI_Type.vector. Bei kurzen Nachrichten ist MPICH langsamer (die SGI-Implementierung besitzt eine geringere Latenz), optimiert aber den Datentyp MPI.Type.vector
326
Β Die MPI-Implementierung MPICH
Möglichkeit zu bieten, Anwendungen auf ihre eigenen systemnahen Kommunikationsschichten zu portieren. Beispiele hierfür sind Fast Messages [CPL+97, LC97] und Globus [FK97, FGG+98, FK99b]. Andere Projekte haben mit Hilfe von MPICH Probleme bei der Implementierung von MPI untersucht (zu kollektiven Weitverkehrskommunikationen siehe [KHB+99]). Zwei der Buchautoren (Gropp und Lusk) stützten ihre MPI-Forschungen auf MPICH. Zu diesen Untersuchungen gehörten die Implementierung von MPI auf Vektor-Supercomputern [GL97a], MPICH als Beispiel für Entwicklung und Verwaltung verteilbarer Software [GL97b] oder die Verbesserung der Leistung von MPI-Implementierungen [GLS99b].
B.2
Installation und Ausführung der Modellimplementierung
Die Installation von MPICH beginnt mit der Ausführung von c o n f i g u r e , um die passenden make-Dateien für eine Installation zu erzeugen, worauf die Ausführung von make folgt. Details sind im zugehörigen Installationshandbuch [GL96a] zu finden, ebenso ein Abschnitt zur Fehlerbehebung in der Installationsphase. Für Schwierigkeiten, die der Anwender nicht lösen kann, gibt es auf der Seite http://www.mcs.anl.gov/mpi/mpich/buglist-tbl.html eine Liste mit bekannten Fehlern und Korrekturen (engl.: patches). Sollte auch dort keine Hilfe gefunden werden, können Fehlerbeschreibungen an [email protected] geschickt werden. MPI-1 hat keinen Standard zum Start von MPI-Programmen spezifiziert, zum Teil wegen der Unterschiedlichkeit der schon gebräuchlichen Mechanismen. Mit der Verwendung des Startprogramms mpirun versteckt die MPICH-Implementierung diese Unterschiede so weit wie möglich. Inzwischen verfügt MPICH über das Kommando mpiexec, das vom MPI-2-Standard empfohlen wird. Die Ausführung mit mpirun wird aber weiter unterstützt, da es Anwender gibt, die auf Basis von mpirun geschriebene Scripts verwenden.
B.3
Die Geschichte von MPICH
MPI ist wohl das erste parallele Programmiermodell für wissenschaftliche Berechnungen, das es lange genug gibt, um eine zweite Auflage von Büchern zu dieser Thematik zu rechtfertigen (beginnend mit [SOHL+98]). MPICH gibt es lange genug, um eine eigene Geschichte zu haben. In der Entwicklung von MPICH gab es drei Implementierungsgenerationen, die drei Generationen einer abstrakten Geräteschnittstelle reflektierten. Die erste Entwicklungsstufe des ADI war mit Blick auf die Effizienz auf massiv parallelen Rechnern, die über
Β . 3 Die Geschichte von MPICH
327
ein proprietäres Message-Passing-System für die Interprozesskommunikation verfügten, ausgelegt. Dieses ADI bot eine gute Leistung bei geringem Overhead, wie in Abbildung B . l zu sehen ist, in der MPICH mit einer frühen einfachen MPI-Implementierung (MPI-F) [FHP+94] und dem Message-Passing-System EUI von I B M verglichen wird. Diese frühe Version von MPICH war auf verschiedenartige Systeme portierbar, ζ. B. auf massiv parallele Rechner wie Intel i P S C / 8 6 0 , Delta, Paragon und n C U B E , die eine Implementierung des ADI auf Basis der proprietären Message-Passing-Bibliotheken von Intel und n C U B E nutzten. Eine andere Implementierung des ADI verwendete Chameleon [GS93a], das selbst ein portierbares Message-Passing-System ist. Chameleon unterstützte viele verschiedene Kommunikationsbibliotheken, darunter p4 [ B B D + 8 7 ] als eine der bekanntesten. p4 ermöglicht die Kommunikation zwischen heterogenen Rechnern über große Entfernungen (auch über Kontinente hinweg) ebenso wie eine MehrmethodenKommunikation (engl.: multi-method), bei der zum Beispiel sowohl mit T C P zwischen Rechnern, die über ein Netzwerk verbunden sind, als auch mit einer spezialisierten Kommunikation, wie z . B . Zugriff auf gemeinsamen Speicher in einem SMP-System oder proprietärem Message-Passing in einem Parallelrechner, gearbeitet wird. MPICH unterstützt also von Anfang an eine heterogene Mehrmethoden-Kommunikation. Das ADI der zweiten Generation, ADI-2, wurde mit dem Ziel eingeführt, eine höhere Flexibilität bei der Implementierung zu gewähren und Kommunikationsmechanismen, die eine größere Funktionalität bieten, effizient zu unterstützen. So nutzt zum Beispiel die auf Globus [ F G G + 9 8 ] aufsetzende Implementierung des ADI-2 die Option für das „Gerät" aus, um eine eigene Implementierung der Routinen für abgeleitete MPIDatentypen zu erstellen. Damit kann diese auf Globus basierende Implementierung des ADI, bezeichnet mit MPICH-G, die Fähigkeiten von Globus zur Kommunikation mit heterogenen Daten ausnutzen. In ADI-2 gab es auch einen Mechanismus, mit dem ein abstraktes Gerät jede beliebige kollektive MPI-Routine Kommunikator für Kommunikator ersetzen konnte. Das heißt, das ADI konnte einfach MPI_Barrier auch bezüglich MPI_C0MM_W0RLD, oder jede Kombination von Routinen, ersetzen. Damit konnten Geräte spezielle Hardware oder Algorithmen zur Implementierung kollektiver Routinen verwenden. Die dritte Generation des ADI, ADI-3, wurde entwickelt, um eine bessere Anpassung an die aufkommenden Netzwerke mit Zugriff auf entfernten Speicher und Thread-parallele Ausführungsumgebungen zu erreichen. Desweiteren galt es, MPI-2-Operationen, z . B . für Zugriff auf entfernten Speicher oder dynamische Prozessverwaltung, zu unterstützen. ADI-3 ist die erste Version des ADI von MPICH, die nicht zwecks enger Anpassung an die Fähigkeiten anderer Message-Passing-Bibliotheken entwickelt wurde, denn die meisten anderen Message-Passing-Systeme im Bereich des wissenschaftlichen Rechnens waren inzwischen durch MPI ersetzt worden. ADI-3 hat, wie alle vorherigen ADIs, das Ziel, die Portierung von MPICH auf neue Plattformen und für neue Kommunikationsmethoden zu fördern.
C
Die MPE-Bibliothek
Wir gehen hier etwas näher auf die MPE-Bibliothek ein, die wir in den Beispielen dieses Buches verwendet haben. Sie besteht aus Funktionen, die • im Stil konsistent mit MPI, • in M P I nicht enthalten, • frei zugänglich und • in jeder MPI-Implementierung arbeitsfähig sind. Diese Werkzeuge sind extrem rudimentär und lassen viele wünschenswerte Eigenschaften vermissen. Trotzdem erachten wir sie als nützlich, sogar in dem Zustand, in dem sie sich aktuell befinden. Sie werden ständig weiterentwickelt. In C- und Fortran-Programme müssen die Dateien 'mpe . h ' bzw. 'mpef . h ' eingebunden werden.
C.l
Protokollierung mit MPE
Mit den MPE-Protokollierungsroutinen können Protokolldateien (engl.: logfiles) erzeugt werden, die Ereignisse, die bei der Ausführung eines parallelen Programms eintreten, aufzeichnen. Diese Dateien können dann nach dem Programmende ausgewertet werden. Die entsprechenden Routinen wurden im Abschnitt 3.7.3 eingeführt und erläutert. Zurzeit gibt es zwei Formate für Protokolldateien. Das erste, ALOG genannt, wird von u p s h o t und n u p s h o t verwendet. Das zweite Format, CLOG, gilt für jumpshot, das letzte in der Reihe dieser Programme (siehe Abschnitt C.4). Das Format ALOG entspricht etwa dem in [HL91] beschriebenen Format. Die C-Signaturen für die Protokollroutinen sind in Tabelle C . l angegeben, die für Fortran in Tabelle C.2. Anstelle einer automatischen Protokollierung von Aufrufen der MPI-Bibliothek kann der Nutzer mit Hilfe dieser Routinen Ereignisse, die in bestimmten Anwendungen von Bedeutung sind, protokollieren. Die Basisroutinen sind MPE_Init_log, MPE_Log_event und MPE-Finish_log. MPE_Init_log muss (von allen Prozessen) aufgerufen werden, um die Datenstrukturen der MPE-Protokollierung zu initialisieren. MPE_Finish_log sammelt die protokollierten Daten von allen Prozessen auf, fügt sie zusammen und gleicht die Zeitstempel entsprechend den Zeiten an, zu denen MPE_Init_log und MPE_FinishJLog aufgerufen wurden. Anschließend schreibt der Prozess mit Rang 0 aus MPI_C0MM_W0RLD
330
C Die MPE-Bibliothek
int MPEJnitJog(void) int Μ ΡE_Start_log(void) int MPE_Stop_log(void) int MPE_Finish_log(char *logfilename) int MPE_Describe_state(int start, int end, char *name, char *color) int MPE_Describe_event(int event, char *name) int MPE_Log_event(int event, int intdata, char *chardata)
Tabelle C.l: C-Signaturen für
MPE-Protokollroutinen
MPE_INIT_LOG() MPE_FINISH_LOG(logfilename) character*(*) logfilename MPE_START_LOG() MPE_STOP_LOG() M P E _ D E S C R I B E _ S T A T E ( s t a r t , end, name, color) integer start, end character*(*) name, color M P E _ D E S C R I B E _ E V E N T ( e v e n t , name) integer event character*(*) name MPE_LOG_EVENT(event, intdata, chardata) integer event, intdata character*(*) chardata
Tabelle C.2: Fortran-Signaturen für
MPE-Protokollroutinen
C.2 MPE-Graphik
331
das Protokoll in eine Datei, deren Name als Argument übergeben wird. Ein einfaches Ereignis wird mit der Routine MPE_Log_event aufgezeichnet. Diese Routine spezifiziert einen Ereignistyp (der völlig dem Anwender überlassen ist) sowie ein Integer und eine Zeichenkette für Nutzerdaten. Man kann in eine Protokolldatei auch Daten einfügen, die für die Protokollauswertung oder ein Visualisierungsprogramm (wie ζ. B. upshot) von Nutzen sein können. Dazu dienen die Routinen MPE_Describe_event und MPE_Describe_state, mit denen man Ereignis- und Zustandsbeschreibungen hinzufügen kann, indem man für jeden Zustand ein Start- und Endeereignis festlegt. Man kann auch eine Farbe für einen Zustand vorsehen, die ein Protokoll-Visualisierungsprogramm nutzen kann. Für upshot kann eine Farbe in der Form "red:vlines" angegeben werden, um so gleichzeitig eine Farbe für ein Farbdisplay und eine Bitmap für eine SchwarzWeiss-Darstellung (wie oft in Büchern) festzulegen. Schließlich kann mit MPE_Stop_log und MPE_Start_log die Protokollierung dynamisch ein- bzw. ausgeschaltet werden. Nach dem Aufruf von MPE_Init JLog ist sie standardmäßig aktiviert. Diese Routinen werden in einer der Analysebibliotheken, die mit zum Gesamtpaket gehören, verwendet, um MPI-Bibliotheksaufrufe automatisch zu protokollieren.
C.2
MPE-Graphik
Viele Anwendungsprogrammierer möchten die Ausgaben ihrer Programme mit einfachen Graphiken aufwerten, empfinden es aber als zu aufwändig, sich deswegen mit dem Xll-Programmiermodell zu befassen. Um hier zu helfen, haben wir einige einfache Graphikelemente definiert, die in MPI-Programmen eingesetzt werden können. Eine Einführung in diese Bibliothek wird in Abschnitt 3.8 gegeben. Die C-Signaturen dieser Routinen sind in Tabelle C.3 zusammengestellt. (Wir weisen darauf hin, dass einige MPI-Implementierungen möglicherweise nicht mit XI1 kompatibel sind; unsere Implementierung setzt voraus, dass Xll-Routinen von Programmen, die auch mit MPI arbeiten, direkt aufgerufen werden können.)
C.3
Hilfen in MPE
Zusätzlich zu den Protokollierungs- und Graphikprogrammen enthält die MPE-Bibliothek eine Reihe von Routinen, die dem Programmierer bei der Erstellung paralleler Anwendungsprogramme helfen können. Die in den Tabellen C.5 und C.6 angegebene Routine MPE_Decompld wurde im Kapitel 4 verwendet, um Dekompositionen eines Feldes zu berechnen. Sollten wir weitere Routinen benötigen, werden wir sie mit in die MPEBibliothek aufnehmen.
332
C Die MPE-Bibliothek
int MPE_Open_graphics(MPE_XGraph *handle, MPLcomm comm, char *display, int x, int y, int is.collective) int MPE_Draw_point(MPE-XGraph handle, int x, int y, MPE_Color color) int MPE_Draw_line(MPE_XGraph handle, int x l , int yl, int x2, int y2, MPE-Color color) int MPE_Draw_circle(MPE_XGraph handle, int centerx, int centery, int radius, MPE_Color color) int MPE_Fill_rectangle(MPE_XGraph handle, int x, int y, int w, int h, MPE.Color color) int MPE_Update(MPE_XGraph handle) int MPE_Num_colors(MPE_XGraph handle, int *nc) int MPE_Make_color_array(MPE_XGraph handle, int ncolors, MPE_Color array[]) int MPE_Close_graphics(MPE-XGraph *handle)
Tabelle C.3: C-Signaturen für ΜΡ Ε-Graphikroutinen
CA
Das Upshot-Visualisierungssystem
Das Programm u p s h o t zur Auswertung von Protokolldateien [HL91] wird seit mehreren Jahren verwendet. Inzwischen wurde es in T c l / T k [0st90, Ost91] neu implementiert. Nützliche Eigenschaften des Programms, einige davon zeigt die Abbildung C . l , sind
• die Möglichkeit zur Verschiebung (engl.: scroll), Vergrößerung und Verkleinerung (engl.: zoom) des Fensterinhalts, sowohl horizontal als auch vertikal, • die Möglichkeit, mehrere Fenster der gleichen oder unterschiedlicher Dateien gleichzeitig anzuzeigen (in Abbildung C . l zeigt das mittlere Fenster einen detaillierten Ausschnitt des oberen Fensters) und • die Darstellung von Histogrammen zur Dauer von Zuständen (Breite und Anzahl der Säulen sind variabel).
Während dieses Buch geschrieben wurde, waren wir gerade dabei, die Tcl/Tk-Version von u p s h o t , wie auch die schnellere Tcl/Tk-Version n u p s h o t , durch eine Java-Version mit dem Namen jumpshot [ZLGS99] (welchen Namen hätte man sonst schon wählen können?) zu ersetzen. Das Programm jumpshot wird inzwischen mit MPICH ausgeliefert. Ein Screenshot ist in Abbildung C.2 zu sehen.
333
C.4 Das Upshot-Visualisierungssystem
I g ] Teeshot ] s..i.yl
~fjB»mp| jcyt
Log I i 1 Ii I |.·ί.ι» hyy Hi . 1 oq
• Horizontal Zoo·: [ i n j jOut| V e r t i c a l Z o o · : jln] jOut) laska
I
taskb
ESSSB t a s k b p
jPriiit| task c
|«a»et]
jClo
task d ο -
ι ® samhyp.ie.log Horizontal
Zoo·:
taska
ut
Γ ~ ~ Ί task_b
Vertical
Zoo·: |ίη] jOut| [Print]
(WM
®
task_bp
t m ^ l task_c
Säsetj « f f i
pflöge] Ρ tasked
ttsk_c lengths
Huaber of task c s t a t e s : Total t i m ;
130
0.17067 sec.
Start state ι 11
length 0.0010
End s t a t e
length
L .
0.0049
Huxber o f
bins ι m 25 cursor: 0. 00196 (Prlnt|
Abbildung
C.l:
—1
jclosej
Eine Bildschirmausgabe von upshot
334
C Die MPE-Bibliothek
M P E _ O P E N _ G R A P H I C S ( h a n d l e , comm, display, x, y, is-collective, ierror) integer handle, comm, x, y, ierror character*(*) display logical is_collective M P E _ D R A W _ P O I N T ( h a n d l e , x, y, color, ierror) integer handle, x, y, color, ierror MPE_DRAW_LINE(handle, x l , y l , x2, y2, color, ierror) integer handle, x l , y l , x2, y2, color, ierror M P E _ D R A W _ C I R C L E ( h a n d l e , centerx, centery, radius, color, ierror) integer handle, centerx, centery, radius, color, ierror M P E _ F I L L _ R E C T A N G L E ( h a n d l e , x, y, w, h, color, ierror) integer handle, x, y, w, h, color, ierror M P E _ U P D A T E ( h a n d l e , ierror) integer handle, ierror M P E _ N U M _ C O L O R S ( h a n d l e , nc, ierror) integer handle, nc, ierror M P E _ M A K E _ C O L O R _ A R R A Y ( h a n d l e , ncolors, array, ierror) integer handle, ncolors, array(*), ierror M P E _ C L O S E _ G R A P H I C S ( h a n d l e , ierror) integer handle, ierror
Tabelle C.J^: Fortran-Signaturen für
MPE-Graphikroutinen
int M P E _ D e c o m p l d ( i n t η, int size, int rank, int *s, int *e)
Tabelle C. 5: C-Signaturen für eine weitere
MPE-Routine
M P E _ D E C O M P l D ( n , size, rank, s, e) integer n, size, rank, s, e
Tabelle C. 6: Fortran-Signaturen für eine weitere MPE-Routine
C.4 Das Upshot-Visualisierungssystem
335
D
MPI-Quellen im Internet
Wir wollen hier auflisten, wie man über das Internet auf Dokumente und andere Werkzeuge zu MPI zugreifen kann. E i n s t i e g s s e i t e n zu M P I . Es gibt im World Wide Web zahlreiche „MPI-Homepages". Die wichtigste ist die des MPI-Forums, h t t p : / / w w w . m p i - f o r u m . o r g . Auf der Seite h t t p : / / w w w . m c s . a n l . g o v / m p i findet man Verweise auf andere Einstiegsseiten sowie auf Werkzeuge, Übungsanleitungen, Implementierungen und Dokumentationen zu MPI. B e i s p i e l e f ü r M P I - P r o g r a m m e . Alle im Buch verwendeten Beispiele sind im Web abrufbar über h t t p : / / w w w . m c s . a i i l . g o v / m p i / u s i n g m p i oder über anonymes f t p von f t p . m c s . a n l . g o v , Verzeichnis ' p u b / m p i / u s i n g / e x a m p l e s ' . Die Beispielsammlung ist den Buchkapiteln entsprechend gegliedert. In der Datei 'README' sind die Beispiele kapitelweise aufgelistet, die Beispiele selbst sind in der komprimierten Unix-Archivdatei ' e x a m p l e s . t a r . g z ' enthalten. Anweisungen zum Entpacken dieser Datei sind in der 'README'-Datei angegeben. M P I - I m p l e m e n t i e r u n g e n . Die von den Autoren dieses Buchs und anderen Beteiligten geschriebene MPICH-Implementierung ist frei erhältlich und kann von der Seite http://www.mcs . a n l .gov/mpi/mpich herunter geladen werden oder über anonymes f t p vom Server f t p . m c s . a n l . g o v b e z o g e n werden. In der Datei 'README' im Verzeichnis ' p u b / m p i / ' ist beschrieben, wie die aktuellste Version der Modellimplementierung zu bekommen und zu installieren ist. Die MPICH-Implementierung enthält Beispiele und Testprogramme sowie Programme zur Leistungsmessung. Die meisten dieser Testund Prüfprogramme können mit jeder beliebigen MPI-Implementierung verwendet werden. Eine Aufstellung mit sowohl freien als auch kommerziellen Implementierungen wird auf h t t p : //www. lam-mpi. o r g / aktuell gehalten. D e r M P I - S t a n d a r d . Der MPI-Standard ist im Web sowohl im Postscript- als auch im HTML-Format auf der Seite h t t p : //www. mpi-f orum. org zugänglich, ebenso Errata zu MPI-1 und MPI-2. Zusätzlich können dort Archive des MPI-Forums, ζ. B. mit EMail-Diskussionen, Mitteilungen von Zusammenkünften oder Abstimmungsberichten eingesehen werden. D i s k u s s i o n e n zu M P I . Die Newsgroup c o m p . p a r a l l e l .mpi widmet sich der Diskussion aller Aspekte von MPI. Manchmal gibt es auch Erörterungen zu MPI betreffenden Themen in der allgemeineren Gruppe comp. p a r a l l e l , die sich mit Parallelrechnern und Problemen der Parallelverarbeitung befasst. Im W W W ist eine große Menge an Informationen zu Projekten und Werkzeugen für die parallele Programmierung zugänglich. Wir möchten dem Leser empfehlen, auch andere Quellen im Web mit weiteren Informationen zu MPI und zur Parallelverarbeitung aufzusuchen.
Ε
Sprachdetails
In diesem Anhang wollen wir kurz einige Details zu den Sprachen C und Fortran, die das Zusammenspiel mit MPI betreffen, behandeln.
E.l
Felder in C und Fortran
Dieser Abschnitt befasst sich mit der Anordnung von Fortran-Feldern im Speicher und reißt kurz an, wie implizite „Umordnungen" von Feldern in Fortran 77 gehandhabt werden. Alle Felder werden im Speicher nach gewissen Regeln abgelegt, die durch die Sprache definiert sind. So wird festgelegt, wie die Indizes einer Feldreferenz wie a ( i , j , k , l ) in den Speicher eines Rechners abzubilden sind. Für eine effiziente Darstellung von Daten ist es wesentlich, diese Regeln zu verstehen und auszunutzen.
E.l.l
Spalten- und zeilenweise Anordnung
Viele Diskussionen zu den Unterschieden zwischen Fortran und C beziehen sich auf die „spaltenweise" und „zeilenweise" Anordnung. Diese Bezeichnungen rühren von der Betrachtung eines zweidimensionalen Felds für die Darstellung einer Matrix her. Zum besseren Verständnis wollen wir die Matrix / a n