Diplomarbeit Analyse und Optimierung der Netzwerkschnittstellen
Transcrição
Diplomarbeit Analyse und Optimierung der Netzwerkschnittstellen
Diplomarbeit Analyse und Optimierung der Netzwerkschnittstellen BMI und NEON von cand. inform. Hynek Schlawack Universität Potsdam Institut für Informatik Professur Betriebssysteme und Verteilte Systeme Aufgabenstellung und Betreuung: Prof. Dr. Bettina Schnor Potsdam 29. September 2006 2 Inhaltsverzeichnis I. Einführung 13 1. Motivation 15 2. Ursachen für ineffiziente Software 17 2.1. Ungünstige Designentscheidungen . . . . . . . . . . . . . . . . . . . . . . 18 2.2. Falsche Annahmen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19 2.3. Schlechte Implementierung . . . . . . . . . . . . . . . . . . . . . . . . . . 19 2.3.1. Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19 2.3.2. Auswahl ungeeigneter Werkzeuge . . . . . . . . . . . . . . . . . . 20 2.3.3. Unwissen und Nachlässigkeit . . . . . . . . . . . . . . . . . . . . . 20 2.4. Fazit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21 3. Methoden und Werkzeuge zur Laufzeitanalyse 23 3.1. Debugger . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23 3.2. Manuelles Messen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24 3.3. gprof . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24 3.4. OProfile . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27 3.5. Callgrind . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30 3.6. Fazit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35 4. Optimierung 37 4.1. Möglichkeiten . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37 4.1.1. Compiler . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38 4.1.2. Manuell . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39 4.2. Fazit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 44 3 5. Vorgehen bei den Analysen 45 5.1. Messumgebung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45 5.2. Verwendete Werkzeuge . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47 5.2.1. Analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47 5.2.2. Benchmark . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47 5.3. Durchführung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49 II. Untersuchung der Netzwerkschnittstellen 51 6. BMI 53 6.1. Messergebnisse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53 6.2. Architektur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55 6.2.1. BMI und PVFS2 . . . . . . . . . . . . . . . . . . . . . . . . . . . 55 6.2.2. Aufbau von BMI-TCP . . . . . . . . . . . . . . . . . . . . . . . . 57 6.2.3. Sendeformat . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59 6.2.4. Operationen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59 6.2.5. Zustellmodi . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 61 6.2.6. Kommunikation . . . . . . . . . . . . . . . . . . . . . . . . . . . . 61 6.2.7. Eventsysteme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62 6.2.8. Verbindungsverwaltung . . . . . . . . . . . . . . . . . . . . . . . . 63 6.2.9. Operationsverwaltung . . . . . . . . . . . . . . . . . . . . . . . . 64 6.2.10. Callgraphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69 6.2.11. Fazit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 74 6.3. Anwendungsbeispiel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 74 6.4. Laufzeitanalyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 74 6.4.1. Caches . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 76 6.4.2. Profiling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 77 6.5. Gegenüberstellung mit BMI-GAMMA . . . . . . . . . . . . . . . . . . . . 78 6.6. Verbesserungsvorschläge . . . . . . . . . . . . . . . . . . . . . . . . . . . 80 6.7. Fazit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 84 7. NEON 4 85 7.1. One-Sided Communication . . . . . . . . . . . . . . . . . . . . . . . . . . 85 7.2. Architektur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 86 7.2.1. Kommunikationsmodell . . . . . . . . . . . . . . . . . . . . . . . . 86 7.2.2. Netzwerkunterstützung . . . . . . . . . . . . . . . . . . . . . . . . 7.2.3. API . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.2.4. Asynchronität . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.2.5. Nachrichtenmodi . . . . . . . . . . . . . . . 7.3. Anwendungsbeispiel . . . . . . . . . . . . . . . . . . 7.4. Durchgeführte Optimierungen und Verbesserungen 7.4.1. Progress-Engine . . . . . . . . . . . . . . . . 7.4.2. 7.4.3. 7.4.4. 7.4.5. 7.4.6. Locking . . . . . . Speicherverwaltung Compiler-Hints . . Auto-Tools . . . . Fazit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88 89 89 89 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 89 91 92 92 92 7.5. Messergebnisse . . . . . . . . . . 7.5.1. Bandbreiten und Latenzen 7.5.2. Zellularautomat . . . . . . 7.5.3. Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 92 93 94 95 7.6. Laufzeitanalyse 7.6.1. Caches . 7.6.2. Profiling 7.7. Fazit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 96 96 96 98 III. Abschluss . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87 87 88 . . . . . . . . 99 8. Zusammenfassung 101 9. Ausblick 103 5 6 Abbildungsverzeichnis 1.1. Latenzvergleich von Christoph Kling aus [Kli05] . . . . . . . . . . . . . . 16 3.1. Gekürzte Beispielsausgabe von gprof . . . . . . . . . . . . . . . . . . . . 25 3.2. Gekürzte Beispielsausgabe von opreport --demangle=smart --symbols ” ./pingpong neon“ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29 3.3. Gekürzte Beispielsausgabe von gprof einer mit opgprof“ erstellten gmon.out“. 29 ” ” 3.4. Cachestatistiken von Callgrind . . . . . . . . . . . . . . . . . . . . . . . . 31 3.5. KCachegrind . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32 3.6. KCachegrinds Callmap . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33 3.7. KCachegrinds exportierter Callgraph . . . . . . . . . . . . . . . . . . . . 34 5.1. Latenzen für BMI-TCP und TCP, mit und ohne SMP. . . . . . . . . . . 46 5.2. Architektur eines fiktiven eins-Moduls foo“. . . . . . . . . . . . . . . . . ” 48 6.1. Latenzen von TCP, BMI-TCP, GAMMA und BMI-GAMMA. . . . . . . 54 6.2. Bandbreiten von TCP und BMI-TCP. . . . . . . . . . . . . . . . . . . . . 55 6.3. Funktionsweise von BMI. Aus [PVF02a]. . . . . . . . . . . . . . . . . . . 56 6.4. Ausschnitt aus der Definition von BMI-Modulen. . . . . . . . . . . . . . 58 6.5. Die Struktur tcp msg header. . . . . . . . . . . . . . . . . . . . . . . . . 59 6.6. Queue- und Zustandsverlauf beim Senden in der BMI-TCP-Methode. . . 66 6.7. Queue- und Zustandsverlauf beim Empfangen in der BMI-TCP-Methode. 68 6.8. Callgraph zum Versenden von Daten in BMI-TCP. . . . . . . . . . . . . 70 6.9. Callgraph zum Empfangen von Daten in BMI-TCP. . . . . . . . . . . . . 72 6.10. Callgraph zu BMI test(). . . . . . . . . . . . . . . . . . . . . . . . . . . . 73 6.11. Ein einfacher Echo-Server mit der BMI-API. . . . . . . . . . . . . . . . . 75 6.12. Die Funktion BMI sockio nbvector() (Format leicht angepasst). . . . . . . 79 6.13. Bandbreite von BMI-TCP, TCP und UDP. . . . . . . . . . . . . . . . . . 81 6.14. Latenzen von BMI-TCP, TCP und UDP. . . . . . . . . . . . . . . . . . . 82 6.15. Schlechte Pipelinenutzung . . . . . . . . . . . . . . . . . . . . . . . . . . 82 7 8 6.16. Gute Pipelinenutzung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.17. Auswirkungen von Fragmentierung auf UDP-Messwerte. . . . . . . . . . 83 83 7.1. Kommunikationsmodell nach L. Schneidenbach aus [SS06]. . . . . . . . . 86 7.2. 7.3. 7.4. 7.5. 87 90 93 94 NEON im Kommunikationsmodell . . . . . . . Ein einfacher Echo-Server mit NEON. . . . . . Latenzen von TCP, BMI-TCP und NEON. . . Bandbreiten von TCP, BMI-TCP und NEON. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Tabellenverzeichnis 5.1. Hardware . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.2. Software . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45 47 6.1. Cachemissraten in BMI-TCP. . . . . . . . . . . . . . . . . . . . . . . . . 76 6.2. Laufzeitanteile in BMI-TCP. Nur Funktion mit Laufzeitanteil von mehr als 1%. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 77 7.1. Messergebnisse mit dem Zellularautomaten in Sekunden. . . . . . . . . . 7.2. Cachemissraten in NEON. . . . . . . . . . . . . . . . . . . . . . . . . . . 95 96 7.3. Laufzeitanteile in NEON. Nur Funktion mit Laufzeitanteil von mehr als 1%. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 97 9 Eidesstattliche Erklärung Hiermit versichere ich, die vorliegende Arbeit selbständig, ohne fremde Hilfe und ohne Benutzung anderer als der von mir angegebenen Quellen angefertigt zu haben. Alle aus fremden Quellen direkt oder indirekt übernommenen Gedanken sind als solche gekennzeichnet. Die Arbeit wurde noch keiner Prüfungsbehörde in gleicher oder ähnlicher Form vorgelegt. Potsdam, den 10 Danksagungen Ich danke dem gesamten Lehrstuhl Betriebssysteme und Verteilte Systeme“, insbeson” dere Bettina Schnor, Lars Schneidenbach und Klemens Kittan, für die hervorragende Betreuung. Desweiteren danke ich Sebastian Freundt, Marten Lehmann und Sebastian Bauer für das aufopferungsvolle Korrekturlesen. Besonders danke ich meiner Freundin Monika für ihre Geduld während der nicht immer einfachen Zeit. 11 12 Teil I. Einführung 13 1. Motivation Netzwerkschnittstellen ermöglichen das programmatische Ansprechen von Netzwerken. Die zwei bekanntesten Vertreter sind hierbei zunächst die so genannten Sockets, welche ihren Ursprung im BSD Betriebssystem haben und sich im UNIX-Bereich gegen das von SystemV favorisierte Transport Layer Interface (TLI) – beziehungsweise dessen Erweiterung X/Open Transport Interface“ (XTI) – durchsetzen konnten. Die zweite große“ ” ” Netzwerkschnittstelle sind die Windows Sockets (Winsock) für die Windows-Plattform. Aufgrund seiner Popularität im Clusterbereich liegt der Fokus dieser Arbeit jedoch auf dem freien UNIX-artigen Betriebssystem Linux und die dort verbreitetste Sprache C. Die genannten Vertreter sind jedoch recht umständlich einzusetzen1 und auch nicht die einzigen Alternativen. Insbesondere Cluster, die besonders auf schnelle Netzwerke angewiesen sind, benutzen oft spezielle Infrastrukturen wie zum Beispiel InfiniBand oder Myrinet. Um die Entwicklung portabler Netzwerkapplikationen zu beschleunigen, beziehungsweise andere Programmierparadigmen zu ermöglichen, werden deshalb zusätzliche Netzwerkschnittstellen eingesetzt, die die darunterliegende Schicht abstrahieren. Da beim heute in Clustern üblichen Gigabit-Ethernet zusätzliche Latenzen im Mikrosekundenbereich mess- und fühlbar sind, ist es vonnöten, dass diese Schnittstellen einen möglichst geringen Overhead verursachen. Ein Beispiel für solch eine Netzwerkschnittstelle ist das Buffered Messaging Interface (BMI) des Parallel Virtual Filesystems (PVFS). Es ermöglicht, dass das PVFS sowohl auf normalem TCP [Pos81] als auch auf InfiniBand und Myrinet laufen kann. Zusätzlich wurde es von Christoph Kling im Rahmen seiner Master-Arbeit [Kli05] für GAMMA [Cia99] (BMI-GAMMA) umgesetzt und anschließend mit der üblichsten Variante verglichen: Dem TCP-Modul (BMI-TCP). Wie man in Abbildung 1.1 sehen kann, ergaben diese Messungen einen deutlich höheren Overhead im BMI-TCP als in BMI-GAMMA. Dies ist verwunderlich und auch der Hauptimpuls für diese Arbeit, in der BMI-TCP ausführlich untersucht wird, um die Ursache für die Latenzen zu ermitteln. 1 In parallelen Anwendungen ergibt sich zum Beispiel das Problem der unportablen Adressierung. 15 100 MPI-TCP BMI-TCP TCP BMI-GAMMA MPI-GAMMA GAMMA 80 Time [us] 60 40 20 0 1 4 16 64 Message Size [bytes] 256 1024 4096 Abbildung 1.1.: Latenzvergleich von Christoph Kling aus [Kli05] Eine andere Netzwerkschnittstelle ist das New Efficient One-sided communications iNterface (NEON), die Lars Schneidenbach im Rahmen seiner (bis dato unveröffentlichten) Dissertation entwickelt hat. Es bildet One-Sided Communication“ (OSC) auf eine Send” Receive-Semantik ab und bettet somit die Synchronisation implizit in die Kommunikation ein. Der nächste Schritt ist es, die gewonnenen Erkenntnisse aus der Analyse von BMI-TCP auf NEON anzuwenden und die Schicht noch performanter zu machen. Bevor jedoch mit dem Analysieren und Optimieren begonnen wird, erfolgt eine allgemeine Einführung in performante Software; wie man sie erreicht und welche Fehler es zu vermeiden gilt. Anschließend wird noch das Vorgehen bei der Analyse erörtert. Nach der folgenden Untersuchung wird im Abschluss ein Fazit gezogen und ein Ausblick gewährt. 16 2. Ursachen für ineffiziente Software Warum ist Software ineffizient“? Auf diese Frage gibt es viele Antworten und einige ” von ihnen widersprechen sich sogar. Insofern ist dieses Kapitel notwendigerweise subjektiv, da es unmöglich ist, alle Theorien und Ansätze vor- oder gar gegenüberzustellen. Deshalb sind die vorgestellten Gründe eine Sammlung, die vom Autor während seiner Recherchen als nachvollziehbar und relevant erachtet wurden. Als maßgebliche Quellen für diese Sammlung sind [Lei02] und [WC00] hervorzuheben. Einer der Hauptgründe ist der Versuch, die Software zu einem Fließbandprodukt zu machen. Hierfür werden Muster angewendet und Komponenten genutzt, ohne den Sinn zu hinterfragen oder gar die Konsequenzen der eigenen Handlungen zu verstehen. Dieses Handeln wird durch den ständigen Technologiefortschritt und damit verbundenen neuen Ressourcen zusätzlich gefördert. Dies führt einerseits zu Code von niedriger Qualität und andererseits zum so genannten Bloat: Aufgeblähte Software, deren Größe und Performance in keinem Verhältnis zum Nutzen und Funktionsumfang steht. Aber warum ist Bloat“ schlecht? Immerhin ist die Faustregel, dass mehr Geschwin” digkeit durch mehr Code erreichbar ist, im allgemeinen Bewusstsein. Diese Argumentation hat jedoch einige Lücken. Vor allem entstand sie in Zeiten, als der hauptsächliche Flaschenhals die CPU war. Dies ist heute nicht mehr uneingeschränkt so. Durch den schnellen Fortschritt bei der Entwicklung von CPUs, entstand ein weiterer Faktor, der die Geschwindigkeit maßgeblich beeinflusst: Der Speicherbus. Heutige CPUs können ihre volle Rechenleistung nur dann voll zur Geltung bringen, wenn die benötigten Daten (und Code) vollständig im Cache liegen. Bei übermäßig aufgeblähter Software werden Caches ineffektiv ausgenutzt, da der Code viel Platz einnimmt. Dies führt einerseits dazu, dass oft Code nachgeladen werden muss und andererseits auch, dass der Platz nicht für Daten ausreicht und diese ebenfalls oft transferiert werden müssen. Andi Kleen zittierte auf dem Linux Kongress 2006 den bekannten LinuxKernelentwickler Alan Cox hierzu sinngemäß: 17 Speicher ist heute so langsam im Vergleich zu CPU-Caches, dass man anfangen muss, Speicherzugriffe wie Plattenzugriffe zu behandeln. Dies ist sicherlich (noch) überspitzt formuliert, illustriert aber sehr gut die Wichtigkeit der CPU-Caches und ihre optimalen Nutzung. In diesem Kapitel wird erörtert, wie diese und andere Probleme entstehen und vor allem wie sie vermieden werden können. 2.1. Ungünstige Designentscheidungen Während die Designentscheidungen normalerweise keinen Einfluss auf Codequalität haben, kann mit schlechtem Design unverhinderbarer Bloat entstehen. Abhängig von der Detailliertheit des Entwurfs ergeben sich verschiedene Möglichkeiten“ ” Bloat zu induzieren. Ein klassisches Beispiel, welches in jedem Fall möglich ist, ist das übermäßige Schich” ten“. Während es natürlich positiv ist, mit wiederverwendbaren Komponenten zu arbeiten, hat ein übertriebenes Wrapping mehrere negative Nebeneffekte. Zum einen erzeugt das Aufrufen von Funktionen und Methoden Overhead: Die Register müssen auf den Stack gerettet, die Rücksprungadresse gespeichert und letztlich der Sprung durchgeführt werden. Sprünge wiederum haben den Nachteil, dass sie die Datenlokalität verletzen: Daten sollten idealer Weise nah bei dem Code der auf die zugreift liegen. Nicht zuletzt, um das Risiko zu minimieren, sich gegenseitig im Cache zu behindern, der in der Regel Hashing1 zum Adressieren benutzt. Lange Callstacks haben also unmittelbar negativen Einfluss auf die Performance und sollten für den Common Case (also den am häufigsten vorkommenden Fall) möglichst kurz sein. Zusätzlich führt die Komponentisierung“ jedoch auch zu mehr generischen Funktio” nen als nötig und fördert den Ehrgeiz die APIs unnötig komplex zu gestalten (siehe auch Abschnitt 2.3.2 zu diesem Thema). Andere Probleme ergeben sich daraus, dass verschiedene Komponenten oder Module von unterschiedlichen Entwicklern programmiert werden. Dies kann bei schlechtem 1 Prinzipiell bedeutet dies nur, dass die ersten Bits einer Adresse ignoriert werden und die restlichen als Cache-Adresse dienen. So ist sichergestellt, dass nah bei einander liegende Adressen meistens zusammen im Cache liegen. Eine genauere Beschreibung von Caches und ihrer Funktionsweise ist zum Beispiel in [PH98] zu finden. 18 Design und Management dazu führen, dass zum Beispiel Konfigurationsdateien von jedem Modul einzeln geparsed werden oder dass Hilfsfunktionen mehrfach implementiert werden. 2.2. Falsche Annahmen Mythen und Legenden sind nicht nur im Bereich der Software ein Problem. Insofern lautet der Grundsatz, dass man nichts glauben sollte, was man nicht selber überprüft hat. Drei dieser Mythen seien hier kurz vorgestellt: Compiler optimiert unbenutzten Code weg Dies ist nicht zutreffend, da der Compiler nicht wissen kann, ob die innerhalb des Objektes unbenutzten Funktionen nicht später vom Linker für ein anderes Objekt benötigt werden. Linker optimiert unbenutzte Symbole weg Dies ist nur für das (seltene) statische Linken richtig, wo zur Linkzeit alle Abhängigkeiten aufgelöst werden. Inline-Funktionen werden ausschließlich geinlinet“ Ein Inlining (Einfügen von Co” de statt Funktionsaufruf) kann nur stattfinden, wenn der Compiler Zugriff auf den Sourcecode der Funktion hat. Falls Hilfsfunktionen in einer eigenen Datei sind, die separat übersetzt wird, kann bei der Benutzung kein Code eingefügt werden. Dies müsste letztendlich der Linker übernehmen. Der Compiler muss also die vermeintliche Inline-Funktion als Symbol exportieren, da die Möglichkeit besteht, dass sie vom Linker benötigt wird. Insbesondere die ersten beiden vorgestellten Mythen sind schuld an vielen überflüssigen Funktionen, die mit allen erdenklichen Softwareprojekten ungewollt ausgeliefert werden. 2.3. Schlechte Implementierung Der letztendlich wichtigste Faktor ist die eigentliche Implementierung. Hier ergeben sich Probleme verschiedener Granularitäten, die im Folgenden erläutert werden. 2.3.1. Algorithmen Algorithmen sind mit Abstand die ergiebigste Möglichkeit die Performance eines Programms zu beeinflussen. 19 Das Ersetzen eines Algorithmus aus der Klasse2 O(n2 ) (zum Beispiel Bubblesort) durch einen aus O(n · log(n)) (zum Beispiel Heapsort) oder gar O(1) (für das Sortieren nicht anwendbar) kann die Laufzeit um Größenordnungen verbessern. Man muss jedoch trotzdem wohlüberlegt vorgehen, denn die Landau-Notation birgt auch Gefahren. Abhängig vom Fall kann es sich zum Beispiel auszahlen, einen O(log(n))Algorithmus einem aus der Klasse O(1) vorzuziehen, wenn Letzterer einen hohen konstanten Overhead c hat (zum Beispiel in Folge komplexer Datenstrukturen). Dieser wird in der Landau-Notation weggekürzt, kann jedoch unter Umständen deutlich höher sein, als log(n) es im üblichen Fall ist. 2.3.2. Auswahl ungeeigneter Werkzeuge Der Terminus Werkzeuge“ ist an dieser Stelle im weitesten Sinne zu verstehen. Ein ” Werkzeug kann ein Programm zum Erstellen von grafischen Benutzeroberflächen, ein Metacompiler, eine Hilfslibrary oder auch eine Funktion aus der C-Bibliothek sein. Abgesehen von der Abhängigkeit von fremdem Code ist das Hauptproblem erneut, dass oft nicht erfasst wird, welcher Overhead durch die Wahl erzeugt wird. Ein klassisches Beispiel für unterschätzte“ Funktionen sind die Formatierungsfunktio” nen der C-Bibliothek (stdio). Unerfahrene Entwickler benutzen sprintf() statt strcat() zum konkatenieren von Strings oder printf() statt puts() zur Ausgabe von statischen Strings. Die Ursache des Übels sind also generische Funktionen, die außer dem, was benötigt wird, noch deutlich mehr Funktionalität mitbringen. Dies ist nicht nur schlecht für die Größe des Endprodukts, es erhöht auch gleichzeitig die Wahrscheinlichkeit, auf Bugs zu stoßen, die mit der benötigten Funktionalität nicht in Verbindung stehen. Die Faustregel für alle Arten von Werkzeugen lautet also: So spezifisch wie möglich, nur das benutzen, was man wirklich braucht. 2.3.3. Unwissen und Nachlässigkeit Nachlässige Implementierungen, ganz gleich ob durch Unwissen oder Faulheit induziert, sind ein weiterer wichtiger Faktor. Er ist jedoch leider nicht so leicht fassbar wie die vorherigen. 2 Diese so genannte Landau-Notation, oder O-Notation, wird in der Informatik üblicherweise verwendet, um die Laufzeitkomplexität eine Funktion abzuschätzen. Eine gute Einführung ist die Thematik bietet zum Beispiel [EP00]. 20 Verbreitete Beispiele dafür sind unnötig wiederholte Operationen (zum Beispiel Konvertieren von Datentypen oder Parsen von Text) und natürlich das unreflektierte Kopieren von Sourcecode von einer Stelle zur anderen, anstatt Funktionen oder wenigstens Makros zu definieren3 . Aber auch scheinbare Details sind wichtig: Strukturen sollten zum Beispiel ausschließlich als Zeiger ( by reference“) übergeben werden. Übergibt man ” eine Struktur by value“, muss sie vorher kostspielig kopiert werden. ” Ein anderer beliebter Fehler ist das blinde Optimieren für den Worst Case, der normalerweise nicht eintritt. Wichtig ist, dass die Software im Common Case möglichst schnell ist. Optimierungen für den Worst Case zu Lasten des Common Case sind nur selten sinnvoll. 2.4. Fazit Die Gründe für Ineffizienz sind so vielfältig wie die Software selbst. Der wichtigste Punkt in allen Ebenen ist jedoch, dass stets bewusst sein muss, was man tut. Ob es hierbei um die Definition des API, dem Einsatz von Werkzeugen oder der Auswahl des Algorithmus geht: Der Verantwortliche muss sich jederzeit über die Konsequenzen seines Tuns im Klaren sein. Während schlechte Implementierungen teilweise noch reparabel sind (zum Beispiel durch den Einsatz besserer Algorithmen), stellen ungünstige Designentscheidungen ein enormes Problem dar, welches oft nur durch eine komplette Neuentwicklung lösbar ist. 3 Dies führt wiederum sowohl zu Bloat“, als auch zu Bugs, die dann gegebenenfalls an mehreren ” Stellen vorhanden sind. Dies zeigt erneut eindrucksvoll, wie die Aufgeblähtheit und Fehlerhaftigkeit von Software korrelieren. 21 22 3. Methoden und Werkzeuge zur Laufzeitanalyse Um das Laufzeitverhalten verbessern zu können, muss man erstmal wissen, wo man ansetzen kann. Man sucht so genannte Flaschenhälse. Teil der Analyse ist es aber auch, den Programmfluss zu verstehen (in Form von Callgraphen), da auch dieser einen erheblichen Einfluss auf die Gesamtperformance hat. Ein sehr langer Callstack hat zum Beispiel keinen klassischen Flaschenhals. Vielmehr ist das Design ein solcher und muss überdacht werden. Technisch aufwändiger als die Flussanalyse ist das eigentliche Profiling. Hier ist die Kernfrage, wo das Programm die meiste Zeit verbringt. Somit ist sie die direkte Vorarbeit zur Optimierung, da sie potentielle Stellen aufzeigt, wo das Optimieren hilfreich sein könnte. Es gibt hierbei mehrere sehr unterschiedliche Ansätze, für die jeweils ein verbreiteter Vertreter im Folgenden vorgestellt wird. 3.1. Debugger Profiling kann ein Debugger zwar nicht ermöglichen, ist jedoch der direkteste Weg einen Programmverlauf zu analysieren. Der Entwickler erlebt den Programmablauf komplett mit und hat zusätzlich noch Zugriff auf alle Variablen (letztendlich ist das klassische Debuggen“ meistens auch eine ” Programmflussanalyse im weitesten Sinne). Das Programm entgleitet ihm auch nicht und er hat durchgehend die komplette Kontrolle. Je nach Schwerpunkt der Analyse sind die meisten der Vorteile gleichzeitig die Nachteile. Im Allgemeinen ist die Analyse mit dem Debugger also nur bedingt zu einer umfangreichen Flussanalyse geeignet, weil die Last des Protokollierens auf dem Benutzer liegt. Dies erfordert viel manuelle Arbeit und ist fehleranfällig. 23 Ein Debugger ist jedoch hervorragend zum Erforschen bestimmter Abschnitte geeignet, um sich ein präziseres Bild zu machen (meistens mit der Frage verbunden Warum ” nimmt das Programm diesen Pfad?“). 3.2. Manuelles Messen Moderne CPUs bieten die Möglichkeit der hochpräzisen Zeitmessung. Bei der IA32 Architektur von Intel ist dies zum Beispiel der Befehl rdtsc (beschrieben in [Int02]) um einen Tickcounter auszulesen. Zum Messen liest man den Tickcounter am Anfang und Ende jeweils aus und bildet die Differenz. Dividiert man nun die gemessenen Ticks durch die Prozessortaktrate, erhält man Zeitwerte. Problematisch an der Sache ist, dass diese Art von Profiling obtrusive“ ist, man ” muss also direkt am Code etwas ändern. Dies hat zwei negative Konsequenzen: Zum einen ist es problematisch, wenn der zweite Zeitpunkt innerhalb einer Library ist, da sie hierfür jedes mal neu übersetzt werden muss. Zum anderen verfälscht die Zeitmessung geringfügig die Zeiten. Letztendlich ist das manuelle Messen jedoch der einzige Weg, präzise Werte zu bekommen, insbesondere wenn man wissen möchte, wie lange ein Funktionsaufruf dauert oder wie lange ein bestimmter Codeabschnitt dauert. 3.3. gprof Der klassische Profiler für freie UNIX-artige Betriebssysteme ist der gprof vom GNU Projekt. Es funktioniert nach dem Prinzip des automatischen Einfügen von zusätzlichem Code, welcher den Callgraphen ermittelt. Zusätzlich wird, je nach Architektur verschieden oft, stichprobenhaft überprüft, in welcher Funktion sich das Programm derzeit befindet. Um es nutzen zu können, muss das gewünschte Programm mit der Compileroption -pg“ übersetzt werden. Dies ist insofern problematisch, weil eventuelle Abhängigkeiten, ” die ebenfalls analysiert werden sollen, auch mit dieser Option übersetzt werden müssen. Wird ein mit -pg“ übersetztes Programm ausgeführt, schreibt es eine Datei namens ” gmon.out“ in das aktuelle Verzeichnis. Mit dem Shellfrontend gprof ist es möglich, ” diese Datei zu untersuchen. Eine Beispielsausgabe kann in Abbildung 3.1 betrachtet werden. Im ersten Teil (bis zum ersten [...]“) sind die Funktionen nach verbrauchter Laufzeit ” aufgeschlüsselt. Die eigenwilligen Übersetzungen der Spalten gehören sind Teil von gprof, 24 Jedes Muster zählt als 0.01 seconds. % kumulativ Selbst Selbst Gesamt Zeit seconds seconds Aufrufe ms/Aufru ms/Aufru Name 23.39 0.29 0.29 519546 0.00 0.00 mmx_memcpy 16.94 0.50 0.21 560456 0.00 0.00 try_to_complete 9.68 0.62 0.12 540001 0.00 0.00 NEON_Put 7.26 0.71 0.09 540001 0.00 0.00 remove_putlist_entry 7.26 0.80 0.09 20460 0.00 0.01 progress_engine 5.65 0.87 0.07 1579093 0.00 0.00 pool_free 4.84 0.93 0.06 540001 0.00 0.00 receive_header 4.84 0.99 0.06 540001 0.00 0.00 receive_payload 4.03 1.04 0.05 540001 0.00 0.00 internal_send 3.63 1.08 0.04 1059549 0.00 0.00 create_job 2.42 1.11 0.03 1579095 0.00 0.00 pool_alloc [...] Granularität: jeder Stichprobentreffer deckt 4 Byte(s) ab für 0.81% von 1.24 Sekunden Index % Zeit Selb. Kinder aufgerufen Name <spontan> [1] 100.0 0.01 1.23 main [1] 0.01 0.54 540000/540001 NEON_Repost [3] 0.02 0.43 1080002/1080002 NEON_Wait [4] 0.12 0.09 540001/540001 NEON_Put [8] 0.00 0.02 1/1 NEON_Init [18] 0.00 0.01 1/1 NEON_Exit [22] 0.00 0.00 1/1 NEON_Post [26] 0.00 0.00 2/2 checkbuffer [36] ----------------------------------------------0.01 0.01 20455/560456 NEON_Wait [4] 0.20 0.33 540001/560456 NEON_Repost [3] [2] 44.8 0.21 0.35 560456 try_to_complete [2] 0.29 0.00 519546/519546 mmx_memcpy [5] 0.05 0.00 1039092/1579093 pool_free [11] 0.01 0.00 519546/519546 remove_next_taglist_entry [21] 0.00 0.00 519546/519546 internal_update_network_data [32] ----------------------------------------------[...] Abbildung 3.1.: Gekürzte Beispielsausgabe von gprof 25 im Folgenden ihre Erklärung: % Zeit Das Sortierkriterium und der prozentuale Anteil der Funktion (ohne Unterfunktionen, die eigene Zeit) an der Gesamtlaufzeit. kumulativ seconds Die Gesamtzeit, die in der Funktion und allen Unterfunktionen von ihr verbracht wurde (also steht hier bei der main()-Funktion die Gesamtlaufzeit der Applikation). Selbst seconds Die eigene Zeit. Mit Hilfe dieser wurde die erste Spalte berechnet. Ist sie identisch mit der kumulativ seconds“-Spalte, dann ruft sie keine, oder im ” vernachlässigbaren Umfang, weitere Funktionen auf. Aufrufe Die Summe der Funktionsaufrufe. Selbst ms/Aufru Eigene Zeit pro Aufruf in Millisekunden. Gesamt ms/Aufru Gesamtzeit pro Aufruf in Millisekunden Im zweiten Teil wird der Callgraph dargestellt. Diese Darstellung ist auf den ersten Blick nicht ganz so klar, wie der erste Teil, jedoch trotzdem sehr zweckmäßig. Die Ausgabe besteht aus abgetrennten Blöcken, wo ganz rechts mehrere Funktionen stehen. Eine (Hauptfunktion) von ihnen ist ausgerückt (steht also vor den anderen), das ist die Funktion, um die es innerhalb des Blocks geht. Funktionen die über ihr stehen, rufen sie auf (Parents), die unter ihr stehen, werden von ihr aufgerufen (Children). Die Spalten haben folgende Bedeutungen: Index Der Index mittels dem sich die Blöcke gegenseitig adressieren. Jede Funktion hat einen eigenen Index und ist so leicht auffindbar, da der Index in der Funktionsauflistung stets rechts vom Funktionsnamen ausgegeben wird. % Zeit Die prozentuale Anteil der kumulativen Zeit in der Funktion. Selb Die Bedeutung der Zahl hängt davon ab, ob sie für die Parents, die Hauptfunktion oder die Children ist. Parents: Die Zeit, die von der Hauptfunktion erhalten wurde. Also der Anteil der Hauptfunktion an der kumulativen Zeit des Parents. Hauptfunktion: Eigene Zeit. Children: Zeit, die die Hauptfunktion von dem Child erhält. 26 Kinder Hier ist die Semantik der Zahl ebenfalls vom Kontext abhängig. Parents: Zeit von Children der Hauptfunktion. Hauptfunktion: Zeit von Children. Children: Zeit der Children des Childrens. aufgerufen Wie gehabt, ist die Semantik fallabhängig: Parents: Verhältnis zwischen der Anzahl der Aufrufe der Hauptfunktion durch den Parent und der Gesamtzahl der Aufrufe der Hauptfunktion (das heißt, dass die zweite Zahl für alle Parents gleich ist). Hauptfunktion: Gesamtzahl der Aufrufe. Children: Verhältnis zwischen der Anzahl der Aufrufe durch die Hauptfunktion und der Anzahl der Aufrufe insgesamt. Name Die Namen der Funktionen, gefolgt von ihrem Index. Die Hauptfunktion ist ausgerückt. Die Nachteile von gprof sind zunächst die benötigten Compilerflags, die auch zum Beispiel ein Ausmessen von Kernelfunktionen verhindern (dies wird derzeit nur von OProfile unterstützt, siehe Abschnitt 3.4). Des Weiteren ist die geringe Granularität beim Messen problematisch - laut Anleitung wird circa 100 mal pro Sekunde eine Stichprobe (Sample) entnommen. Selbst für millisekundengenaue Ausgaben, müsste man zehn mal öfter den Zustand untersuchen. Insofern sind auch die Zeitausgaben irreführend. Ein letzter, aber wesentlicher, Fehler ist technischer Natur: gprof kann unter bestimmten Kerneln und libcs nicht korrekt mit Threads umgehen und profilet nur der Hauptthread. Während es dafür eine Lösung [Hoc02] gibt, hat der Autor von der glibc (Linux’ libc, die auch große Teile der Threadimplementierung enthält) Ulrich Drepper in diesem Zusammenhang in [Dre02] bereits angedeutet, dass gprof s Zeit abgelaufen ist und er kein Interesse daran hat, es in irgendeiner Weise weiter zu unterstützen. In der Tat ist gprof aufgrund seiner Umständlichkeit und vor allem der sehr niedrigen Granularität nur für kleinere Projekte, die keine allzu genauen Messergebnisse benötigen, geeignet. Für diese Arbeit ist gprof also nicht von Relevanz. 3.4. OProfile OProfile ist ein Projekt von John Levon und kann unter http://oprofile.sourceforge. net/ gefunden werden. Es benötigt keine besonderen Compileroptionen für die Applikation die untersucht werden soll. Es dürfen jedoch nicht die Symbole entfernt werden 27 (zum Beispiel mit dem Programm strip) und, falls man am Callgraphen interessiert ist, darf das Programm nicht mit der Option -fomit-frame-pointer“ übersetzt werden. ” Es besteht aus einem Kernelmodul und einem Daemon, der mittels des Programms opcontrol kontrolliert wird. Die Daten ermittelt er, indem er bei einstellbaren Events den Zustand aller Prozesse des Systems untersucht ( ein Sample geholt“). Hierfür nutzt ” er Hardwarecounter moderner CPUs, welche beim Overflow eine Exception werfen. Standardmäßig schaut er alle 100.000 CPU CLK UNHALTED (Ticks, in denen die CPU nicht im halt-Zustand ist) Events. Auf Wunsch kann er ebenfalls die Callgraphen mitspeichern, dann verschlechtert sich jedoch die maximale Messgenauigkeit um Faktor 15. Da sich seine Messintervalle auf Ticks beziehen, muss man sie abhängig von der Taktfrequenz der CPU ausrechnen. Die Taktfrequenz f einer CPU ist die Anzahl der Ticks pro Sekunde. Wenn man x-mal pro Sekunde ein Sample entnehmen möchte, reicht es, die Frequenz f durch das Intervall zu teilen: f x Ein AMD Athlon-XP 3200+ hat beispielsweise eine Taktfrequenz von circa 2200 MHz. Möchte man auf ihm 1.000 Samples pro Sekunde (also im Millisekundenbereich messen) entnehmen, ergibt das die folgende Rechnung: 2200·1.000.000 = 2.200.000. 1.000 Eine Messreihe sieht dann für gewöhnlich in etwa so aus: T ickintervall = opcontrol --reset opcontrol --setup --callgraph=42 --event=CPU_CLK_UNHALTED:2200000:0:1:1 opcontrol --start ./messreihe opcontrol --shutdown Das --callgraph“-Argument gibt an, bis zu welcher Tiefe die Callgraphen gesam” melt werden sollen. Mit --event“ wird definiert, welche Events berücksichtigt werden ” sollen: In diesem Fall achten wir auf CPU CLK UNHALTED, holen alle 2.200.000 Events ein Sample, benutzen keine Maske und zählen sowohl im Kernel- als auch im Userspace. Anschließend können die gesammelten Messdaten mit dem Programm opreport untersucht werden. Hierbei ist es auch möglich, sich mittels opreport --demangle=smart ” --symbols“ ausgeben zu lassen, welches Programm während der Messperiode am meisten Laufzeit verbrauchte. Am interessantesten ist in diesem Zusammenhang jedoch die Ausgabe von opreport --demangle=smart --symbols BEFEHL“. In Abbildung 3.2 ” kann ein solches Beispiel betrachtet werden. 28 CPU: Athlon, speed 2194.44 MHz (estimated) Counted CPU_CLK_UNHALTED events (Cycles outside of halt state) with a\ unit mask of 0x00 (No unit mask) count 45000 samples % image name symbol name 6110 15.7726 pingpong_neon progress_engine 3252 8.3949 pingpong_neon NEON_Put 2805 7.2410 pingpong_neon internal_send [...] Abbildung 3.2.: Gekürzte Beispielsausgabe von --symbols ./pingpong neon“ opreport --demangle=smart ” Flaches Profil: Jedes Muster zählt als 1 samples. % kumulativ Selbst Selbst Gesamt Zeit samples samples Aufrufe K1/Aufru K1/Aufru 18.21 6110.00 6110.00 25185 0.00 0.00 9.69 9362.00 3252.00 16097 0.00 0.00 8.36 12167.00 2805.00 2805 0.00 0.00 Name progress_engine NEON_Put internal_send Abbildung 3.3.: Gekürzte Beispielsausgabe von gprof einer mit opgprof“ erstellten ” gmon.out“. ” Auffällig ist die gegenüber gprof deutlich einfachere Ausgabe. Die erste Spalte beschreibt, wie oft das Programm in der jeweiligen Funktion aufgefunden wurde. Die zweite wie viel Prozent dies insgesamt ausmacht. Die dritte besagt, wo sich die Funktion befindet, und die letzte schließlich wie sie heißt. Falls man das Ausgabeformat von gprof bevorzugt, ist es auch möglich die Daten mit opgprof ins gmon.out“-Format umzu” wandeln; mehr Informationen erhält man dadurch jedoch nicht. Die gleiche Ausgabe, nur umgewandelt und von gprof ausgegeben, ist in Abbildung 3.3 zu sehen. Interessant sind hierbei die geringen Abweichungen bei den Prozentwerten – vermutlich müssen die gmon.out“-Daten 100% ergeben, was bei opreport“ nicht der Fall ist. Deshalb wird ” ” aufgerundet. Eine abschließend klärende Antwort konnte jedoch auch John Levon, der Autor von OProfile, nicht geben. Man erfährt also nicht, wie oft genau eine Funktion aufgerufen wurde oder wie viel Zeit in ihr verbracht wurde. Dies ist schlicht nicht möglich, da OProfile nur Samples 29 (also Stichproben) nimmt1 . Wenn aktiviert, ist es auch möglich sich mittels opreport -cl BEFEHL“ einen Call” graphen ausgeben zu lassen. Dieser ist ähnlich dem von gprof, beinhaltet aber weniger Informationen. Die Vorteile von OProfile sind also die Möglichkeit, ohne weiteres am laufenden System zu messen (der zusätzliche Overhead ist laut den Autoren, je nach Messgenauigkeit, lediglich im einstelligen Prozentbereich), das Ausmessen von Kernelfunktionen, Interrupthandlern und anderen Low-Level-Funktionen sowie die Möglichkeit, das gesamte System zu untersuchen. Die Nachteile sind die Notwendigkeit vom administrativen Zugriff (Kernelmodul, systemweiter Daemon), die fehlenden präzisen Zeitangaben und Aufrufszähler und schließlich die etwas niedrige Granularität: Das kleinste Tickintervall zum Nehmen von Samples ist 3.000. Auf einem Rechner mit 2.2 GHz bräuchte man 2.200, um wirklich im Mikrosekundenbereich zu messen. Nichtsdestotrotz ist OProfile die genauste automatisierte Möglichkeit, sich ein Bild von der Verteilung der Laufzeit einer Applikation zu machen. Anwendungen (wie Netzwerkschnittstellen), für die eine sehr hohe Messgenauigkeit benötigt wird, müssen über eine längere Zeit laufen. Dadurch erhält man statistisch signifikante Werte, obwohl die Messgenauigkeit etwas niedriger als benötigt ist. Dabei darf die Abweichung natürlich nicht allzu groß sein. gprof oder OProfile mit eingestellter niedriger Samplingrate können so nicht aufgewertet werden. 3.5. Callgrind Callgrind ist Teil des Valgrind -Paketes (http://www.valgrind.org/) und verfolgt den komplexesten Ansatz, indem es eine CPU emuliert und dadurch die Ausführung des Programms komplett überwachen kann. Daher ist es am präzisesten, da keine Nebeneffekte durch das Messen auftreten können und jede einzelne Aktion überwacht wird. Leider wird dadurch die Ausführung aber deutlich verlangsamt (bis zu Faktor 50). Zeitangaben erhält man von Callgrind jedoch nicht, denn es misst die Anzahl der Instruktionen und (auf Wunsch) die Cachemisses, also wie oft benötigte Daten nicht im Cache zu finden waren. Aus diesem Grund wird in diesem Zusammenhang von Kosten“ ” statt Zeit“ gesprochen. Diese können sowohl die Anzahl der Instruktionen, als auch die ” cachespezifische Werte sein. Eine Instruktion ist hierbei ein Assemblerbefehl, da diese 1 Bei gprof ist es auch nicht möglich. Die ausgegebenen Zeiten sind eine vorgetäuschte Messgenauigkeit. 30 ==20660== ==20660== ==20660== ==20660== ==20660== ==20660== ==20660== ==20660== ==20660== ==20660== ==20660== ==20660== ==20660== ==20660== ==20660== ==20660== ==20660== ==20660== Events : Ir Dr Dw I1mr D1mr D1mw I2mr D2mr D2mw Collected : 741317 249914 157204 2713 3706 591 1913 1907 436 I I1 L2i I1 L2i refs: 741,317 misses: 2,713 misses: 1,913 miss rate: 0.36% miss rate: 0.25% D D1 L2d D1 L2d refs: 407,118 misses: 4,297 misses: 2,343 miss rate: 1.0% miss rate: 0.5% L2 refs: L2 misses: L2 miss rate: (249,914 rd ( 3,706 rd ( 1,907 rd ( 1.4% ( 0.7% 7,010 ( 4,256 ( 0.3% ( + 157,204 wr) + 591 wr) + 436 wr) + 0.3% ) + 0.2% ) 6,419 rd + 3,820 rd + 0.3% + 591 wr) 436 wr) 0.2% ) Abbildung 3.4.: Cachestatistiken von Callgrind unterschiedlich viele Ticks benötigen können, sind genaue Zeitangaben beziehungsweise Angaben über die Verteilung der Laufzeit nicht möglich. Um annähernd Werte zu erhalten, die die Laufzeit reflektieren, gibt es die Möglichkeit einer Abschätzung. Dazu werden die Instruktionen und Cachemisses zu einem Wert kombiniert. Letztendlich sind das aber auch nur Schätzungen, die Stärken von Callgrind liegen woanders. Wo, wird im Folgenden erläutert. Die Benutzung ist sehr einfach: Man startet callgrind mit dem Befehl, der ausgemessen werden soll und dessen Argumenten. Nach dem Beenden erhält man eine Datei namens callgrind.out.PID“. So ist es einfach möglich, mehrere Messreihen auszuführen, ” ohne dass sich die Ergebnisse überschreiben. Hat man zusätzlich mittels --simulate-cache=yes“ eine Cacheanalyse durchgeführt, ” gibt Callgrind am Ende ebenfalls eine allgemeine Statistik aus, wie in Abbildung 3.4 zu sehen ist. Die erste, von ==“ eingerahmte, Spalte ist die PID des untersuchten Prozesses. ” Anschließend findet eine detaillierte Aufschlüsselung der Cachezugriffe statt. Der L1Cache ist hierbei in einen Instruction- (I) und einen Data- (D) Teil aufgeteilt. Am wichtigsten sind die Zugriffe (refs) und Misses, anhand derer sich die Missrate (miss rate), also der prozentuale Anteil von erfolglosen Cachezugriffen, errechnet. Bei den 31 Abbildung 3.5.: KCachegrind Daten-Caches (also L1-D und L2) sind die Zugriffe ebenfalls nach Lesen (read, rd) und Schreiben (write, wr) aufgeschlüsselt. In diesem Beispiel liegen fast alle Missraten deutlich unter einem Prozent. Der Autor von Callgrind (und dessen Vorgängers, Cachegrind), Josef Weidendorfer, äußerte eine Trefferrate von 97% als ungefähren Richtwert für eine Applikation mit guter Cachenutzung. Insofern ist die Beispielapplikation in dieser Hinsicht positiv zu bewerten. Die während der Laufzeit erstellte Datei kann mit dem Kommandozeilenwerkzeug callgrind annotate analysiert werden. Deutlich komfortabler und übersichtlicher ist jedoch das Programm KCachegrind (http://kcachegrind.sourceforge.net/). Da es zu komplex ist, um hier ausführlich vorgestellt zu werden, seien nur kurz seine Features mit Hilfe eines Screenshots in Abbildung 3.5 herausgestellt. Im linken Bereich ist eine Liste mit allen Funktionen, standardmäßig sortiert nach den kumulativen Kosten. Die Art der Kosten (zum Beispiel Anzahl der Instruktionen, Cachemisses oder abgeschätzte Laufzeit) kann hierbei gewählt werden und sie werden in allen 32 Abbildung 3.6.: KCachegrinds Callmap anderen Teile berücksichtigt. Das heißt, alle Graphen basieren auf der ausgewählten Kostenart. Wird eine Funktion ausgewählt, gibt es im rechten Bereich verschiedene Möglichkeiten der Betrachtung: Alle Parents und Children, Callgraphen, Ansicht des Quelltextes und die besonders interessante Callmap, wie sie in Abbildung 3.6 zu sehen ist. Aus ihr ist noch besser als aus einem Callgraphen ersichtlich, welche Funktion welche Funktion aufruft und vor allem welche Kosten dieser Aufruf verursacht. Das Prinzip ist sehr einfach und gleichzeitig informativ. Als letzte interessante Funktion sei noch das sammeln von Branchentscheidungen genannt. Wird Callgrind mittels --collect-jumps=yes“ gestartet, wird in der Quell” textansicht bei if-Abfragen zusätzlich angezeigt, in wievielen Fällen welche Route genommen wurde. Quantifizierte Aussagen sind mit diesem Werkzeug zwar leicht (die aktuellen Kosten werden stets angezeigt), jedoch ist eine Weiterverarbeitung der Daten nur von Hand möglich: KCachegrind bietet abgesehen vom graphischen Callgraphen keinerlei Exportmöglichkeiten. Callgraphen sind aber neben der Cacheanalyse gleichzeitig die größte Stärke von Callgrind und KCachegrind : Zu jeder Funktion kann man sich den Callgraphen inklusive Eltern und Kindern anzeigen lassen, Listen mit allen Kindern (das heißt auch Kindern von Kindern bis zu einer beliebigen Tiefe) sind verfügbar und eine Grafik zu jeder Funktion ist exportierbar. Ein Beispiel für so eine Grafik ist Abbildung 3.7. Der Graph zeigt den Callstack für die Funktion progress engine() (sie ist ein Teil 33 NEON_Wait 449 677 688 449 677 688 internal_wait 449 677 688 449 677 688 progress_engine 450 392 604 139 861 247 114 480 212 receive_header 139 861 247 receive_payload 114 480 212 31 860 059 31 860 059 recv 63 720 118 68 040 252 epoll_wait 68 040 252 22 680 084 __libc_enable_asynccancel 22 680 084 Abbildung 3.7.: KCachegrinds exportierter Callgraph 34 von NEON, siehe Kapitel 7). In den Knoten stehen neben dem Namen die kumulativen Kosten der jeweiligen Funktion (in diesem Fall Instruktionen), die Achsenbeschriftungen zeigen den Kostentransfer. Das ist hier zum Beispiel bei der Funktion recv() interessant, deren kumulative Kosten sich auf zwei Funktionen verteilen. Der einzige Nachteil von Callgrind ist die Metrik: Zum abschätzen von Zeiten ist er nur sehr bedingt geeignet. Dafür glänzt er im Bereich der Callgraphen und Cacheanalyse und ist somit ein idealer Partner für OProfile, um sich umfassend ein Bild von einer Applikation zu machen. 3.6. Fazit Ein Allheilmittel gibt es beim Analysieren nicht. Abhängig von gewünschten Informationen bieten sich verschiedene Werkzeuge an. • Zum präzisen Ermitteln von Laufzeiten, muss man von Hand messen wie in Abschnitt 3.2 beschrieben. In den meisten Fällen ist solch eine Präzision jedoch nicht notwendig. • Für einen genauen Überblick über die Laufzeit ist OProfile aus Abschnitt 3.4 das beste Werkzeug. • Zur Analyse der Cachenutzung und dem Ermitteln von genauen Callgraphen ist Callgrind (Abschnitt 3.5) zusammen mit der grafischen Oberfläche KCachegrind am besten geeignet. Eine erschöpfende Analyse erfordert also den Einsatz mehrerer Werkzeuge mit unterschiedlichen Schwerpunkten. Aus diesem Grund wurden auch für diese Arbeit verschiedene Werkzeuge eingesetzt. Eine genaue Auflistung befindet sich in Abschnitt 5.2. 35 36 4. Optimierung Das Optimieren sollte stets der letzte Schritt in der Softwareentwicklung sein. Das blinde Optimieren ist ein Fehler, der am besten durch zwei bekannte Zitate illustriert wird, deren Quellen leider nicht mehr zurückzuverfolgen sind: Premature optimization is the root of all evil.“ (C. A. R. Hoare) ” Ersteres weist darauf hin, dass es am wichtigsten ist zuallererst mit sinnvollen Algorithmen einen funktionierenden Prototypen zu haben, ehe man mit Tunnelblick“ ” anfängt Funktionen zu optimieren, die eventuell im Endprodukt gar nicht zum Einsatz kommen. Don’t speculate – benchmark.“ (D. J. Bernstein) ” Zweiteres ist ein Verweis zu Kapitel 3: Glauben beziehungsweise Spekulieren haben bei der Analyse von Software nichts zu suchen. Während Bernstein diese Aussage allgemein machte, bedeutet es in unserem Fall, dass – bevor man anfängt, sich mit der Optimierung zu beschäftigen – es stets notwendig ist, umfassende Messungen anzustellen, um zu wissen, was überhaupt optimiert werden soll. Es ergibt zum Beispiel keinen Sinn, wenn man mit handoptimierten Assembler eine Routine doppelt so schnell macht, die 0.01% der Laufzeit ausmacht. Sobald aber mit Hilfe der im Kapitel 3 vorgestellten Methoden Flaschenhälse und andere Unzulänglichkeiten identifiziert wurden, gilt es, diese zu entfernen beziehungsweise zu entschärfen. 4.1. Möglichkeiten Prinzipiell gibt es bei der Optimierung zwei Möglichkeiten und in der Regel werden auch beide parallel genutzt: Einmal das automatisierte Optimieren durch den Compiler und das Optimieren von Hand. Beide werden im Folgenden vorgestellt. 37 4.1.1. Compiler Jeder moderne Compiler bietet Möglichkeiten der Optimierung. Aufgrund seiner Verbreitung konzentriert sich die Beschreibung auf den GNU C-Compiler (gcc) aus der GCC (GNU Compiler Collection). Der gcc bietet eine große Fülle von Optimierungsoptionen. Im Allgemeinen nutzt man jedoch lediglich einige wenige, die die anderen, feingranulareren, implizieren. Am wichtigsten sind die Metaoptionen zur Angabe des Optimierungsstufen, welche die entsprechenden Unteroptionen setzen. -O0 Keinerlei Optimierungen. Optimal zum Debuggen, da die Reihenfolge der Anweisungen in keiner Weise verändert wird. -O1 Einfachste Optimierungen, ohne die Übersetzungszeit spürbar zu verlängern. -O2 Sinnvolle Standardoptimierung, die die Übersetzungszeitzeit spürbar verlängert, jedoch das Kompilat nicht allzu sehr vergrößert. -Os Entspricht weitestgehend -O2, lässt jedoch alle Optimierungsarten aus, die das Kompilat vergrößern könnten. Interessanter weise sind einige Programme mit Os schneller als mit -O2 oder gar -O3. Dies bestätigt die im Kapitel 2 erstellte Hypothese, dass unnötig große Software schlecht für die Performance ist. -O3 Inlined zusätzlich automatisch Funktionen und führt aggressivere Operandenumstellungen durch. Typischerweise sind Programme, die mit -O3 compiliert wurden am größten. Durch das aggressive Optimieren können sich allerdings auch Fehler einschleichen. So ist es seit geraumer Zeit nicht mehr möglich, den GNU Emacs, wie er derzeit im CVS liegt, mit -O3 zu compilieren. Eine weitere wichtige Option ist -march=Architektur “. Bei Architektur gibt man ” die gewünschte Zielarchitektur an. Beispiele sind i386, pentium4 oder athlon-xp. Im Regelfall bringen diese Optionen jedoch nicht den Schub, den man sich erhofft, da sie im Wesentlichen nur versuchen unter bestimmten Voraussetzungen CPU-spezifische Features wie zum Beispiel SSE oder MMX auszunutzen. Allerdings sind die Stellen, wo dies möglich ist, nur schwer zu erkennen. Insofern kommt man nicht um handgeschriebenen Assembler-Code (siehe Abschnitt 4.1.2) herum, falls man diese Funktionalitäten voll ausnutzen möchte. 38 Speziell auf diese Art von Vektorisierung 1 ausgelegt ist der von Intel hergestellte und vertriebene Compiler icc (Intel C++ Compiler ). Er bringt trotz seines Namens auch auf AMD Prozessoren Performancevorteile (er unterstützt zwar nicht den Pendanten von AMD zu SSE, 3DNow!, jedoch den von beiden unterstützten Vorgänger MMX). Doch ähnlich wie beim gcc sind die Geschwindigkeitsvorteile, je nach Anwendung, nur minimal. Eine schließlich noch beliebte Option für den gcc ist -ffast-math“ welche einige ” riskante Optimierungen durchführt indem einige Fehler nicht abgefangen beziehungsweise signalisiert werden (zum Beispiel wird errno bei Overflows nicht gesetzt). Es kann einiges an Geschwindigkeit bringen, sollte aber nur eingesetzt werden, wenn man weiß was man tut. Insbesondere kann es riskant sein, wenn man mit Werten rechnet, die von außen übergeben werden. Die letzte Möglichkeit, die zumindest halbautomatisch abläuft und vom Compiler unterstützt wird, ist das zweistufige Compilieren. gcc implementiert diese Funktionalität seit Version 4.0. Hierfür wird das Programm zunächst mit -fprofile-generate“ compiliert. An” schließend benutzt man es auf eine typische Art und Weise“. gcc hat beim ersten ” Compilieren Code eingefügt, welches Daten während des Testlaufs gesammelt hat. Abschließend compiliert man das Programm nochmal, jedoch mit -fprofile-use“. Der ” Compiler nutzt nun die gesammelten Daten und versucht, das Programm entsprechend zu optimieren. Ähnliches kann man manuell, wie in Abschnitt 4.1.2 vorgestellt wird, mittels Hints erreichen. 4.1.2. Manuell Das manuelle Optimieren ist zwar deutlich komplizierter und erfordert einschlägige Erfahrung, hat im Endeffekt jedoch deutlich höheres Potential. Das noch einfachste Optimieren, sind Hinweise ( Hints“) für den Compiler, damit ” dieser besser optimieren kann. Hierfür ist das gcc-spezifische Schlüsselwort b u i l t i n e x p e c t ( Ausdruck , E r w a r t e t e s E r g e b n i s ) Üblicherweise definiert man sich der Einfachheit halber die folgenden Makros: # define 1 l i k e l y (x) b u i l t i n e x p e c t ( ( x ) , 1) Der Begriff kommt daher, dass die Multimediaerweiterungen wie MMX (M ultimedia Ex tensions) oder SSE (Internet S treaming S IMD E xtensions) vektorbasiert sind, also die Ausführung von mehreren Befehlen gleichzeitig unterstützen. Dies deutet bereits das zweite S in SSE an: SIMD bedeutet S ingle I nstruction, M ultiple Data – es wird also der gleiche Befehl parallel über mehrere Daten getätigt. 39 # define unlikely (x) b u i l t i n e x p e c t ( ( x ) , 0) Damit sagt man dem Compiler, welcher Zweig einer if-Abfrage der wahrscheinlichere ist. Der Compiler kann dann für den Common Case optimieren, ohne dass zwei Compilerdurchläufe samt Testdurchlauf nötig wären. Im Folgenden werden mehrere Techniken zur Optimierung vorgestellt. Ähnlich wie in Kapitel 2 gibt es hier viele Ansichten und Quellen, die zu einem Ganzen zusammengefügt wurden. Konsultiert wurden insbesondere [Ray03], [Num00], [Lei01] und [WC00]. Letztendlich sind aber viele der hier vorgestellten Möglichkeiten Optimierungs-Allge” meinwissen“ und somit unmöglich speziellen Quellen zuzuordnen. Algorithmen Da Algorithmen den größten Einfluss auf die Performance haben, sollten sie auch als erste hinterfragt werden. Ein umständliches Quicksort ist im Common Case Fällen immer noch deutlich schneller, als ein hochoptimiertes Bubblesort. Hilfreich bei der Wahl eines Algorithmus sind Bücher wie zum Beispiel [CLRS01]. Selbsterdachte Algorithmen sind meist nicht schneller als bereits bekannte und erprobte. Zusätzlich kosten sie noch Zeit bei der Entwicklung. Lohnend ist es oft, bekannte Algorithmen für spezielle Anwendungen anzupassen. Datenstrukturen Großen Einfluss hat auch die Wahl der Datenstrukturen (auch hier helfen Bücher wie [CLRS01]). Um eine gute Wahl treffen zu können, muss sich der Entwickler im Vorhinein im Klaren sein, was die übliche Anwendung sein wird. Soll möglichst schnell iteriert und schnell auf einzelne Elemente zugegriffen werden können, ist das klassische Array die beste Wahl. Es ist sehr Cachefreundlich, erlaubt Zugriff auf das n-te Element in O(1) und – entgegen verbreiteter Überzeugung – lässt sich sehr einfach mittels realloc() in der Größe verändern. Muss man jedoch nur oft an einem der beiden Enden einer linearen Liste etwas ändern (zum Beispiel eine klassische FIFO-Queue), ist eine verkettete Liste die bessere Wahl. Bäume und andere fortgeschrittenen Datenstrukturen sind nur in seltenen Fällen sinnvoll. Das sind Fälle mit sehr großen Datenmengen, auf die schnell zugegriffen werden muss, wie zum Beispiel Dateisysteme. Im Normalfall ist der Aufwand aber höher, als die gewonnene Performance. Inlining Ausgesprochen kurze Routinen sollten nicht als normale Funktion realisiert werden. Der Aufwand für den Funktionsaufruf kann leicht größer werden, als der, 40 der eigentlichen Funktion. Man sorgt stattdessen dafür, dass der gewünschte Code eingefügt wird. Erreichen kann man dies auf zwei Arten. Zum einen mittels des inline-Schlüsselwortes (unter Beachtung der Warnungen in Abschnitt 2.2) und zum anderen über PräprozessorMakros. Allerdings ist das Inlining ein zweischneidiges Schwert und sollte nur mit größter Sorgfalt eingesetzt werden. Bei einer zu breiten Anwendung wird aus dem Programm Bloat, ohne dass man es dem Sourcecode oder dem Architekturdesign unmittelbar ansieht. Loop-Unrolling Während exzessives Loop-Unrolling ähnlich gefährlich ist wie Inlining, kann es dennoch gegenüber automatisierter Optimierung große Geschwindigkeitsvorteile erreichen. Die Idee ist, bei Schleifen einfach statt einer Instruktion mehrere zu machen und im Anschluss den Rest zu erledigen. Statt while ( l e n −−) { ∗ d e s t++ = ∗ s r c ++; } schreibt man while ( l e n >= 4 ) { dest [ 0 ] = src [ 0 ] ; dest [ 1 ] = src [ 1 ] ; dest [ 2 ] = src [ 2 ] ; dest [ 3 ] = src [ 3 ] ; l e n −= 4 ; s r c += 4 ; d e s t += 4 ; } while ( l e n −−) ∗ d e s t++ = ∗ s r c ++; um vier statt einer Zuweisung je Iteration durchzuführen. Die erste Konstruktion braucht auf einem AMD Athlon-XP 3200+ für 50 Bytes circa 320 Ticks, die zweite nur 120. Wir sind durch Loop-Unrolling also mehr als doppelt so schnell! 41 Der Grund dafür ist trivial nachzuvollziehen: Um vier Bytes zu kopieren, braucht die erste Version vier Zuweisungen und vier Sprünge. Die zweite benötigt nur die vier Zuweisungen und einen Sprung (zum Überspringen der zweiten whileSchleife). Für acht Bytes braucht man im ersten Fall acht Zuweisungen und acht Sprünge, im zweiten acht Zuweisungen und zwei Sprünge. Bei nicht durch vier teilbaren Größen addiert sich gleichmäßig jeweils die Anzahl der Zuweisungen und Sprünge. Durch Loop-Unrolling kann also sehr viel zusätzliche Performance erreicht werden. Theoretisch kann der Compiler dieses Loop-Unrolling auch automatisch durchführen (-funroll-loops), jedoch sind die Ergebnisse meist nicht optimal. Als Programmierer kann man viel besser abschätzen, wie viele Schritte innerhalb einer Iteration am zweckmäßigsten sind. Zusätzliche Geschwindigkeitsvorteile können beim Abarbeiten des Rests durch den Einsatz des Duff ’s Device [Duf88] statt der einfachen while-Schleife erreicht werden: switch ( l e n ) { case 3 : d e s t [ 2 ] = s r c [ 2 ] ; case 2 : d e s t [ 1 ] = s r c [ 1 ] ; case 1 : d e s t [ 0 ] = s r c [ 0 ] ; } Da die case-Zeilen keine break-Anweisungen enthalten, werden genau so viele Bytes kopiert, wie noch ausstehen. Bei maximal drei Bytes, wie in diesem Beispiel, ist der Gewinn allerdings nicht groß. Assembler In manchen Fällen lohnt es sich spezielle, oft aufgerufene Routinen in Assembler zu implementieren. Diese Möglichkeit ist insbesondere interessant, falls die SIMD-Fähigkeiten (MMX, SSE und so weiter) moderner CPUs ausgenutzt werden können. Typische Anwendungsbeispiel dafür sind Routinen die viel Speicher verschieben (zum Beispiel memcpy()) oder exzessiv mit Gleitkommeoperationen operieren (zum Beispiel Shader in der Computergrafik). Entfernung von Nachlässigkeiten Dieser Punkt ist allgemein und bezieht sich auf alles, was in Abschnitt 2.3.3 vorgestellt wurde. 42 Ein reales Beispiel, wie man ihn in verschiedenen Abwandlungen oft sieht, ist zum Beispiel die folgende Schleife: for ( int i = 0 ; i < 4 2 ; i ++) v [ i ] += a t o i ( s t r ) ; Hier wird bei jedem Schleifendurchlauf ein String in eine Zahl umgewandelt. In schlimmeren Fällen werden ganze Textdateien geparsed. Wenn die Schleife nicht so eng ist wie im Beispiel, fällt der Fehler auch nicht so schnell auf. Eine bessere Version ist: int x = a t o i ( s t r ) ; for ( int i = 0 ; i < 4 2 ; i ++) v [ i ] += x ; Diese Schleife hat zwar jeweils eine Zeile und Variable mehr, ist aber je nach Länge des Strings deutlich schneller, da das Parsen von Strings mindestens in O(n) liegt. Auf einem AMD Athlon-XP 3200+ mit str = ‘‘42’’ dauert die erste Version circa 5400 Ticks, während die zweite nur 216 Ticks benötigt. Die Schleife ist also um Faktor 20 schneller! Es bleibt der Hinweis, dass sämtliche Optimierungen überprüft werden sollten, ob sie wirklich schneller sind. Dies sei an einem angepassten Beispiel aus [Lei01] vorgeführt: void copy ( char ∗ d e s t , const char ∗ s o u r c e , unsigned int l e n g t h ) { for ( int i = 0 ; i < l e n g t h ; i ++) dest [ i ] = source [ i ] ; } Es handelt sich um eine Kopierfunktion, wie zum Beispiel memcpy(). In dieser Version wird eine einfache for-Schleife genutzt. Versucht man zu Optimieren, indem man statt der for-Schleife Zeigerarithmetik nimmt, und so auch die Variable einspart, erhält man Folgendes: void copy ( char ∗ d e s t , const char ∗ s o u r c e , unsigned int l e n g t h ) { while ( l e n g t h −−) ∗ d e s t++ = ∗ s o u r c e ++; } 43 Sieht schlanker und schneller aus – ist aber langsamer. Auf einem AMD Athlon-XP 3200+ braucht die erste Funktion 198 Ticks für 50 Bytes. Die zweite hingegen 225 Ticks. Grund für solche Effekte sind spezielle Eigenheiten von CPUs und Architekturen2 , die der Compiler besser kennt und ausnutzt als ein durchschnittlicher Programmierer. Durch die for-Schleife sagt man ihm expliziter, was man erreichen möchte und der Compiler kann sich darauf einstellen. 4.2. Fazit Beim Optimieren hat der Entwickler viele Werkzeuge und Möglichkeiten, die er zu seinem Vorteil einsetzen kann. Um die manuellen Optimierungen sinnvoll einsetzen zu können, ist Erfahrung und vor allem eine vorhergehende umfangreiche Analyse notwendig. Optimiert ein Programmierer auf Verdacht“ und ohne seine Arbeit kritisch zu überprüfen, ” kann es passieren, dass seine Bemühungen gegenteiligen Effekt haben und die Software noch langsamer wird. 2 In diesem Fall ist das Problem, dass auf einigen CPUs Zeigerarithmetik langsamer ist, als das Adressieren über Indizes. Näheres zur maschinennahen Programmierung findet man in [PH98] und [Int02]. 44 5. Vorgehen bei den Analysen Um das nun erworbene Wissen anzuwenden, werden zwei Netzwerkschichten untersucht: BMI vom PVFS2 und NEON, ein OSC-API. Obwohl beide APIs Netzwerkschnittstellen darstellen, sind sie aufgrund unterschiedlicher Ansprüche nicht unmittelbar vergleichbar. Die Analyse an sich wurde jedoch trotzdem in beiden Fällen identisch und unter gleichen Bedingungen durchgeführt. Mit welchen Werkzeugen auf welcher Hardware sie wie untersucht wurden, ist der Inhalt dieses Kapitels. 5.1. Messumgebung Sämtliche Messungen finden auf zwei identischen PC-Systemen statt. Als Betriebssystem kommt Linux zum Einsatz. Die Vernetzung fand Back-To-Back (also ohne Switch/Hub dazwischen) über Gigabit-Ethernet statt. Die genaue Konfiguration kann aus Tabelle 5.1 (Hardware) und Tabelle 5.2 (Software) entnommen werden. CPU Board RAM PCI-Bus NIC Vernetzung 2x Pentium III @ 800 MHz, 256 KB Cache Es wird jedoch nur eine CPU genutzt. Gigabyte GA-6VXD C7 512 MB, 133 MHz SDRAM 32 Bit @ 33 MHz Intel Corporation 82544EI Gigabit Ethernet Controller (Fiber) (rev 02) Back-to-Back CORNING FutureLink J-VH 2x1G50/12 TB3 FRNC Tabelle 5.1.: Hardware Dass auf einem SMP1 -System lediglich eine CPU genutzt wird, mag seltsam anmuten. 1 S ymmetric M ultiprocessing, das Einsetzen mehrerer identischen CPUs auf einem Mainboard. 45 Latency Send 1 - Receive 1 66 64 BMI TCP SMP BMI TCP Raw TCP Raw TCP SMP 62 60 Time [us] 58 56 54 52 50 48 46 44 10 20 30 40 Message Size [bytes] 50 60 Abbildung 5.1.: Latenzen für BMI-TCP und TCP, mit und ohne SMP. Der Grund hierfür ist jedoch, dass Multiprozessor-System deutlich underterministischer sind. In Messreihen wurden Schwankungen im Bereich von bis 20% festgestellt. Vergleichende Messungen für TCP und BMI-TCP mit und ohne aktiviertem SMP ergaben Abbildung 5.1. TCP wird durch SMP circa 5µs schneller, während BMI-TCP um einen ähnlichen Wert langsamer wird. NEON, welche aus Übersichtsgründen in der Grafik fehlt, alterniert unter SMP sogar undeterministisch um bis zu 20µs. Die Gründe dafür sind vielfältig. Zum Beispiel in multithreaded Applikationen wie NEON (Kapitel 7), verteilen sich die Threads auf mehrere CPUs. Normalerweise ist dies wünschenswert, in dieser Anwendung jedoch nicht, da der zusätzliche Thread nur aktiv sein soll, wenn der Hauptthread nicht kommuniziert. Dies führt dazu, dass sich die Threads einerseits beim Locking behindern. Andererseits ist es ungünstig für Datenzugriffe, da die CPUs jeweils eigene Caches haben. Ein gegenseitiges Invalidieren von Cacheinformationen führt zu unnötigen Cachemisses. Die genaue Untersuchung dieser Effekte würde jedoch den Rahmen dieser Arbeit überschreiten. 46 5.2. Verwendete Werkzeuge 5.2.1. Analyse Zur Analyse werden die in Abschnitt 3.6 vorgestellten Werkzeuge verwendet: OProfile 0.9.1 für die Übersicht über die Laufzeitverteilung und Callgrind 0.10.1 für die Cacheanalyse und detaillierte Callgraphen. Für das manuelle Lesen von Sourcecode kam hauptsächlich GNU Emacs zusammen mit dem mitgelieferten Programm etags zum Einsatz. 5.2.2. Benchmark Da es sich sowohl bei BMI als auch bei NEON um APIs handelt, ist zum Analysieren eine Anwendung notwendig, die sie benutzt. Inspiriert durch das Tool sockping, welches Bestandteil von Lars Schneidenbachs Diplomarbeit [Sch03] war, wurde dafür eigens ein flexibles Werkzeug mit dem Namen eins (eins i s not sockping) entwickelt. Ähnlich wie sockping ermöglicht es Ping-Pong-Messungen. Darunter ist zu verstehen, dass der Client dem Server eine Nachricht bestimmter Größe zuschickt und misst, wie lange es dauert, bis die gleiche Nachricht wieder bei ihm ankommt. Während sockping dies nur für TCP-Sockets ermöglichte, stellt eins ein Framework zur Verfügung, für das Module ein API implementieren und sich weitestgehend nur um die Belange des Netzwerkes kümmern müssen. Die Architektur eines solchen Moduls ist in Abbildung 5.2 zu sehen. foo handle arg() Ermöglicht eigene Argumente für einzelne Module im Clientmodus. Beim Parsen der Argumente werden modulspezifische Optionen mit Hilfe dieser OS Kernel NIC-Treiber PVFS2 MPICH2 Compiler libc NEON eins Fedora Core 4 Linux 2.6.15 (vanilla), ohne SMP-Unterstützung e1000 1.3.2, epoll aktiviert 1.0.3 gcc (Red Hat 4.0.0-8) glibc 2.3.5-10 Subversion-Revision 635 Subversion-Revision 637 Tabelle 5.2.: Software 47 mod_foo +foo_handle_arg(in opt:char,in arg:char *): bool +foo_init(in ma:mod_args *): bool +foo_measure(): double +foo_serve(ma:mod_args *): bool +foo_cleanup(): void Abbildung 5.2.: Architektur eines fiktiven eins-Moduls foo“. ” Funktion an das Modul weitergereicht. foo init() Initialisiert das Netzwerksystem für den Client, falls nötig sollte ein Handshake mit dem Server geschehen, um zum Beispiel abzuklären, wie große Pakete verschickt werden oder welche Optionen durch den Benutzer übergeben wurden. foo measure() Führt die eigentliche Messung durch und liefert die Zeit als double zurück. Um die Zeitmessung zu vereinheitlichen und einfach zu halten, bietet eins dafür einige Hilfsroutinen. Für diesen Zweck sind get time() (Ermittlung eines Zeitstempels) und time diff() von Relevanz. Im Allgemeinen sieht diese Funktion wie folgt aus: double foo measure () { t i m e 5 8 6 ta , tb ; get time ( ta ) ; MODULSPEZIFISCHES SENDEN ( ) ; MODULSPEZIFISCHES EMPFANGEN ( ) ; g e t t i m e ( tb ) ; return t i m e d i f f ( tb , t a ) ; } Wie man erkennen kann, können sich die Module aufs Wesentliche konzentrieren. Die Ausgabe der Ergebnisse, Berechnung des Mittelwertes und andere netzwerkunabhängige Aufgaben geschehen für die Module vollkommen transparent. foo serve() Dies ist die einzige Server-Funktion. Hier muss sowohl die Initialisierung als auch die Ping-Pong-Schleife enthalten sein. 48 foo cleanup() Zurücksetzen der Netzwerkschicht in den ursprünglichen Zustand. Für diese Arbeit sind insbesondere die Module für TCP, UDP [Pos80], BMI und NEON von Interesse. 5.3. Durchführung Beide Netzwerkschnittstellen werden zunächst mit dem Ping-Pong-Benchmark eins vermessen und mit den Messwerten vom reinem TCP über Sockets verglichen. Betrachtet werden die Latenzwerte im linearen Bereich von 1 bis 64 sowie die Bandbreite auf einer logarithmischen X-Achse im Bereich 1 bis 66.000. Im zweiten Schritt wird mit Hilfe von vorhandenen Dokumenten, Callgrind und manueller Sourcecodekonsultation die Architektur ermittelt und dargestellt. Zuletzt findet eine Laufzeitanalyse mittels OProfile und eine Cacheanalyse mittels Callgrind statt. Bei diesen statistischen Messungen wird für Nachrichtengrößen von 1 bis 1400 Bytes gemessen. Für größere Werte verfälscht die Maximum Transmission Unit (MTU, für Ethernet normalerweise 1500 Bytes) die Werte, da Datenpakete ab diesem Wert fragmentiert werden müssen. Da NEON mehr als nur eine Netzwerkabstraktion darstellt, sondern mit OSC auch eine Funktionalität zur Verfügung stellt, sind die Ergebnisse des reinen Ping-PongBenchmarks nicht mit denen von BMI vergleichbar. Aus diesem Grund wird NEON ebenfalls mittels eines Zellularautomaten untersucht und mit den Ergebnissen von MPI verglichen. Im Anschluss an die Analyse werden Optimierungen vorgeschlagen und, falls angebracht, auch durchgeführt. Zum Abschluss wird versucht, qualifizierte Aussagen über beide Netzwerkschnittstellen zu machen. 49 50 Teil II. Untersuchung der Netzwerkschnittstellen 51 6. BMI Das Buffered Messaging Interface (BMI) ist die Netzwerkschnittstelle des verteilten Dateisystems Parallel Virtual Filesystem (PVFS2) (http://www.pvfs2.org/). Als solches ist es maßgeblich an der Gesamtperformance des Dateisystems mitverantwortlich. Es ist jedoch nicht von PVFS2 abhängig und kann unabhängig in anderen Projekten eingesetzt werden. Entsprechend leicht war es, BMI in eins (siehe Abschnitt 5.2) zu integrieren. Prinzipiell ist es ein fest definiertes API mit dokumentiertem Verhalten1 . Das untersuchte BMI-TCP ist eine von mehreren Implementierungen dieses APIs. Andere Alternativen sind InfiniBand und Myrinet (Stand: PVFS2 1.3.2). Im PVFS2- beziehungsweise BMI-Jargon heißen diese Implementierungen Methoden. Nota bene, dass diese Methoden nichts mit den Methoden in objektorientierten Programmiersprachen zu tun haben. Nach außen bietet es dem Nutzer einen zuverlässigen Datagramm-Service. Man kann also einzelne Pakete verschicken (im Gegensatz zu TCP [Pos81], welches streamorientiert ist) wie beim Unreliable Datagram Protocol (UDP [Pos80]), hat jedoch die Garantie, dass sie ankommen. Und zwar nur einmal und in der richtigen Reihenfolge. Deshalb kann man im Zusammenhang mit BMI auch vom einem Reliable Datagram Service (RDP) sprechen. Eine detailliertere Beschreibung des APIs ist in [Kli05] zu finden. In dieser Arbeit liegt der Fokus auf dem Laufzeitverhalten und der Implementierung der TCP-Methode. Wichtige BMI-Konzepte werden ebenfalls nicht gesondert, sondern zusammen mit ihrer Umsetzung innerhalb von BMI-TCP erläutert. 6.1. Messergebnisse Obwohl die Messwerte aus [Kli05] zur Verfügung stehen, ist ein erneutes Nachmessen erforderlich, da sich die verwendete Hard- und Softwareumgebung unterscheidet (siehe 1 Laut [Kli05] ist dieses Verhalten jedoch nur lückenhaft dokumentiert. Infolgedessen gelang es nicht vollständig, PVFS2 erfolgreich zusammen mit dem in der Arbeit entwickeltem BMI-GAMMA zu benutzen. 53 Latency Send 1 - Receive 1 70 HS: BMI TCP HS: Raw TCP CK: BMI GAMMA CK: Raw GAMMA 60 Time [us] 50 40 30 20 10 0 10 20 30 40 Message Size [bytes] 50 60 Abbildung 6.1.: Latenzen von TCP, BMI-TCP, GAMMA und BMI-GAMMA. Abschnitt 5.1). Hierfür werden reines TCP und BMI-TCP ausgemessen und verglichen. Die Latenzen sind zum Vergleich zusammen mit den GAMMA-Ergebnissen aus [Kli05] in Abbildung 6.1 zu sehen. Um Missverständnissen vorzubeugen, sind die Messkurven aus [Kli05] hierbeit durch ein CK:“ markiert, eigene Kurven mit einem HS:“. Die Bandbreiten ” ” sind in Abbildung 6.2 – wegen der Übersichtlichkeit ohne die GAMMA-Kurven – zu sehen. Die Kurven bestätigen die Messwerte aus [Kli05] nur teilweise: Der Abstand zwischen den Latenzen von TCP und BMI-TCP beträgt lediglich circa 10µs. Auch wenn die Werte aufgrund anderer Messumgebung insgesamt schlechter als die aus [Kli05] sind, ist der Abstand zwischen TCP und BMI-TCP deutlich geringer. Bei genauem Betrachten und Vergleich mit Abbildung 1.1 ist jedoch erkennbar, dass vor allem TCP schlechter abschneidet. Da die Messungen in [Kli05] auf einem SMP System entstanden, kann – angesichts der in Abschnitt 5.1 angestellten Untersuchungen – diese Differenz nachvollzogen werden. In letzter Konsequenz ist die Differenz zwischen den Messwerten von TCP und BMI-TCP stark davon Abhängig, ob sie auf einem SMPSystem laufen. BMI-GAMMA hatte gegenüber GAMMA in [Kli05] einen Overhead von 5µs. Der 54 Bandwidth Send 1 - Receive 1 30 Raw TCP BMI TCP 25 Bandwidth [MB/s] 20 15 10 5 0 1 4 16 64 256 Message Size [bytes] 1024 4096 16384 Abbildung 6.2.: Bandbreiten von TCP und BMI-TCP. Overhead von BMI-TCP gegenüber TCP ist somit um 100% größer als der Overhead von BMI-GAMMA gegenüber GAMMA. Der relative Overhead ist zwar in beiden Fällen sehr ähnlich, jedoch nicht relevant, da er nicht an die Latenzen der darunter liegenden Netzwerkschicht gebunden ist. Das schlechte Abschneiden von BMI-TCP gegenüber BMI-GAMMA wurde bereits in [KSS05] thematisiert und der Öffentlichkeit vorgestellt. 6.2. Architektur Im Folgenden wird die Architektur und Funktionsweise von BMI im Allgemeinen und BMI-TCP im Speziellen vorgestellt. Insbesondere wird im zweiten Teil untersucht, welche Codepfade für einzelne Operationen gegangen werden. 6.2.1. BMI und PVFS2 PVFS2 an sich hat keinerlei Kenntnis über das darunterliegende Netzwerksystem. Es initialisiert lediglich anhand von Konfigurationsdateien BMI, welches wiederum über keine netzwerkspezifischen Funktionalität verfügt. 55 BMI Interface Method Control Network Address Reference List Method Interface Method Interface Method One Method Two Operation Queues Operation Queues Abbildung 6.3.: Funktionsweise von BMI. Aus [PVF02a]. Abbildung 6.3 illustriert die Funktionsweise von BMI: Die eigentliche Aufgabe ist das Dispatching von Funktionsaufrufen an Netzwerkmethoden wie BMI-TCP. Es handelt sich also um ein intelligentes API“, die Funktionsaufrufe abhängig von Ihren Argumen” ten weiterleitet. Da BMI es erlaubt, mehrere Netzwerkmodule gleichzeitig zu benutzen, geschieht die Auswahl anhand der Adressen. Beim Auflösen von Adressen (BMI addr lookup()) erfolgt dies über einen Adresspräfix. Für BMI-TCP könnte eine solche Adresse zum Beispiel wie folgt aussehen: tcp://rechner.netz.de:3334. Bei allen übrigen Funktionsaufrufen wird als Ziel das von BMI addr lookup() zurückgegebene Adresshandle method addr übergeben. Diese werden in einer Hashtabelle verwaltet und enthalten auch Informationen über die dazugehörige Methode. Dies ist insofern nachvollziehbar, da auf einem ausgelasteten Server ein O(n)-Algorithmus nicht akzeptabel wäre. Bei Hashtabellen ist der Zugriff im Best Case eine O(1)-Operation mit nur minimalen konstanten Overhead c. Schlecht geplante Hashes können jedoch in den O(n)-Bereich rutschen“. Dies ge” schieht, wenn die Berechnung des Hashwertes viele (oder gar alle) Schlüssel auf einen Wert mappen. Hashes und verschiedene Hashfunktionen werden ausführlich in [CLRS01] behandelt. 56 Integration von Methoden Für BMI sind BMI-Methoden lediglich Strukturen, die zum größten Teil aus Funktionszeigern bestehen. Hinter diesen Zeigern befinden sich die methodenspezifischen Implementierungen des BMI-API. Zur vollständigen Wiedergabe ist diese Struktur zu groß, Abbildung 6.4 zeigt jedoch einen Ausschnitt. Innerhalb PVFS2s ist der direkte Nutzer von BMI der Flow . Seine Aufgabe ist es allgemein effizient Daten zwischen zwei Orten zu bewegen. Diese können jeweils im Speicher, auf einer Festplatte oder im Netzwerk liegen. Für Letzteres verwendet Flow das BMI. Weitere Informationen über das Design vom Flow können in [PVF02b] gefunden werden. 6.2.2. Aufbau von BMI-TCP Nachdem BMIs Platz und Funktion innerhalb von PVFS2 geklärt sind, wird die konkrete BMI-TCP Methode erläutert. Da BMI-TCP versucht, mit TCP ein verbindungs- und streamorientiertes Protokoll auf ein zuverlässiges Datagrammprotokoll (reliable datagram protocol, RDP ) abzubilden, ergeben sich bei der Implementierung zwei Hauptprobleme: Verbindungsverwaltung Ein RDP kann spontan Nachrichten an jeden Host schicken. TCP benötigt vorher eine Verbindung zu dem gewünschten Rechner. Um transparent RDP-Pakete verschicken zu können, muss BMI-TCP automatisch TCPVerbindungen aufbauen und anschließend verwalten. Da ein Verbindungsaufbau auf dem Testsystem circa 50µs dauert, ist es ausgeschlossen, für jedes Paket ein neues connect() zu verwenden. Transferverwaltung In einem streamorientierten Protokoll wie TCP gibt es keine Pa” kete“. Deshalb ist es möglich, dass en Block verschickte Nachrichten im Worst Case byteweise ankommen. Diese einzelnen Fragmente müssen von BMI-TCP gesammelt und den richtigen Nachrichten zugeordnet werden. Da ein Transfer“ in BMI eine Operation (genaue Definition folgt) ist, wird im ” Folgenden in diesem Zusammenhang von Operationsverwaltung gesprochen. Diese beiden Problematiken werden im Folgenden erörtert. Zuvor jedoch werden hierfür notwendige Grundlagen untersucht: Wie die Datenpakete aussehen, die BMI-TCP verschickt, was genau Operationen sind, welche Zustellmodi für Nachrichten BMI-TCP kennt und wie die Kommunikation gelöst ist. 57 struct bmi method ops { const char ∗ method name ; int ( ∗ B M I m e t h i n i t i a l i z e ) ( method addr p , int , int ) ; int ( ∗ B M I m e t h f i n a l i z e ) ( void ) ; int ( ∗ B M I m e t h s e t i n f o ) ( int , void ∗ ) ; int ( ∗ B M I m e t h g e t i n f o ) ( int , void ∗ ) ; void ∗ ( ∗ BMI meth memalloc ) ( b m i s i z e t , enum b m i o p t y p e ) ; int ( ∗ BMI meth memfree ) ( void ∗ , bmi size t , enum b m i o p t y p e ) ; int ( ∗ BMI meth post send ) ( b m i o p i d t ∗ , method addr p , const void ∗ , bmi size t , enum b m i b u f f e r t y p e , bmi msg tag t , void ∗ , bmi context id ) ; int ( ∗ BMI meth post sendunexpected ) ( b m i o p i d t ∗ , method addr p , const void ∗ , bmi size t , enum b m i b u f f e r t y p e , bmi msg tag t , void ∗ , bmi context id ) ; int ( ∗ BMI meth post recv ) ( b m i o p i d t ∗ , method addr p , void ∗ , bmi size t , bmi size t ∗ , enum b m i b u f f e r t y p e , bmi msg tag t , void ∗ , bmi context id ) ; [...] }; Abbildung 6.4.: Ausschnitt aus der Definition von BMI-Modulen. 58 # define TCP ENC HDR SIZE 24 struct t c p m s g h e a d e r { u i n t 3 2 t m a g i c n r ; /∗ , , Magische Zahl ’ ’ a l s Kennung von ∗ BMI−N a c h r i c h t e n ( 5 1 9 0 3 ) . ∗/ u i n t 3 2 t mode ; /∗ Eager o d e r Rendez−Vous ? ∗/ b m i m s g t a g t t a g ; /∗ B e n u t z e s p e z i f i s c h e r Tag ∗/ bmi size t size ; /∗ Laenge d e r N a c h r i c h t ∗/ char e n c h d r [ TCP ENC HDR SIZE ] ; /∗ S i e h e Text ∗/ }; Abbildung 6.5.: Die Struktur tcp msg header. 6.2.3. Sendeformat Um Daten einem Kontext zuordnen zu können, benutzen Netzwerkprotokolle traditionell Header , die beim Versenden vor die eigentliche Nachricht gelegt werden. BMI-TCP tut dies ebenfalls und verschickt seine Daten zusammen mit der Struktur tcp msg header, die in Abbildung 6.5 zu sehen ist. BMI-TCP verschickt die Struktur jedoch nicht einfach so“. Vielmehr werden nur ” die ersten vier Felder mittels BMI TCP ENC HDR() und BMI TCP DEC HDR() kodiert beziehungsweise dekodiert und im letzten Feld (enc hdr) abgelegt. Dieses 24 Byte große Feld wird letztendlich auch versendet beziehungsweise empfangen. Der Zweck hiervon ist es, Zahlen portabel zu übertragen. Die beiden genannten Makros tun hierbei nicht mehr, als die Zahlen in ein internes Format zu wandeln. Das Feld enc hdr ist also gewissermaßen nur ein Cache, um diese Konvertierung nicht mehrfach durchführen zu müssen. 6.2.4. Operationen Die Hauptakteure innerhalb BMI-TCP sind Operationen. Jede Nachricht – egal ob sie versendet oder empfangen werden soll – wird auf eine solche Operation abgebildet. Innerhalb BMIs ist dies der übliche Weg und wird durch Hilfsfunktionen zur Verwaltung von ihnen unterstützt. Abgesehen vom Abbruch (BMI cancel()) sind sämtliche Funktionen auf Operationen asynchron. Asynchron ist hierbei nicht mit non-blocking zu verwechseln: Während asynchron bedeutet, dass Daten an eine Schicht übergeben werden, die sie potentiell parallel verarbeitet, bedeutet non-blocking lediglich, dass die Funktionsaufrufe nicht blockieren und sofort zurückkommen. Also zum Beispiel auch, wenn keine Daten zum empfangen 59 bereit liegen oder die übergebenen Daten nicht versenden werden können. Bei einem asynchronen API würde es sich darum kümmern, die Daten vollständig zu empfangen beziehungsweise zu versenden. Bei einem non-blocking API muss der Benutzer diese Aufgaben übernehmen. Für TCP-Sockets gibt es beide Alternativen: Man kann sie in einen non-blockingModus versetzen oder das – derzeit leider wenig verbreite – POSIX Asynchronous IO (AIO) API benutzen. Im BMI-Jargon bedeutet asynchron“, dass sie Operationen gepostet 2 werden anstatt ” unmittelbar ausgeführt zu werden. Außerdem werden für ankommende Nachrichten – für die es noch keine passende Empfangsoperation gibt – ebenfalls welche angelegt. Trotzdem kann es passieren, dass Operationen sofort ausgeführt werden. Dies wird als Immediate Completion bezeichnet und wird von allen Funktionen durch eine 1“ als ” Rückgabe signalisiert. Der Zustand von Operationen wird in der Struktur method op gespeichert. Hierbei gibt es den allgemeinen BMI-Zustand, der von allen BMI-Methoden genutzt wird. Zusätzlich gibt es den methodenspezifischen Zustand, welcher in dem Feld method data gespeichert ist. Bei BMI-TCP heißt die spezifische Struktur tcp op und wird im Abschnitt über die Operationsverwaltung näher beschrieben. Sobald Operationen gepostet wurden, muss mittels BMI test() überprüft werden, ob – und wie erfolgreich – sie abgeschlossen wurden. Da BMI nicht multithreaded ist, folgt daraus, dass normalerweise die Kommunikation komplett innerhalb von BMI test() stattfindet. Zumindest bei BMI-TCP ist es hierbei unwichtig, für welche Operation BMI test() aufgerufen wurde: Mittels einer einstellbaren Metrik wird festgelegt, wie viele bereite Sockets pro BMI test()-Aufruf abgearbeitet werden. Es kann also passieren, dass bei einem Aufruf von BMI test() überhaupt keine Daten für die Operation, die übergeben wurde, übertragen werden. Da BMI grundsätzlich nur numerische IDs als 64 Bit Integer dem User beim Posten zurück gibt, muss BMI-TCP eine Möglichkeit haben, von den ganzzahligen IDs auf die dazugehörige Operation, beziehungsweise ihre method op-Struktur, schließen zu können. BMI-TCP löst dieses Problem – wie schon bei Adressen – mit Hilfe von Hashes. 2 Deshalb tragen sie auch das Infix post “ in sich, zum Beispiel BMI post send() zum Versenden ” von Daten. 60 6.2.5. Zustellmodi BMI-TCP kennt abhängig von der Nachrichtengröße zwei Modi, um Nachrichten zuzustellen: Eager Nachrichten bis einschließlich 16 Kilobytes. Falls eine solche Nachricht eintrifft, wird sie sofort komplett empfangen. Falls es noch keine dazugehörige Empfangsoperation gibt, wird sie in einen temporären Puffer kopiert und aufgehoben bis sie abgeholt wird. Rendez-Vous Nachrichten von 16 Kilobytes bis einschließlich 16 Megabytes. Kommt eine solche Nachricht an und es gibt keine passende Empfangsoperation, dann wird lediglich der Header aus dem Socket-Puffer abgeholt. Der Rest wird erst abgeholt, wenn eine passende Empfangsoperation gepostet wird. So wird die Flusskontrolle von TCP ausgenutzt, um das Rendez-Vous durchzuführen. Diese wird irgendwann aufhören, Fragmente für das jeweilige Socket anzunehmen, wenn sie nicht abgeholt werden. Beim Senden gibt es keinerlei Unterschiede zwischen diesen beiden Modi. Der Zweck dieser Trennung ist, für große“ Nachrichten keine temporären Puffer al” lozieren zu müssen. Für jede Eager -Nachricht ohne passende Empfangsoperation bei Ankunft der Daten, wird der Speicher zeitweise doppelt alloziert: Einmal der EagerPuffer und einmal in der Applikation. 6.2.6. Kommunikation BMI-TCP benutzt das unter Linux und den meisten aktuellen UNIXen übliche SocketsAPI zur Netzwerkkommunikation. Hierbei werden auch fortgeschrittene Features wie zum Beispiel das Senden und Empfangen von Vektoren mittels readv() und writev() ausgenutzt. Interessant ist ebenfalls, dass bis auf einige wenige Ausnahmen alle Socket-Aufrufe non-blocking aufgerufen werden. Da BMI single-threaded ist, ist diese auch unbedingt notwendig, weil sonst ein einzelner Aufruf die gesamte Applikation einfrieren könnte. Eine sehr gute Abhandlung über fortgeschrittene Socket-Programmierung ist [Ste98]. Die Kommunikationsabstraktion unterteilt sich in zwei Module: sockio Prinzipiell der Low-Level-Socket-Code (also zum Beispiel connect(), close() oder readv()) mit zusätzlichen Fehlerabfragen, die die Socket-Fehlercodes auf BMI-Codes abbilden. Zusätzlich sind auch einige Hilfsfunktionen enthalten. 61 socketcollection Funktionen zur Verwaltung von Mengen von Sockets. Beinhaltet insbesondere auch die Verarbeitung von Events. Events sind in diesem Zusammenhang Ereignisse, die eintreten, wenn Sockets bereit zum kommunizieren (also Senden oder Empfangen) sind. Eine nähere Beschreibung von Events und der sie verwaltenden Systeme ist im folgenden Abschnitt zu finden. Beide machen den Code lesbarer und erleichtern den Einsatz von fortgeschrittenen Techniken, weil sich der Socket-Code auf eine Stelle konzentriert. Sie fügen jedoch jeweils mindestens eine zusätzliche Indirektion hinzu. Die Abstraktion der socketcollection ermöglichte jedoch auch die freie Wahl des Eventsystems. 6.2.7. Eventsysteme Ein Eventsystem ist in diesem Kontext das Überwachen von Sockets durch den Kernel. Durch ein definiertes API wird dem Systemkernel mitgeteilt, an welchen Sockets man interessiert ist. Später kann gewartet werden, bis ein Event eintrifft, also dass eines von den Sockets bereit zum Senden oder Empfangen ist. Je nach Eventsystem ist es möglich, nur dann geweckt zu werden, wenn während des Wartens ein Event auftritt ( edge triggered“). Die Alternative ist, sich auf jeden Fall wecken zu lassen, falls eines ” der Sockets bereit ist ( level triggered“). ” Der traditionelle Weg für I/O-Multiplexing - so wird das Prinzip, mehrere I/O-Kanäle vom Kernel auf Bereitschaft zu überwachen, allgemein genannt – sind die Befehle select() und poll(). Beide haben jedoch Unzulänglichkeiten, die sie für den Einsatz in komplexen und hochperformanten Systemen wie Netzwerkschnittstellen ausschließen: select() Arbeitet mit einem Bit-Array dessen Größe systemabhängig (bei Linux sind es 1024 Bit) und unveränderbar ist. poll() Besitzt kein Größenlimit, bekommt aber bei jedem Aufruf einen Array mit den Deskriptoren übergeben. Das bedeutet einerseits, dass der Aufruf von poll() eine O(n)-Operation ist. Andererseits, dass die Entnahme von einem Deskriptor aus der Mitte des Arrays ein Auffüllen der entstehenden Lücke mit einem Deskriptor vom Ende der Liste erfordert. Aus diesen Gründen entstanden auf den meisten UNIXen alternative Eventsysteme. Auf FreeBSD ist das zum Beispiel Kqueue [Lem01] und auf Linux epoll [Lib02]. 62 epoll ist ein außerordentlich skalierbares Eventsystem, dessen Performance und Skalierbarkeit laut [Lei03a] und [lse04] im Bereich der freien UNIXe unerreicht ist. Allerdings ist epoll lediglich ab Linux 2.5, beziehungsweise 2.6, erhältlich. Durch die Abstraktion des Eventsystems in socketcollection, ist es möglich das fortgeschrittene epoll zu verwenden und trotzdem Benutzern mit inkompatiblen Kerneln die Benutzung von BMI nicht unmöglich zu machen. In diesen Fällen benutzt BMI-TCP poll(). Eine ausführliche Abhandlung der Thematik von I/O-Multiplexing ist in [Ste98] zu finden. Eine gute und verständliche Einführung in aktuelle Eventsysteme stellt [Lei03b] dar. 6.2.8. Verbindungsverwaltung Da BMI im Gegensatz zu TCP verbindungslos ist, ist eine für den API-Nutzer transparente Verbindungsverwaltung notwendig. In der Praxis sieht es so aus, dass vor jeder Operationen – falls notwendig – überprüft wird, ob bereits eine Verbindung zum Host besteht. Ist dem nicht so, wird versucht, eine Verbindung aufzubauen. Doch obwohl BMI paketorientiert ist, gibt es Elemente, die eine effiziente Implementierung auf streamorientierten Protokollen erleichtern sollen. Hierfür gibt es zwei Arten von Nachrichten: Unerwartete Werden mittels des allgemeinen Funktionsaufrufs (BMI testunexpected()) empfangen. Falls eine Nachricht vorhanden ist, wird sie sofort an den Aufrufer zurückgegeben. Prinzipbedingt sind unerwartete Nachrichten stets eager. Erwartete Werden über eine mittels BMI post recv() gepostete Empfangs-Operation empfangen. Hierbei müssen der Absender und ein benutzerdefiniertes Tag (IntegerZahl) angegeben werden. Nur wenn diese Parameter mit denen einer hereinkommenden Nachricht exakt übereinstimmen, wird die Nachricht der Operation zugeordnet. Die Daten werden dann durch sukzessive BMI test()-Aufrufe vollständig übertragen. Kommen erwartete“ Daten an, für die noch keine zuordenbare Empfangs-Operation ” existiert, werden sie zunächst in einen temporären Puffer gespeichert. 63 Üblicherweise werden unerwartete Nachrichten dazu verwendet, die Erstkommunikation mit einem anderen Rechner durchzuführen. Auf dieses Verwendungsmuster kann man sich beim Implementieren einstellen. Weil es zu jeder Adresse nur eine Verbindung gibt, werden die Verbindungen zusammen mit Adressen verwaltet. Die von BMI address lookup() zurückgegebene Struktur method addr bietet hierfür ein für die Methoden frei benutzbares Feld namens method data. BMI-TCP benutzt hierfür die Struktur tcp address. Auch wenn es aus dem Namen nicht ersichtlich ist, enthält sie ebenfalls das Socket zur dazugehörigen Verbindung. Da die Adressen bereits von BMI verwaltet werden, ist eine weitere Verwaltung der Verbindungen nicht notwendig. 6.2.9. Operationsverwaltung Am komplexesten ist die Transfer-, beziehungsweise Operationsverwaltung, da sie deutlich mehr Logik enthält, als die API-Abstraktion in der Kommunikation oder die Verwaltung von Verbindungen. Ihre Hauptaufgabe ist das Sammeln und Zuordnen von ankommenden TCP-Fragmenten. Außerdem muss sie Operationen erstellen, wenn Nachrichten ankommen, für die noch keine Empfangsoperationen gepostet wurden. Sobald später die entsprechende Operation gepostet wird, muss die vorhandene erkannt und mit der neuen Operation verschmolzen werden. Operation Queues Verwaltet werden Operationen in Operation Queues. Dies ist eine Funktionalität, die allgemein von BMI den Netzwerkmethoden zur Verfügung gestellt wird. Es gibt von ihnen fünf und sie werden über Indizes in einem Array (op list array) angesprochen: IND SEND Sende-Operationen. Diese werden nur gequeued, wenn die Nachricht nicht in die Socket-Puffer passt. Das heißt, wenn die Socket-Funktion writev() die Nachricht nicht als Ganzes abschicken kann. IND RECV Empfangs-Operationen die zwar gepostet wurden, aber für die noch keine Daten empfangen wurden. IND RECV INFLIGHT Empfangs-Operationen, für die bereits Daten empfangen wurden. Diese kann sowohl durch eine unerwartete Nachricht erzeugt werden, als auch durch ein manuelles Posten. 64 IND RECV EAGER DONE BUFFERING Empfängerlose Eager -Nachrichten, die vollständig übertragen wurden. IND COMPLETE RECV UNEXP Unerwartete Nachrichten, die vollständig übertragen wurden. Zusätzlich können BMI-TCP-Nachrichten noch einen von den folgenden drei Zuständen haben, die im Feld tcp op state der Struktur tcp op gespeichert wird: BMI TCP INPROGRESS Normale Operation ohne Besonderheiten. BMI TCP BUFFERING Daten wurden in einen temporären Puffer empfangen. BMI TCP COMPLETE Nachricht ist fertig übertragen worden. Fertige Operationen kommen in eine der Completion Queues. Diese befinden sich im Array completion array und es wird über den Context 3 der Operation auf sie zugegriffen. Dort verweilen sie, bis Ihr Zustand erfolgreich mit BMI test()4 abgefragt wurde. Beispiele für Queue- und Modi-Verläufe Um diese Konzepte zu illustrieren, seien einige Verläufe gezeigt. Es werden Abbildungen präsentiert, die zeigen, welche Queues und Modi eine Operation bei der Ausführung durchläuft. Eckigen Boxen sind Funktionsaufrufe, Ellipsen sind Zustände. Dabei steht hinter Q: die aktuelle Queue und hinter dem S: der aktuelle BMI-TCP-Zustand. Beschriftungen rechts der Achsen geben die Bedingungen an, unter welchen dieser Pfad genommen wird. Fehlt die Beschriftung, wird der Pfad immer genommen. Abbildung 6.6 zeigt den einfachsten Fall: Die Funktion BMI post send(), die eine Sende-Operation postet. Falls das Paket in den Socket-Puffer passt, wird es direkt abgeschickt. Es wird keine Operation angelegt und somit auch gar nicht in eine Queue abgelegt. Dies Verhalten heißt Immediate Completion. In diesem Fall darf also der Status nicht mittels BMI test() überprüft werden. Insofern ist es wichtig, den Rückgabewert nicht nur auf Fehler (negative Werte), sondern auch auf Immediate Completion ( 1“) zu überprüfen. ” 3 Ein Context ist eine Möglichkeit Operationen zu gruppieren. Das ist zum Beispiel bei mehreren Threads praktisch. Außerdem ist es möglich einen ganzen Context mit BMI testcontext() zu tes” ten“. 4 Alternativ kann auch BMI wait() verwendet werden, welches nicht sofort zurückkehrt. Stattdessen wartet es, bis die Operation vollständig ausgeführt wurde. In dieser Arbeit wird jedoch der Einfachheit halber stets von BMI test() gesprochen. 65 BMI_tcp_post_send() writev() Message too big Q: IND_SEND S: BMI_TCP_INPROGRESS User calls BMI_test() Data left writev() Message fits socket buffer No data left Q: completion_array S: BMI_TCP_COMPLETE User calls BMI_test() End Abbildung 6.6.: Queue- und Zustandsverlauf beim Senden in der BMI-TCP-Methode. 66 Passt die Nachricht nicht vollständig in den Socket-Puffer oder gibt es bereits eine Sende-Operation für den gleichen Empfänger, die in der Queue liegt, wird eine SendeOperation erstellt und in die IND SEND-Queue hinzugefügt. So lange noch Daten zum Versenden übrig sind, wird mit BMI test() versucht, sie zu verschicken. Sobald die Nachricht vollständig übertragen wurde, wird die Operation in die Completion Queue abgelegt und der Zustand auf BMI TCP COMPLETE gesetzt. Vollständig gelöscht wird die Operation jedoch erst, wenn ihr Status mittels BMI test() abgeholt wurde. Abbildung 6.7 zeigt die Übergänge für das Empfangen mittels BMI post recv(). Um die Abbildung übersichtlich zu halten, illustriert sie nur den Empfang von erwarteten Eager -Nachrichten. Für Rendez-Vous-Nachrichten sieht der Graph fast identisch aus, es entfällt lediglich die Abkürzung“ im rechten Teil. Unerwartete Nachrichten sind ” ebenfalls ähnlich, werden aus Platzgründen jedoch weggelassen. Nach dem Aufruf von BMI post recv() gibt es drei Möglichkeiten: 1. Die Nachricht wurde bereits komplett übertragen und liegt in einem Puffer bereit. In diesem Fall werden die Daten lediglich kopiert und die Funktion endet mit einer Immediate Completion. 2. Die Nachricht wurde bereits teilweise (zum Beispiel der Header bei Rendez-VousNachrichten) übertragen. In dem Fall wird die Operation übernommen und bleibt in der Queue IND RECV INFLIGHT. Der Status ändert sich von BMI TCP BUFFERING zu BMI TCP INPROGRESS, da ab sofort die Daten nicht mehr in einem temporären Puffer liegen. 3. Es wurden noch keinerlei Daten der gewünschten Art empfangen. Die Operation kommt in die IND RECV-Queue mit dem Status BMI TCP INPROGRESS. Falls die gewünschten Daten als Ganzes ankommen, wechselt die Operation in die Completion Queue und setzt den Status auf BMI TCP COMPLETE. Kommen Daten nur teilweise an, geht die Operation, wie im zweiten Punkt, in die IND RECV INFLIGHT-Queue mit dem Zustand BMI TCP INPROGRESS. Im Folgenden ist die Operation entweder fertig, in der Completion Queue oder in der IND RECV INFLIGHT-Queue mit dem Status BMI TCP INPROGRESS. In Letzterer bleibt sie so lange, bis die Nachricht mittels sukzessiven BMI test()-Aufrufen komplett übertragen wurde. Anschließend geht die Operation ebenfalls in die Completion Queue über. 67 BMI_post_recv() No data yet Q: IND_RECV S: BMI_TCP_INPROGRESS User calls BMI_test() Still no data Matching Q: IND_RECV_INFLIGHT S: BMI_TCP_BUFFERING found readv() Partial data arrives Matching Q: IND_RECV_EAGER_ DONE_BUFFERING S: BMI_TCP_BUFFERING found Q: IND_RECV_INFLIGHT S: BMI_TCP_INPROGRESS Complete data arrives User calls BMI_test() Data left readv() No data left Q: completion_array S: BMI_TCP_COMPLETE User calls BMI_test() End Abbildung 6.7.: Queue- und Zustandsverlauf beim Empfangen in der BMI-TCPMethode. 68 In der Completion Queue bleibt sie, bis der Status der Empfangsoperation mit BMI test() abgeholt wurde. Abschließend wird sie freigegeben. 6.2.10. Callgraphen Da die Funktionsprinzipien von BMI-TCP nun geklärt sind, fehlen noch konkrete Callgraphen für einzelne Operationen. Diese sollen zeigen, wie die bereits vorgestellten Konzepte letztendlich umgesetzt beziehungsweise implementiert wurden. Senden Abbildung 6.8 zeigt das Versenden von erwarteten und unerwarteten Nachrichten. Auffällig ist der sehr tiefe Callstack. Insgesamt sind fünf Funktionsaufrufe notwendig, bis die Daten an die Socket-Schnittstellen übergeben werden. Damit wird offensichtlich das Prinzip der Datenlokalität verletzt. Andererseits – und deshalb sind sowohl erwartete als auch unerwartete Nachrichten im Graphen – wäre es nicht möglich, diese einzuhalten, ohne zusätzlichen Code einzuführen. Wie bereits erörtert wurde, sind BMI und BMI-TCP operationsorientiert. Und genau dieses Konzept wird hier durchgesetzt, obwohl letztendlich keine Operation angelegt wird: BMI post send() / BMI post sendunexpected() Übergabe von BMI an BMI-TCP. BMI tcp post send() / BMI tcp post sendunexpected() Ausfüllen von Headern und Übergabe an eine generische Sende-Funktion. BMI tcp post send generic() Vorbereitungen zum Verschicken. Diese Funktion ist für alle Arten von Sende-Operationen zuständig. Die Operationen unterscheiden sich lediglich in einem Header-Feld, insofern ist es überflüssig sie gesondert zu behandeln. In dieser Funktion wird zum Beispiel überprüft, ob eine Verbindung zum Zielrechner besteht und gegebenenfalls wird sie aufgebaut. Falls irgendwelche Probleme bestehen, oder die Nachricht nicht vollständig versendet werden kann, wird mittels enqueue operation() eine Operation erstellt und in die IND SEND-Queue hinzugefügt. payload progress() Sowohl fürs Senden, als auch Empfangen zuständig. Erstellt Vektoren für writev() und aktualisiert Datenstrukturen, je nachdem wie viele Daten übertragen wurden. 69 BMI_post_send() BMI_post_sendunexpected() BMI_tcp_post_send() BMI_tcp_post_sendunexpected() BMI_tcp_post_send_generic() If message couldn’t be sent payload_progress() enqueue_operation() BMI_sockio_nbvector() alloc_tcp_method_op() writev() alloc_method_op() Abbildung 6.8.: Callgraph zum Versenden von Daten in BMI-TCP. 70 BMI sockio nbvector() Generische Funktion zum Senden und Empfangen. Gehört zur vorgestellten Socket-API-Abstraktion. Ob Versendet oder Empfangen wird, hängt von einem Boolean-Argument ab. writev() Socket-API-Funktion, die nicht nur einen Puffer übergeben bekommt, sondern einen Vektor von Puffern, die verschickt werden. Das ist nützlich, wenn man zum Beispiel einem Puffer einen Header voranstellen möchte. Empfangen Das Empfangen ermöglicht derzeit kein Immediate Completion. Der Code ist zwar vorhanden, wurde jedoch mit Hinweis auf Deadlocks auskommentiert. Deshalb ist der Callgraph in Abbildung 6.9 relativ einfach. Prinzipiell ist der Ablauf identisch zu dem vom Senden. Die Hälfte dieses Graphen besteht aus allgemeinen operationsbasierten Funktionen. Der Overhead, Senden und Empfangen zu unterstützen, ist hierbei bei weitem nicht so groß, wie der, der entstehen würde, wenn man zwei sehr ähnliche Funktionen schreiben müsste. Test BMI test() ist die eigentliche Transferfunktion für alle gequeuete Operationen. Entsprechend ist der Graph in Abbildung 6.10 am komplexesten. 71 BMI_post_recv() BMI_tcp_post_recv() tcp_post_recv_generic() enqueue_operation() alloc_tcp_method_op() alloc_method_op() Abbildung 6.9.: Callgraph zum Empfangen von Daten in BMI-TCP. 72 BMI_test() BMI_tcp_test() Operation finished tcp_do_work() dealloc_tcp_method_op() Obtain list of ready sockets Need to receive header BMI_sockio_brecv() work_on_recv_op() work_on_send_op() BMI_socket_collection_testglobal() dealloc_method_op() payload_progress() BMI_sockio_nbvector() writev() readv() Abbildung 6.10.: Callgraph zu BMI test(). BMI test() Übergabe von BMI an BMI-TCP. BMI tcp test() Ruft zunächst tcp do work() auf. Überprüft anschließend, ob die übergebene Operation bereits beendet ist und dealloziert sie gegebenenfalls. tcp do work() Besorgt sich über BMI socket collection testglobal() eine Menge von Operationen, die bereite Sockets haben. Dabei hat tcp do work() keinerlei Wissen darüber, mit welcher Operation BMI test() aufgerufen wurde. Es werden wirklich nur die ersten x (per Default circa 128) bereiten Operationen bearbeitet. Sollten an einem Socket Daten für eine Operation ankommen, für die bisher der BMI-Header nicht empfangen wurde, peekt“ tcp do work() in den Socket-Puffer, ” ob bereits der gesamte Header übertragen wurde. Ist dies der Fall, wird mit dem einzigen blockierenden Socket-Funktionsaufruf in BMI sockio brecv() der Header ausgelesen und die notwendigen Felder der Operation ausgefüllt. Anschließend ruft er work on recv op() beziehungsweise work on send op() mit 73 diesen Operationen auf. BMI socket collection testglobal() Nutzt poll() oder epoll , um aus den Sockets, für die noch Transfer anstehen, eine bestimmte Anzahl von bereiten herauszusuchen. work on recv op()/work on send op() Stellt sicher, dass eine Verbindung besteht und ruft payload progress() auf. Der Rest ist analog zur Sende-Operation. readv() ist wie writev() vektororientiert, wobei er eingehende Daten in Puffer, die in einem Vektor übergeben werden, verteilt. 6.2.11. Fazit Die Betrachtung der Architektur von BMI-TCP zeigte eines deutlich: Sie wird maßgeblich durch BMI selbst vorgegeben. Auf unterster Ebene werden Datenstrukturen wie Hashes oder lineare Listen von BMI verwendet. Das Konzept der Operationsverwaltung ist im Wesentlichen 1:1 durch BMI vorgegeben und entsprechend wurde auch das Queue-Konzept übernommen. Eine Bewertung findet in der Zusammenfassung in Abschnitt 6.7 statt. 6.3. Anwendungsbeispiel Nachdem BMI-TCP theoretisch beschrieben ist, sei um eine bessere Vorstellung von BMI zu vermitteln, ein Beispiel für die Anwendung des API präsentiert. Abbildung 6.11 zeigt einen einfachen Echo-Server. Der Sourcecode ist an eins angelehnt. Es wartet zunächst auf eine unerwartete Nachricht und schickt sie anschließend an den Absender zurück. Der Übersicht halber enthält es keine Sicherheitsabfragen, was im richtigen Code nicht der Fall sein darf. 6.4. Laufzeitanalyse Für eine umfassende Bewertung von BMI-TCP steht nun nur noch die Analyse der Laufzeit – wie in Abschnitt 5.3 beschrieben – aus. Messwerte in Abschnitt 6.1 zeigten bereits, dass BMI-TCP auf nicht-SMP-Systemen gute Ergebnisse erreicht. Insofern ist es interessant, inwiefern die folgende Ergebnisse stützen kann, oder ob sich Flaschenhälse finden. 74 /∗ I n i t i a l i s i e r e BMI a l s TCP−S e r v e r a u f l o c a l h o s t ∗/ B M I i n i t i a l i z e ( " bmi_tcp " , "tcp ://127.0.0.1:3334 " , BMI INIT SERVER ) ; /∗ O e f f n e e i n e n neuen Context . ∗/ bmi context id ctx ; BMI open context (& c t x ) ; while ( 1 ) { struct B M I u n e x p e c t e d i n f o u i n f o ; s i z e t outcount ; do { /∗ U e b e r p r u e f e ob , , 1 ’ ’ u n e r w a r t e t e N a c h r i c h t da i s t , s p e i c h e r e ∗ d i e Anzahl d e r a b g e h o l t e n N a c h r i c h t e n i n , , o u t c o u n t ’ ’ , ∗ I n f o r m a t i o n e n und den I n h a l t d e r N a c h r i c h t i n , , u i n f o ’ ’ und ∗ warte maximal 100 ms . ∗/ r e t = B M I te s t u n e x p e c t e d ( 1 , &outcount , &u i n f o , 1 0 0 ) ; } while ( r e t == 0 && o u t c o u n t == 0 ) ; /∗ Sende d i e N a c h r i c h t , , u i n f o . b u f f e r ’ ’ d e r G r o e s s e ∗ , , u i n f o . s i z e ’ ’ mit Tag , , u i n f o . t a g ’ ’ an d i e A d r e s s e ∗ , , u i n f o . addr ’ ’ z u r u e c k . Benutze d a f u e r den Context , , c t x ’ ’ . ∗ Weil u n s e r e Daten n i c h t s p e z i e l l f u e r s Senden a l l o z i e r t wurden , ∗ i s t d e r P u f f e r t y p BMI EXT ALLOC . Benutze den U s e r p o i n t e r n i c h t ∗ ( , ,NULL ’ ’ ) . S p e i c h e r e d i e ID d e r O p e r a t i o n i n , , b m i i d ’ ’ . ∗/ bmi op id t bmi id ; r e t = BMI post send (& bmi id , u i n f o . addr , u i n f o . b u f f e r , u i n f o . s i z e , BMI EXT ALLOC, u i n f o . tag , NULL, c t x ) ; /∗ Kein BMI test ( ) b e i Immediate Completion . ∗/ if ( r e t != 1 ) { do { /∗ U e b e r p r u e f e , ob d i e Op e r a t io n , , b m i i d ’ ’ aus dem Context ∗ , , ctx ’ ’ f e r t i g i s t . Speichere die v e r s c h i c k t e Groesse in ∗ , , a c t u a l s i z e ’ ’ und e v e n t u e l l e F e h l e r i n , , e r r o r c o d e ’ ’ . ∗ Den U s e r p o i n t e r wird i g n o r i e r t ( , ,NULL ’ ’ ) und maximal ∗ 100 ms g e w a r t e t . Die Zahl i n , , o u t c o u n t ’ ’ wird b e i E r f o l g ∗ inkrementiert . ∗/ r e t = BMI test ( bmi id , &outcount , &e r r o r c o d e , &a c t u a l s i z e , NULL, 1 0 0 , c t x ) ; } while ( r e t == 0 && o u t c o u n t == 0 ) ; } } Abbildung 6.11.: Ein einfacher Echo-Server mit der BMI-API. 75 L1 Instruktionen Daten L2 2,61% 0,0% 0,0% 0,0% Tabelle 6.1.: Cachemissraten in BMI-TCP. 6.4.1. Caches Als erstes wird die manuell einfachste Analyse durchgeführt: Die der Cachenutzung. Hierfür wurde mittels eins von 1 bis 1400 durchgemessen und dabei die Cachenutzung durch callgrind vermessen. Tabelle 6.1 zeigt die Ergebnisse. Wie schon im Abschnitt zu Callgrind (3.5) gesagt, ist eine Trefferrate jenseits 97% ein sehr gutes Ergebnis. Dieses Ergebnis wird hier problemlos erreicht. Auch die 100 prozentige Trefferrate für Datenzugriffe ist beeindruckend. Offensichtlich wurde das Ziel der Datenlokalität voll erfüllt. Die Routinen sind ebenfalls überwiegend im Cache zu finden. Es ergibt sich also kein nennenswerter Overhead aus schlechter Cachenutzung. Diese Werte sind offensichtliche Folge der allgemeinen Operations-Designs. Da die meiste Zeit die gleichen Funktionen genutzt werden, behindern sie sich auch nicht im Cache. Dies ist kein Widerspruch zur Forderung nach speziellen Routinen: Die Funktionen tun weitgehend jeweils nur eine Sache, jedoch mit wechselnden Objekten (zum Beispiel Eager/Rendez-Vous, Senden/Empfangen, Einzelnachrichten/Listen...). Ein Versuch, den Callstack für die wichtigsten Operationen zu verkürzen, würde eine große Menge an zusätzlichen (teilweise mehrfach vorhandenem) Code induzieren. Vermutlich könnte man damit die Kernfunktionen zum einfachen Senden und Empfangen (also BMI post send() und BMI post recv()) unwesentlich schneller machen, würde jedoch Geschwindigkeitsverluste an anderer Stelle hinnehmen müssen. Das Flow benutzt zum Beispiel ausgiebig die Funktionen BMI post send list() und BMI post recv list(). Listen werden zur Zeit von den gleichen Funktionen verarbeitet wie einzelne Nachrichten. Eine Aufteilung zugunsten des Einsparens von Funktionsaufrufen würde bedeuten, dass zum großen Teil doppelter Code entstünde, der sich im Cache behindern würde. Aus diesem Grund kann unter dem Aspekt der Cacheanalyse nur gefolgert werden, dass derartige Änderungen einen überwiegend negativen Einfluss auf die Performance hätten. 76 Laufzeitanteil in % 5,85 5,54 4,29 2,89 2,77 2,73 2,71 2,22 2,22 1,83 1,71 1,65 1,59 1,57 1,53 1,30 1,29 1,17 1,11 1,08 1,05 1,03 1,01 Funktion payload progress() tcp do work() BMI socket collection testglobal() enqueue operation() hash key() op list search() BMI tcp post send generic() tcp post recv generic() BMI tcp post send() work on recv op() id gen safe lookup() BMI tcp test() BMI sockio nbvector() BMI sockio brecv() gen posix mutex lock() BMI test() id gen safe unregister() BMI tcp post recv() gen posix mutex unlock() op list add() BMI sockio nbpeek() id gen safe register() BMI post send() Tabelle 6.2.: Laufzeitanteile in BMI-TCP. Nur Funktion mit Laufzeitanteil von mehr als 1%. 6.4.2. Profiling Untersucht wird mit OProfile (siehe Abschnitt 3.4). Als Granularität wurde nahezu die höchste gewählt: Alle 6000 CPU CLK UNHALTED wird ein Sample entnommen. Auf einem 800 MHz-System entspricht dies circa 133.333 Samples pro Sekunde. Es wurde nicht die maximale Auflösung genommen, um die Beeinflussung der Ausführung zu verringern. Es wurde erneut für Werte von 1 bis 1400 gemessen, wobei jede Messung 1000 statt 54 mal durchgeführt wurde. Dies dient als Ausgleich zur geringen Granularität. Tabelle 6.2 zeigt die Ergebnisse. Die Tabelle spiegelt das wider, was grundsätzlich erwartet wurde: Es gibt keinen klassischen Flaschenhals innerhalb von BMI. Vielmehr verschlingt die notwendige Opera- 77 tionsverwaltung den größten Teil der Zeit. Präziser gesagt, gehören alle aufgelisteten Funktionen zur Operationsverwaltung oder werden von ihr zumindest verwendet (zum Beispiel hash key() zur Berechnung von Hashwerten). Es gibt allerdings keine einzige Funktion von der man sagen könnte, dass sie unverhältnismäßig für Overhead verantwortlich ist. Trotzdem wurden die teuersten“ Funktionen näher betrachtet. Abgesehen von fehlen” den Compiler-Hints fürs Branching, sind die Funktionen außerordentlich gut umgesetzt. Die Autoren versuchten stets möglichst wenige bedingte Sprünge zu haben, damit der Common Case möglichst direkt durchläuft. Gelegentlich hat man nur das Gefühl, dass mit der Codewiederverwertung übertrieben wurde. Spätestens ist dies der Fall bei der Funktion BMI sockio nbvector(), welche in Abbildung 6.12 zu sehen ist. Es handelt sich um eine generische Funktion zum Verschicken und Empfangen von Vektoren. Dadurch ergibt sich die Frage: Was schadet mehr? Doppelter Code oder ” übertrieben generische Funktionen?“ Messbare Unterschiede sind jedenfalls nicht festzustellen. Das Kapseln von fortgeschrittenen Socket-Funktionen wurde bereits in vorangehenden Kapiteln thematisiert und begründet. Letztendlich gibt es keine objektiven Einwände für die Entscheidungen der BMI-TCP-Entwickler, insbesondere angesichts der Ergebnisse und Folgerungen der Cacheanalyse. Der Flaschenhals ist also wenn dann das Konzept beziehungsweise das Design. Angesichts der Funktionalität, die das Mappen von TCP auf ein RDP erfordert, ist fraglich, ob das deutlich besser realisierbar ist. 6.5. Gegenüberstellung mit BMI-GAMMA Alle Untersuchungen so weit zeigen, dass BMI-TCP ein gute durchdachtes und effizient umgesetztes Produkt ist. Wieso hat BMI-GAMMA trotzdem spürbar geringeren Overhead? Zu diesem Zweck ist es sinnvoll kurz GAMMA zu erläutern. Anschließend können hieraus Konsequenzen gezogen werden. The Genoa Active M essage MAchine“ (GAMMA) entstand als Teil der Dissertation ” [Cia99] von Giuseppe Ciaccio. Es setzt direkt auf Ethernet auf und es ist entsprechend notwendig den Netzwerktreiber zu modifizieren. Dadurch erreicht es beeindruckende Latenzen und Bandbreiten. Dies erklärt jedoch keineswegs, warum eine GAMMA-Methode einen deutlich geringeren Overhead als ihr TCP-Pendant haben muss. Insbesondere wenn die BMI-TCP-Methode 78 int B M I s o c k i o n b v e c t o r ( int s , struct i o v e c ∗ v e c t o r , int count , int r e c v f l a g ) { int r e t ; /∗ NOTE: t h i s f u n c t i o n i s d i f f e r e n t from t h e o t h e r s t h a t w i l l ∗ keep making t h e I /O system c a l l u n t i l EWOULDBLOCK i s ∗ e n c o u n t e r e d ; we g i v e up a f t e r one c a l l ∗/ /∗ l o o p o v e r i f i n t e r r u p t e d ∗/ do { if ( r e c v f l a g ) { r e t = readv ( s , v e c t o r , count ) ; } else { r e t = w r i t e v ( s , v e c t o r , count ) ; } } while ( r e t == −1 && e r r n o == EINTR ) ; /∗ r e t u r n z e r o i f can ’ t do any work a t a l l ∗/ if ( r e t == −1 && e r r n o == EWOULDBLOCK) return ( 0 ) ; /∗ i f data t r a n s f e r r e d o r an e r r o r ∗/ return ( r e t ) ; } Abbildung 6.12.: Die Funktion BMI sockio nbvector() (Format leicht angepasst). 79 sehr gut umgesetzt ist. Der Grund ist vielmehr, dass das Kommunikationsmodell praktisch identisch ist. Grob gesagt, ist GAMMA ein leichtgewichtiges RDP, welches Interrupts abschaltet und sämtlich Daten auf der Netzwerkkarte belässt, bis ein Programm die Daten mittels gamma test() oder gamma wait() abholt. Daraus ergibt sich für die BMI-Methode der Vorteil, dass sich die grundlegenden BMIMethoden praktisch 1:1 auf die GAMMA-Funktionen abbilden lassen. In der Praxis werden Daten immer direkt verschickt (stets Immediate Completion) und beim Empfangen werden Operationen nur in eine Queue abgelegt. Bei BMI test() wird mittels gamma test() auf übertragene Nachrichten überprüft, die anschließend – wenn möglich – direkt von der Netzwerkkarte in den richtigen Puffer kopiert werden (Zero Copy). Sämtliche BMI-Nachrichten kommen also stets komplett an und müssen bei vorhandenen Empfangs-Operationen nur einmal von der Hardware in den Hauptspeicher kopiert werden. Dies bedeutet zunächst, dass die Komplexität von BMI-GAMMA deutlich geringer als die von BMI-TCP ist5 . 6.6. Verbesserungsvorschläge Bezogen auf Kapitel 2 und 4 seien nun Gedanken angestellt, inwiefern sich BMI-TCP schneller machen ließe. Wie bereits angedeutet, entfallen alle Punkte, die handwerkliche Fehler implizieren. BMI-TCP nutzt sowohl optimale Datenstrukturen (Hashes) zur Verwaltung von Daten als auch fortgeschrittene Techniken, bei der Netzwerkprogrammierung. Der Code ist insgesamt von sehr guter Qualität. Übrig bleiben somit nur noch Design-Entscheidungen und Überlegungen bezüglich TCP. Wollte man das Design wesentlich verändern, müsste man zusätzlich Anstrengungen anstellen, das neue Design mit BMIs Architektur in Einklang zu bringen. Es ist also sehr unwahrscheinlich, dass auf diese Art deutliche Performance-Vorteile zu erreichen sind, ohne andere Probleme zu erzeugen. Bleibt noch die Frage, ob das andere verbreitete IP-Basierte Protokoll, UDP [Pos80], nicht die bessere Wahl wäre. Seine Unzuverlässigkeit ist innerhalb von LANs weitgehend vernachlässigbar und es würde ebenfalls ein bequemes Mapping zum BMI-API anbieten. Zusätzlich ist es noch leichtgewichtiger als TCP und könnte dadurch weitere 5 In Zahlen ausgedrückt: BMI-TCP hat 4720 Codezeilen, BMI-GAMMA hat nur 2467. Also gut die Hälfte. 80 Bandwidth Send 1 - Receive 1 30 Raw TCP Raw UDP BMI TCP 25 Bandwidth [MB/s] 20 15 10 5 0 1 4 16 64 256 1024 Message Size [bytes] 4096 16384 65536 Abbildung 6.13.: Bandbreite von BMI-TCP, TCP und UDP. Geschwindigkeitsvorteile bieten. Es wird also untersucht, ob ein BMI-UDP reale Vorteile schaffen würde. Hierfür wird zunächst UDP ausgemessen und mit TCP und BMI-TCP verglichen. Abbildung 6.14 zeigt den Bandbreitenvergleich, Abbildung 6.13 die Latenzen. Sichtbar sind drei Probleme: 1. Der UDP-Graph endet bei 65.507 Bytes. Das liegt an der maximalen IP-Paketgröße von 216 = 65.536 Bytes. Da noch zusätzlich BMI- und UDP-Header hinzukommen, kann UDP nur 65.536 − 29 Bytes verschicken. 2. UDP ist in dem Bandbreitengraphen nur undeutlich besser und bricht am Ende sogar ein. 3. Für sehr kleine Nachrichten hat UDP ebenfalls kaum bessere Latenzen als TCP. Erst bei 31 Bytes gehen die Latenzen (reproduzierbar) um circa 5µs zurück. Dies ist jedoch spezifisch für die verwendeten Karten. Gleiche Messungen mit Karten der Firma 3Com6 ergaben konstant schlechte Werte für UDP. Das erstes Problem ist technisch nicht lösbar und würde eine Operationsverwaltung wie bei TCP verlangen, da das Flow BMI mit maximal 64 Kilobyte (216 = 65.536 Bytes) 6 R Gigabit Fiber-SX Server NIC) 3C996-SX (3Com° 81 Latency Send 1 - Receive 1 62 60 58 Time [us] 56 54 52 50 48 46 BMI TCP Raw TCP Raw UDP 44 10 20 30 40 Message Size [bytes] 50 60 Abbildung 6.14.: Latenzen von BMI-TCP, TCP und UDP. A B C 11 00 00000000000000000 11111111111111111 00 11 00000000000000000 11111111111111111 00 11 00000000000000000 11111111111111111 c v 00 11 00000000000000000 00 00000000000000000 0011111111111111111 11 0000000000000000011 11111111111111111 0011111111111111111 11 00000000000000000 11111111111111111 00 11 00000000000000000 c 11111111111111111 v 00 11 00000000000000000 00 00000000000000000 11111111111111111 0011111111111111111 11 0000000000000000011 11111111111111111 00 11 00000000000000000 11111111111111111 00 11 00000000000000000 c 11111111111111111 v 00 11 00000000000000000 0011111111111111111 11 00000000000000000 11111111111111111 0 5 10 15 20 25 30 Time t Abbildung 6.15.: Schlechte Pipelinenutzung benutzt. Zweites Problem, welches auf den erwähnten 3Com-Karten noch deutlicher sichtbar wird, ist durch den Pipeline-Effekt [WKM+ 98] erklärbar. Pipeline-Effekt Dieser Effekt erklärt man am besten anhand von Grafiken. Abbildung 6.15 zeigt eine Nachricht mit einem konstanten Zeitanteil c“ (rot) und einem ” größenabhängigen Zeitanteil v“ (grün). Sie geht durch drei Schichten: A, B und C. In ” der Praxis könnten zum Beispiel A und C jeweils ein TCP/IP-Stack und B ein Netzwerk zwischen zwei Rechnern sein. In diesem Beispiel sind c“ und v“ der Einfachheit halber in allen Schichten identisch, ” ” aber das ist in der Regel nicht der Fall. So würde das Netzwerk (hier B), deutlich länger zum übermitteln der Nachricht brauchen, als die TCP/IP-Stacks. Es fällt auf, dass jeweils immer nur eine Schicht aktiv ist und die anderen beiden in der 82 A B C 11 00 000000 111111 00 11 000000 111111 00 11 000000 111111 00 11 000000 111111 00 11 000000 111111 00 11 000000 111111 00 11 000000 111111 00 11 000000 111111 00 11 000000 111111 c c c v v v 00 11 000000 111111 00 11 000000 111111 00 11 000000 111111 00111111 11 000000 111111 00111111 11 00000011 00 000000 111111 00 11 00000011 111111 00 11 000000 111111 00 000000 00111111 11 00000011 111111 00 000000 111111 00 11 000000 00 11 000000 c c 111111 c v v v 00 11 000000 111111 00 11 000000 111111 00 11 000000 111111 00 000000 111111 00 11 000000 00 000000 00111111 11 00000011 00111111 11 00000011 111111 00111111 11 00000011 111111 00111111 11 000000 111111 00 11 000000 00 000000 111111 00 11 000000 c c c 111111 v v v 00 11 000000 111111 00 11 000000 111111 00 000000 00111111 11 00000011 00111111 00000011 00111111 11 000000 111111 0 5 10 15 20 25 30 Time t Abbildung 6.16.: Gute Pipelinenutzung Bandwidth Send 1 - Receive 1 30 Raw fragmented UDP Raw TCP Raw UDP 25 Bandwidth [MB/s] 20 15 10 5 0 1 4 16 64 256 1024 Message Size [bytes] 4096 16384 65536 Abbildung 6.17.: Auswirkungen von Fragmentierung auf UDP-Messwerte. Zeit untätig sind. Teilt man nun das Paket in drei Teile, drittelt man v“, kann an c“ ” ” allerdings nichts ändern. Da der konstante Teil nun öfter vorkommt, braucht jede Schicht insgesamt länger. Doch weil nun zeitweise alle drei Schichten gleichzeitig arbeiten, ergibt sich ein erheblicher Zeitvorteil. Abbildung 6.16 zeigt dies anschaulich: Anstatt von 30 Zeiteinheiten werden zum Versenden der Nachricht nur 20 verwendet. Nähere Details zur genaueren Modellierung des Pipeline-Effekts sind in [WKM+ 98] zu finden. Um diesen Effekt auch zu nutzen, wurde in eins die Möglichkeit eingebaut, Nachrichten zu fragmentieren. Abbildung 6.17 zeigt, wie sich bei UDP eine Fragmentierung auf 1400 Bytes auf die Messwerte auswirkt. UDP ist nun auch für große Nachrichten die schnellste Alternative, wenn auch nicht deutlich. Während TCP seine Nachrichten (teils auch aus diesem Grund) fragmentiert, ver- 83 schickt UDP seine Nachrichten als ganze Pakete. Eine Umsetzung dieser Fragmentierung für UDP würde ebenfalls eine Operationsverwaltung erfordern. UDPs schlechte Abschneiden gegenüber TCP für geringe Werte ist durch die SocketOption TCP NODELAY erklärbar. Die signalisiert dem Kernel, Nachrichten sofort loszuschicken, statt sie erst zu sammeln, um Bandbreite zu sparen. Das spontane Verbessern der Latenzen ab 31 Bytes ist ohne Kenntnis der Firmware der Netzwerkkarte unmöglich, da es auf anderen Karten nicht nachvollziehbar ist. Zusammenfassend kann also gesagt werden, dass UDP keine gangbare Alternative zu TCP darstellt. Erstens ist UDP nur unwesentlich schneller, zweitens würde BMI-UDP ebenfalls eine Operationsverwaltung benötigen und drittens müssten zusätzlich benötigte Features aus TCP, wie Zuverlässigkeit oder Flusskontrolle, ebenfalls nachimplementiert werden. 6.7. Fazit Das Gesamtdesign von BMI-TCP wird weitgehend von BMI selbst vorgegeben. Das Konzept von Operationen“, das sich durch das gesamte Design von BMI zieht, und ” auch durch BMI unterstützt wird, gibt die allgemeinen Prinzipien von BMI-TCP bereits vor. Ob das positiv oder negativ ist, ist schwer zu beurteilen. Die erzielten Mess- und Analyseergebnisse deuten jedenfalls in die Richtung, dass BMI-TCP sehr gut konzipiert und umgesetzt ist. Jeglicher nennenswerter Overhead entsteht in der Operationsverwaltung, die durch die beschriebenen Eigenschaften von TCP notwendig ist. BMI-GAMMA hat durch das nahezu 1:1-Mapping der Funktion zu BMI erhebliche Vorteile, da sich dessen Operationsverwaltung darauf beschränkt, fertig übertragene Nachrichten Operationen zuzuweisen. Entsprechend entfällt das umständliche Verschieben von Operationen zwischen verschiedenen Queues wie es für BMI-TCP grafisch demonstriert wurde. Zusammenfassend kann also gesagt werden, dass der zusätzliche Overhead durch die Eigenschaften des unterliegenden Stream-Protokolls (TCP) induziert wird. Wollte man den Overhead vermindern, müsste man eine deutlich performantere Operationsverwaltung implementieren. Die Wahrscheinlichkeit, dass dies im nennenswerten Umfang gelingen könnte, ist anhand der Analyseergebnisse niedrig. UDP kommt als Alternative zu TCP nicht in Frage. 84 7. NEON Das New Efficient One-sided communications i Nterface ist ein Versuch [Sch06b] von Lars Schneidenbach, die derzeit populäre One-Sided Communication auf eine SendReceive-Semantik abzubilden und die Synchronisation implizit über die Kommunikation zu erreichen. Begonnen wurde die Implementierung des Projekts im Rahmen des Blockseminars Praktikum Paralleles Rechnen“. ” Die Vorgehensweise wird aufgrund unterschiedlicher Ausgangspositionen zu BMI anders sein: In dem Augenblick, als NEON Teil dieser Arbeit wurde, war es nicht lauffähig. Das Optimieren und das Ausbessern von Fehlern musste aus Zeitgründen parallel geschehen. Deshalb ist die Reihenfolge der Kapitel anders als bei BMI: Zunächst wird erläutert, was genau OSC ist, dann die Architektur erklärt, gefolgt von einem Anwendungsbeispiel. Anschließend werden die vorgeschlagenen und durchgeführten Änderungen erörtert. Erst danach werden Messergebnisse präsentiert, gefolgt von der Laufzeitanalyse und dem Fazit. 7.1. One-Sided Communication NEON ist ein API für One-Sided Communication (OSC). Darunter ist eine Art Remote Memory Access (RMA) zu verstehen: Ein Prozess gibt einen bestimmten Speicherbereich frei“ in den andere (je nach Implementierung) frei schreiben können. Der empfangende“ ” ” Prozess hat auch eine Funktion zur Überprüfung, ob der schreibende Zugriff bereits vollständig durchgeführt ist. Der Vorteil ist, dass die Kommunikation für das Programm vollständig asynchron abläuft. Es wird nur ein Puffer zum Empfangen definiert. Sobald Daten aus diesem Puffer benötigt werden, wird überprüft, ob sie bereits vollständig übertragen wurden und anschließend werden sie benutzt. Die bekannteste Version von OSC wurde in MPI-2 [MPI97] definiert und zum Beispiel durch MPICH2 implementiert. NEON möchte eine eigenständige Alternative zu den 85 K Q S externe Komm. interne Komm. Abbildung 7.1.: Kommunikationsmodell nach L. Schneidenbach aus [SS06]. MPI-Implementierungen bieten. 7.2. Architektur Im Gegensatz zum BMI, wird die Architektur und Funktionsweise von NEON in dieser Arbeit nur grob skizziert. Der Grund hierfür ist, dass – im Gegensatz zu BMI – noch nicht alle Feinheiten des Protokolls spezifiziert sind. Eine aktuelle Dokumentation ist jeweils im Programmpaket beziehungsweise im Subversion-Repository enthalten. 7.2.1. Kommunikationsmodell Das Kommunikationsmodell basiert auf der Annahme, dass parallele Anwendungen mehrheitlich nach dem Producer-Consumer-Prinzip arbeiten. Es versteht unter der geteilten Ressource das Kommunikationssystem. Dieses umfasst das NEON-API der Kommunikationspartner und das eigentliche Netzwerk. Die Kommunikation zwischen den NEON-APIs über das Netzwerk wird folglich als interne Kommunikation bezeichnet und die Kommunikation zwischen NEON und Applikation entsprechend als externe Kommunikation. Abbildung 7.1 von Lars Schneidenbach illustriert dieses Prinzip: Q bezeichnet die Quelle, S die Senke und K das Kommunikationssystem. Wenn die Senke aus der Quelle Daten erhält, betreibt sie externe Kommunikation mit dem Kommunikationssystem. Dieses kommuniziert intern über das Netzwerk und wiederum extern mit der Quelle. Interne Kommunikation ist somit für die Applikation transparent. 86 7.2.2. Netzwerkunterstützung Wie BMI ist NEON dafür ausgelegt, mehrere Arten von Netzwerken zu unterstützen. Der Netzwerkcode ist von der API-Definition getrennt. Im Gegensatz zu BMI ist es jedoch derzeit nicht vorgesehen, mehrere Netzwerktypen gleichzeitig zu nutzen. Die derzeit implementierte Variante nutzt zur Kommunikation TCP-Sockets und zur Adressierung MPI . Abbildung 7.2 zeigt, wie sich NEON in die Kommunikationsschichten einfügt, wenn die MPI-Implementierung MPICH genutzt wird. Obwohl NEON MPI benötigt, werden MPIs Kommunikationsfunktionen nicht verwendet. Vielmehr nutzt NEON MPIs Ränge zur Adressierung. MPI−Application MPICH 111111 000000 000000 111111 000000 111111 NEON 000000 111111 000000 111111 ADI/CH3 Sockets Abbildung 7.2.: NEON im Kommunikationsmodell 7.2.3. API Zur Kommunikation werden prinzipiell nur drei Funktionen gebraucht: NEON Post() Definiert einen Speicherbereich, in den NEON Daten empfangen darf, also andere Prozesse schreiben dürfen (Senke). Er wird mit einem Tag“ markiert, ” um mehrere solche Bereiche in einem Prozess zu erlauben. Ein Speicherbereich wird eindeutig über den MPI-Rang der Senke und einen Tag definiert. NEON Put() Schreibt in einen vorher mit NEON Post() Speicherbereich. Benötigt entsprechend unter anderem den Zielrang und das Tag, mit dem der Zielspeicherbereich markiert wurde. NEON Test() Testet, ob ein NEON Put() vollständig übertragen wurde, beziehungsweise ob in einem durch NEON Post() freigegeben Speicherbereich fertig beschrieben 87 wurde. In der aktuellen Version bedeutet dies, dass der Speicherbereich vollständig gefüllt wurde. Eine ähnliche Funktion ist NEON Wait(): Sie überprüft ebenfalls, ob ein Job (so heißen die Gegenstücke zu BMIs Operationen in NEON) fertig ist. Es kehrt jedoch nicht sofort zurück, sondern erst wenn der Job wirklich fertig ist. Ein reales Anwendungsbeispiel mit dem Zusammenspiel diese Funktionen ist in Abschnitt 7.3 zu finden. 7.2.4. Asynchronität Wie BMI, ist das API von NEON asynchron. Doch während BMI sämtliche Kommunikation innerhalb von BMI test() durchführt, hat NEON einen weiteren Kommunikationsthread . Dieser läuft mit der niedrigsten Priorität. Seine Hauptaufgabe ist das Annehmen von Verbindungen und unter Umständen auch das Durchführen von Transfern, falls die Applikationsthread anderweitig beschäftigt ist. Hierfür kontrolliert er die Progress-Engine – die zentrale Transferverwaltungsroutine, bis der Hauptthread innerhalb von NEON Wait() die Kontrolle übernimmt. Der Zweck dieses zusätzlichen Threads ist es, innerhalb von rechenintensiven Prozessen (wie zum Beispiel dem Zellularautomaten) asynchron Kommunikation zu betreiben. In einem eher kommunikationsintensiven Benchmark – wie dem Ping-Pong in eins – bleibt er aufgrund seiner niedrigen Priorität dauerhaft im Mutex und behindert die Ausführung nicht. 7.2.5. Nachrichtenmodi Ähnlich wie BMI, kennt auch NEON Eager - und Rendez-Vous-Nachrichten. Pakete ab einer Größe von 16 Kilobyte werden über ein echtes“ Rendez-Vous-Protokoll verschickt: ” Erst wenn die Bereitschaft zum Empfangen durch den Empfänger explizit bestätigt wurde, werden Daten verschickt. Als Optimierung wird jedoch, falls vom User kein Puffer bereitgestellt wurde, auf jeden Fall ein Puffer alloziert und dem Sender mittels einer speziellen Nachricht die Erlaubnis erteilt. Der Gedanke dahinter ist, dass ein memcpy() stets schneller ist, als das Warten und anschließende Empfangen. Analog zum Pipeline-Effekt wird versucht, möglichst früh die langsamste Schicht (also das Netzwerk) zu beschäftigen. Der Unterschied zu den Eager-Nachrichten ist, dass keine vorgefertigten Puffer benutzt werden können, deren Allokation schneller als ein malloc() ist. 88 7.3. Anwendungsbeispiel Wie in Abschnitt 6.3 für BMI sei nun ein Anwendungsbeispiel für NEON gezeigt. Abbildung 7.3 zeigt ebenfalls einen einfachen Server, der ankommende Nachrichten zurücksendet. Um das Beispiel einfach zu halten, ist es auf eine Größe festgelegt. Um beliebige Größen zu unterstützen, müssten zwei Speicherbereiche genutzt werden: Einer zum mitteilen der Puffergröße und einer zur eigentlichen Kommunikation. Wie beim BMI-Beispiel fehlen Sicherheitsabfragen, die in echter Software nicht fehlen dürfen. 7.4. Durchgeführte Optimierungen und Verbesserungen Da NEON deutlich jünger als BMI ist, ergaben sich an mehreren Stellen Möglichkeiten zur Optimierung. 7.4.1. Progress-Engine NEON bietet die Möglichkeit, dass mittels NEON Wait() der Hauptthread die Kontrolle über das Transfermanagement übernimmt. Die ursprüngliche Lösung dieses Problems bestand jedoch ungünstiger Weise darin, dass an zwei Stellen Code für Netzwerktransfer eingebaut wurde. Dies war schlecht, weil zweimal sehr ähnlicher Code vorhanden war (der sich gegenseitig Platz im Cache weg nahm) und auch die Komplexität der Verwaltung sehr hoch war. Um diese Probleme zu lösen, wurde eine Progress-Engine implementiert, die sowohl von dem Hauptthread, als auch von dem Kommunikationsthread aufgerufen wurde. In der Tat löste dies die größten Probleme, wegen denen NEON anfangs nicht funktionstüchtig war. 7.4.2. Locking Da NEON multithreaded ist, ist eine Synchronisation der Threads notwendig. Bei POSIX Threads sind die eingebauten Mutexes die übliche Methode, um dies zu erreichen. Traditionell bedeutete das Locken einer Mutexe, dass eine Systemcall aufgerufen werden musste und war somit sehr zeitintensiv. Dank Futexes [Dre05] (F ast U serspace Mutex ) ist dies nicht mehr der Fall und der Kernelspace wird nur betreten, wenn das Mutex nicht gelockt werden konnte. 89 #d e f i n e BUFFERSIZE 42 int myId , numProcs ; /∗ I n i t i a l i s i e r e NEON. , , myID ’ ’ e n t h a e l t a n s c h l i e s s e n d den Rang , ∗ und numProcs d i e Gesamtanzahl d e r P r o z e s s e . ∗/ NEON Init(& argc , &argv , &myId , &numProcs ) ; /∗ Berechne Rang von Kommunikationspartner . In diesem F a l l s o l l ∗ e s nur z w e i geben . ∗/ int otherRank = ( myId + 1 ) % 2 ; /∗ F u e l l e Array mit e r l a u b t e n Kommunikationspartnern . D e r z e i t ∗ d a r f nur j e w e i l s e i n Rank i n e i n e n g e p o s t e t e n P u f f e r ∗ s c h r e i b e n . ∗/ int t a r g e t s [ ] = { otherRank } ; /∗ F a l l s w i r d e r z w e i t e P r o z e s s s i n d , s i n d w i r d e r C l i e n t . ∗/ if ( myId == 1 ) client routine (); char b u f f e r [ BUFFERSIZE ] ; while ( t r u e ) { n e o n h a n d l e t post , put ; /∗ Poste den P u f f e r , , b u f f e r ’ ’ d e r G r o e s s e BUFFERSIZE ∗ und dem Tag , , 4 2 ’ ’ zum B e s c h r e i b e n durch den P a r t n e r ∗ ( , , t a r g e t s ’ ’ ) . S p e i c h e r e den S t a t u s i n , , s t a t u s ’ ’ . ∗/ p o s t = NEON Post ( b u f f e r , BUFFERSIZE, t a r g e t s , 4 2 , &s t a t u s ) ; /∗ Warte , b i s d e r Job , , p o s t ’ ’ f e r t i g i s t . S p e i c h e r e den ∗ S t a t u s i n , , s t a t u s ’ ’ . ∗/ NEON Wait(& post , &s t a t u s ) ; /∗ S c h r e i b e den I n h a l t von , , b u f f e r ’ ’ d e r G r o e s s e BUFFERSIZE ∗ vom O f f s e t , , 0 ’ ’ an , dem P a r t n e r ( , , otherRank ’ ’ ) mit dem ∗ Tag , , 4 2 ’ ’ i n den S p e i c h e r . S p e i c h e r e den S t a t u s i n ∗ , , s t a t u s ’ ’ . ∗/ put = NEON Put( b u f f e r , BUFFERSIZE, 0 , otherRank , 4 2 , &s t a t u s ) ; /∗ Warte , b i s d e r Job , , put ’ ’ f e r t i g i s t . S p e i c h e r e den ∗ S t a t u s i n , , s t a t u s ’ ’ . ∗/ NEON Wait(&put , &s t a t u s ) ; } Abbildung 7.3.: Ein einfacher Echo-Server mit NEON. 90 Trotzdem bedeutet unüberlegtes Locking unnötigen Overhead. Deshalb wurde es überdacht und nach Möglichkeit minimiert. Aufgrund des Gesamtdesigns wurde entschieden, dass wenige große Locks besser sind, als feingranulares Locken, welches eine höhere Parallelität ermöglichen würde, aber auch einen großen Overhead mit sich brächte. Da der Kommunikationsthread nur zum Einsatz kommen soll, wenn die Applikation sich nicht mit der Kommunikation befasst, ist eine niedrige Parallelität nicht von Nachteil. Dank der neuen zentralen Progress-Engine, war die Umsetzung dieses Ziels unproblematisch. 7.4.3. Speicherverwaltung Die Datenstruktur neon handle (also die Job-Struktur) muss bei jedem neuen Job neu angelegt werden. Für jeden Job wurde bisher jedes mal ein malloc() aufgerufen. Dies ist ungünstig, da malloc() eine aufwendige Funktion ist. Entsprechend entstanden – und entstehen – Alternativen. GNOME1 hat hierfür zum Beispiel das GSlice, mit dem das Scrolling im Terminal-Emulator um ein Vielfaches gegenüber den alten malloc()-Routinen beschleunigt werden konnte. GSlice ist jedoch unnötig komplex für dieses Anwendung. Für funktionslokale Daten wird mitunter auch die stackbasierte Funktion alloca() empfohlen. Von der ist jedoch aus Sicherheitsgründen (siehe [Spr06] und [Sch06a]) abzuraten. Da in diesem Fall stets gleich große Puffer alloziert werden, wurde eine eigene Speicherverwaltung mit vorallozierten Puffern geschrieben. Die Puffer werden in Listen verwaltet, so genannnten Pools. Wenn ein solcher Pool“ angelegt wird, bekommt die Funktion die ” Größe der zu allozierenden Puffer und ihre minimale und maximale Anzahl übergeben. Beim Allozieren durch die Pool-Funktionen werden die Puffer aus dem Pool entfernt und dem Aufrufer zurückgegeben. Wenn nötig, werden bis zum erlaubten Maximum (das gegebenenenfalls unendlich sein kann) neue hinzualloziert, die später anhand von Heuristiken wieder freigegeben werden: Bei der Freigabe eines Puffers wird – falls Puffer hinzualloziert wurden – überprüft, wieviel Prozent an unbenutzten Puffern es gibt. Ist ein einstellbarer Wert überschritten, wird der übergebene Puffer freigegeben und nicht zur Liste mit freien Puffer zurückgelegt. Da die temporären Puffer für Eager-Nachrichten ähnlich verwaltet wurden, wurden sie nun mit dieser allgemeineren Pufferverwaltung verschmolzen. Dies spart Code und ist somit cachefreundlicher. Messergebnisse bestätigten diese Logik: Die NEON-Messwerte 1 Ein verbreitetes Desktopsystem für UNIXe. Zu finden unter http://www.gnome.org/. 91 verbesserten sich um circa eine bis zwei µs. In einem reinen Allozierungsbenchmark – wo möglichst schnell Speicher der Größe eines neon handle alloziert wurde – ergab sich bei den Puffer-Funktionen eine Beschleunigung von über 80% gegenüber einem einfachen malloc(). 7.4.4. Compiler-Hints Sämtliche if()-Abfragen wurden untersucht und es wurde analysiert, ob es einen dominierenden Common Case gibt. Falls ein solcher gefunden wurde, wurde er mittels likely() beziehungsweise unlikely() für den Compiler markiert. 7.4.5. Auto-Tools Dies ist keine klassische Optimierung, vereinfacht und beschleunigt jedoch die Entwicklung. Das Buildsystem von NEON war sehr ineffektiv, da bei jedem Make-Aufruf alle Dateien neu übersetzt wurden. Es wurde nicht nur ein besseres Makefile geschrieben, sondern komplett ein neues Buildsystem erstellt, welches auf den GNU Tools Autoconf und Automake basiert. Dadurch ist das Buildsystem einfacher zu pflegen und zukünftig leichter zu erweitern. 7.4.6. Fazit Es wurde an vielen Stellen ausgebessert und optimiert. Leider ist es nur teilweise möglich zu sagen, inwiefern es sich auf die Gesamtperformance ausgewirkt hat. Insgesamt ist NEON jetzt jedoch schlanker, fehlerfreier und für die weitere Entwicklung entwicklerfreundlicher. 7.5. Messergebnisse Da NEON – im Gegensatz zu BMI – mehr als eine reine Netzwerkabstraktion ist, sind die reinen Latenz- und Bandbreitenmesswerte nur bedingt aussagekräftig. Aus diesem Grunde wurden zwei Arten von Analysen durchgeführt: 1. Klassische Bandbreiten- und Latenzmessungen mittels eins. Diese Werte wurden anschließend mit TCP und BMI-TCP verglichen. 92 Latency Send 1 - Receive 1 70 60 Time [us] 50 40 30 20 10 NEON BMI TCP Raw TCP 0 10 20 30 40 Message Size [bytes] 50 60 Abbildung 7.4.: Latenzen von TCP, BMI-TCP und NEON. 2. Als Beispiel für eine rechenintensive Anwendung wurde ein jeweils für NEON, für asynchrones MPI (MPI-Async) und für die OSC-Implementierung von MPI (MPIOSC) umgesetzter Zellularautomat verwendet und ausgemessen. Da die Rendez-Vous-Implementierung zum Messzeitpunkt noch fehlerhaft arbeitete, wurde sie abgeschaltet – beziehungsweise der Rendez-Vous-Threshold wurde oberhalb der Nachrichtengrößen, die genutzt wurden, eingestellt. 7.5.1. Bandbreiten und Latenzen Bei den Latenzen für geringe Werte in Abbildung 7.4 ist NEON teilweise um bis zu 3µs langsamer als BMI-TCP. Wie die Bandbreiten in Abbildung 7.5 zeigen, relativiert sich dieser Unterschied jedoch im weiteren Verlauf und NEON kann teilweise BMI-TCP überbieten. Insgesamt ist NEON in den relevanten Bereichen also ein wenig langsamer als BMITCP: Die Kurven verlaufen im Wesentlichen parallel, mit einem Abstand von wenigen Mikrosekunden. Der Vergleich ist hier jedoch nur der Vollständigkeit halber, da NEON einen anderen Anspruch als BMI hat. Entsprechend muss es sich gegen direkte Konkurenten durchsetzen – in diesem Fall MPI-Async und MPI-OSC. 93 Bandwidth Send 1 - Receive 1 30 Raw TCP BMI TCP NEON 25 Bandwidth [MB/s] 20 15 10 5 0 1 4 16 64 256 Message Size [bytes] 1024 4096 16384 Abbildung 7.5.: Bandbreiten von TCP, BMI-TCP und NEON. 7.5.2. Zellularautomat Zellularautomaten sind in Zellen eingeteilte, meist zweidimensionale, Felder. Der Zustand der einzelnen Zellen ändert sich mit jeder Iteration und hängt hierbei – definierbaren Regeln folgend – von ihren direkten Nachbarn ab. Der bekannteste Zellularautomat ist das Game of Life“ von John Conway aus dem Jahr 1970. ” Da für jede Iteration der Zustand aller Zellen neu berechnet werden muss, handelt es sich um eine sehr rechenintensive Anwendung. Weil die Berechnungen der Zellen jedoch nur von den jeweiligen Nachbarn abhängig sind, ist die Berechnung sehr gut parallelisierbar. Hierfür wird das Feld in Streifen partioniert und unter den Prozessen aufgeteilt. Um das komplette Feld zu berechnen, müssen sich benachbarte Streifen bei jeder Iteration über die unmittelbaren Grenzstreifen austauschen. Daraus folgt, dass je höher ein Feld ist, desto mehr muss gerechnet werden und je breiter ein Feld ist, desto mehr muss kommuniziert werden. Ein solcher Automat dient nun zum Vergleich von NEON, MPI-Async und MPI-OSC. Ursprünglich wurde er von Ingo Boesnach und Peter Sanders entwickelt, später jedoch von Lars Schneidenbach parallelisiert und für die drei Testfälle umgesetzt. Die Messung wurde mit jeweils 30.000 Iterationen für verschiedene Breiten und Höhen des Feldes durchgeführt. Die Höhen und Breiten wurden mit dem Hintergedanken aus- 94 Höhe Breite API 32 128 1024 16 NEON MPI-Async MPI-OSC 4,65 3,51 5,98 6,75 5,32 7,76 24,33 21,31 28,54 128 NEON MPI-Async MPI-OSC 5,40 4,51 6,82 12,93 12,20 13,98 128,20 125,59 128,65 512 NEON MPI-Async MPI-OSC 10,30 9,34 12,59 54,03 54,14 58,31 441,25 439,60 440,11 2048 NEON MPI-Async MPI-OSC 51,03 216,51 1679,67 54,54 217,58 1686,84 59,02 219,46 1686,56 4096 NEON MPI-Async MPI-OSC 116,88 424,82 3434,36 118,47 421,30 3422,51 122,09 429,66 3453,06 Tabelle 7.1.: Messergebnisse mit dem Zellularautomaten in Sekunden. gewählt, für alle interessante Größenordnungen repräsentative Werte zu erhalten. Es wurde jeweils der Mittelwert von fünf Messgängen eingetragen. Tabelle 7.1 zeigt die Ergebnisse. Die besten Werte sind jeweils grün , die schlechtesten rot unterlegt. Es fällt sofort auf, dass NEON dem MPI-OSC nahezu jederzeit überlegen ist. Im Gegensatz dazu, gibt es bei MPI-Async keine so eindeutige Aussage: Während MPIAsync bei geringem Rechenaufwand NEON überlegen ist, kann NEON mit wachsender Zeilenzahl für geringe Breiten mithalten und ist teilweise sogar schneller. 7.5.3. Zusammenfassung In einem reinen Bandbreiten- und Latenzvergleich mit BMI schneidet NEON leicht schlechter ab. Im Zellularautomaten zahlt sich der Kommunikationsthread aus, falls viel Rechenaufwand mit wenig Kommunikation zu bewältigen ist. Offensichtlich wird bei der Kommunikation eine echte Asynchronität erreicht. Der Netzwerkcode an sich ist jedoch langsamer. Gegenüber MPI-Async ist NEON nur für die beschriebenen Fälle schneller, gegenüber MPI-OSC fast immer. Von den untersuchten OSC-APIs ist NEON also für die meisten 95 Fälle das schnellste. 7.6. Laufzeitanalyse Für die Laufzeitanalyse sind letztendlich nur die Kommunikationsroutinen interessant, insofern wurde erneut mit eins von 1 bis 1400 Bytes – mit je 1000 Iterationen – gemessen und die Laufzeit untersucht. 7.6.1. Caches Obwohl die Messwerte von NEON tendenziell schwächer sind als die von BMI-TCP, sind die Cachewerte von NEON vorbildlich. Wie in Tabelle 7.2 zu sehen ist, ist die Missrate von NEON maximal 0, 1%. L1 Instruktionen Daten L2 0,1% 0,0% 0,1% 0,1% Tabelle 7.2.: Cachemissraten in NEON. An dieser Stelle kann also nichts mehr signifikant verbessert werden. Vielmehr muss bei weiteren Optimierungen aufgepasst werden, diesen Wert nicht spürbar zu verschlechtern. 7.6.2. Profiling Die Laufzeitverteilung in Tabelle 7.3 erinnert stark an die von BMI: Die Transferverwaltung nimmt die meiste Laufzeit ein. Entgegen BMI ist sie jedoch kürzer. Die Werte sind entsprechend auf weniger Funktionen verteilt und somit größer. Dass sich die Laufzeit auf weniger Funktionen verteilt, war bereits dank der Cacheanalyse abzusehen, denn die Missraten waren praktisch gleich Null. Unschön ist der sehr hohe Anteil von pool free() an der Laufzeit. pool free() ist die Freigabe von Speicherblöcken, die als schnellere Alternative zu malloc() und free() entwickelt wurden (siehe Abschnitt 7.4). Dass eine Speicherverwaltungsfunktion so weit oben steht, liegt daran, dass die Funktion (zusammen mit pool alloc()) sowohl von der Job-Verwaltung als auch für die Eager-Puffer verwendet wird. Die Freigabe von Speicher ist hierbei aufgrund der oben beschriebenen Freigabeheuristiken für zusätzlich allozierte Puffer komplexer als das Allozieren – deshalb steht pool free() vor pool alloc(). Da 96 Laufzeitanteil in % 13,76 11,17 8,82 8,09 7,58 6,70 6,40 4,71 4,27 2,75 2,72 2,68 2,65 2,62 2,35 1,40 1,28 1,19 Funktion progress engine() pool free() receive header() NEON Wait() internal send() remove putlist entry() NEON Put() NEON Repost() receive payload() set last putlist entry() create job() try to complete() internal receive() pool alloc() internal wait() set first taglist entry() update state() remove first taglist entry() Tabelle 7.3.: Laufzeitanteile in NEON. Nur Funktion mit Laufzeitanteil von mehr als 1%. 97 die privaten Speicherfunktionen letztendlich schneller als die von der glibc sind, ist eine so hohe Position nicht problematisch. 7.7. Fazit NEON kann die Bandbreiten und Latenzen von BMI-TCP nur teilweise erreichen. Die Untersuchungen des Codes ergaben jedoch eine vorbildliche Cachenutzung und kurze Codepfade. Ein Unterschied von 2 − 3µs ist jedoch praktisch unmöglich zu finden. Im Zellularautomaten konnte sich NEON souverän gegen MPIs OSC-Implementierung durchsetzen. Die asynchronen MPI-Funktionen hingegen zeigten sich für niedrige Höhen und hohe Breiten überlegen. NEONs größtes Manko ist derzeit noch die geringe Reife. Trotzdem ist es teilweise deutlich schneller als MPI-OSC. Sollte es also gelingen, NEON robuster zu machen, könnte es sich zur ersten Wahl unter den OSC-Implementierungen entwickeln. 98 Teil III. Abschluss 99 8. Zusammenfassung In dieser Arbeit wurde zunächst die allgemeine Analyse und Optimierung von Software exemplarisch anhand der Tools gprof, OProfile und Callgrind untersucht. Im Anschluss wurde im zweiten Teil das erlangte Wissen auf die Netzwerkschnittstellen BMI und NEON angewendet. BMI, beziehungsweise die untersuchte TCP-Methode BMI-TCP, wurde trotz des im Vergleich zu BMI-GAMMA größeren Overheads als sehr gut bewertet. Zusätzlicher Overhead konnte auf das notwendiger Weise komplexe Mapping eines streamorientierten Protokolls auf ein zuverlässiges Datagrammprotokoll zurückgeführt werden. NEON konnte angesichts des durchdachten Designs ebenfalls überzeugen. Es kann zwar nicht ganz die Latenzen und Bandbreiten von BMI-TCP erreichen, hat aber auch einen anderen Zweck. Im repräsentativeren Vergleich mit einem Zellularautomaten, der jeweils mit NEON, MPI-OSC und MPI-Async implementiert wurde, war NEON fast immer schneller als MPI-OSC und konnte teilweise die Werte von MPI-Async erreichen und überbieten. 101 102 9. Ausblick In der Analyse geht der Trend zu automatischen Analysetools, die auch Software zum Beispiel auf Sicherheitsmängel untersuchen können. Beispiele dafür sind Klocwork K7 oder Coverity Prevent. Trotzdem wird dem Entwickler stets Handarbeit und Interpretation abverlangt. Die Optimierung wird auch in Zukunft zum größten Teil manuell durchzuführen sein. Während es bisher nicht gelungen ist, das automatisierte Optimieren für die SIMDErweiterungen heutiger CPUs zu implementieren, entsteht gerade eine neue Herausforderung: Mit den neuen Cell-Prozessoren – wie sie auch zum Beispiel in der Playstation 3 eingesetzt werden sollen – ist es zur optimalen Ausnutzung notwendig, sämtliche Programme hochgradig zu parallelisieren. Dies ist automatisiert nicht möglich. BMI-TCP wird sich voraussichtlich nicht mehr im größeren Maße ändern. Abgesehen von Kleinigkeiten gibt es kaum noch Spielraum zur Verbesserung. Da UDP nicht als Alternative unter den IP-basierten Protokollen in Frage kommt, gilt es weiter – weniger gängige – Alternativen wie zum Beispiel SCTP [SXM+ 00] zu evaluieren. NEON ist eine interessante Alternative zu MPI-OSC. Es sind noch mehrere Erweiterungen, wie zum Beispiel die Möglichkeit, dass mehrere Prozesse in einen Puffer schreiben, angedacht. Falls diese umgesetzt werden und OSC weiter an Popularität gewinnt, könnte sich NEON als ernst zu nehmende Alternative für Kommunikation in parallelen Systemen etablieren. 103 104 Literaturverzeichnis [Cia99] Ciaccio, Giuseppe: A Communication System for Efficient Parallel Processing on Clusters of Personal Computers, DISI, Universita di Genova, Diss., 1999 [CLRS01] Cormen, Thomas H. ; Leiserson, Charles E. ; Rivest, Ronald L. ; Stein, Clifford: Introduction to Algorithms. 2. MIT Press, 2001 [Dre02] Drepper, Ulrich: libc/1427: gprof does not profile threads. http://groups. google.com/group/mlist.linux.kernel/msg/e438f26cd1269025, März 2002. – Ulrich Dreppers Äußerung zu ,,gprof”. [Dre05] Drepper, Ulrich: Futexes Are Tricky. Version: Dezember 2005. http: //people.redhat.com/drepper/futex.pdf. 2005. – Forschungsbericht [Duf88] Duff, Tom: Re: Explanation, please! http://www.lysator.liu.se/c/ duffs-device.html, August 1988. – Originalerklärung des ,,Duff’s Device” durch den Urheber. [EP00] Erk, Katrin ; Priese, Lutz: Theoretische Informatik. Springer-Verlag, 2000 [Hoc02] Hocevar, Sam: HOWTO: using gprof with multithreaded applicati- ons. http://sam.zoy.org/writings/programming/gprof.html, Dezember 2002 [Int02] R Architecture Software Developer’s Manual Intel (Hrsg.): IA-32 Intel° - Volume 2: Instruction Set Reference. http://www.intel.com/design/ pentium4/manuals/index_new.htm: Intel, 2002 [Kli05] Kling, Christoph: Design and Implementation of a Buffered Message Interface Method for the Lightweight Protocol GAMMA, Universität Potsdam, Diplomarbeit, März 2005 105 [KSS05] Kling, Christoph ; Schneidenbach, Lars ; Schnor, Bettina: A High Performance Gigabit Ethernet Messaging Method for PVFS, 2005 [Lei01] Leitner, Felix von: Optimizing with gcc 101. devel/optimizer-101.txt, Mai 2001 [Lei02] Leitner, Felix von: Wie man kleine und schnelle Software schreibt. http: //www.fefe.de/dietlibc/kleinesoftware.pdf, März 2002 [Lei03a] Leitner, Felix von: Benchmarking BSD and Linux. http://bulk.fefe. de/scalability/, Oktober 2003. – Skalierbarkeitsvergleich wichtigster freier UNIXe. [Lei03b] Leitner, Felix von: Scalable Network Programming. http://bulk.fefe. de/scalable-networking.pdf, Oktober 2003 [Lem01] Lemon, Jonathan: Kqueue: A generic and scalable event notification facility. http://people.freebsd.org/~jlemon/papers/kqueue.pdf, 2001 [Lib02] Libenzi, Davide: Improving (network) I/O performance . http://www. xmailserver.org/linux-patches/nio-improve.html, 2002 [lse04] Linux Scalability Effort: epoll Scalability Web Page. http://lse. sourceforge.net/epoll/index.html, 2004. – Netzwerkskalierbarkeitsver- http://www.fefe.de/ gleich [MPI95] MPI: A Message Passing Interface Standard. Juni 1995. – Message Passing Interface Forum [MPI97] MPI-2: Extensions to the Message Passing Interface. Juli 1997. – Message Passing Interface Forum [Num00] Techniques For Optimizing C Code. appsnotes/c_coding.html, 2000 [PH98] Patterson, David A. ; Hennessy, John L.: Computer Organization & Design. 2. Morgan Kaufmann Publishers, Inc., 1998 [Pos80] Postel, Jonathan: http://www.numerix-dsp.com/ User Datagram Protocol. RFC 768 (Standard). http://www.ietf.org/rfc/rfc768.txt. Version: August 1980 (Request for Comments) 106 [Pos81] Postel, Jonathan: Transmission Control Protocol. RFC 793 (Standard). http://www.ietf.org/rfc/rfc793.txt. Version: September 1981 (Request for Comments). – Updated by RFC 3168 [PVF02a] BMI Document. : BMI Document, Juli 2002. – Teil von PVFS2. Zu finden unter doc/design/bmi-design.tex im PVFS2-Archiv. [PVF02b] Flow Design Document. : Flow Design Document, Juli 2002. – Teil von PVFS2. Zu finden unter doc/design/flow-design.tex im PVFS2-Archiv. [Ray03] Raymond, Eric S.: The Art of Unix Programming. Addison-Wesley, 2003 [Sch03] Schneidenbach, Lars: Entwurf und Implementation einer SocketSchnittstelle für das Leichtgewichtprotokoll GAMMA, Universität Potsdam, Diplomarbeit, April 2003 [Sch06a] Schlawack, Hynek: A small follow-up on stack-based allocations. http: //blog.ox.cx/archives/180-guid.html. Version: August 2006 [Sch06b] Schneidenbach, Lars: NEON, A New Efficient One-sided communications iNterface. 2006 [Spr06] Sprundel, Ilja van: alloca is evil. http://blogs.23.nu/ilja/stories/ 12578/. Version: August 2006 [SS06] Schneidenbach, Lars ; Schnor, Bettina: Design Issues in the Implementation of MPI2 One Sided Communication in Ethernet based Networks / Universität Potsdam. 2006 (ISSN 0946-7580, TR-2006-09). – Forschungsbericht [Ste98] Stevens, W. R.: UNIX Network Programming, Networking APIs: Sockets and XTI. 2. Prentince Hall PTR, 1998 [SXM+ 00] Stewart, R. ; Xie, Q. ; Morneault, K. ; Sharp, C. ; Schwarzbauer, H. ; Taylor, T. ; Rytina, I. ; Kalla, M. ; Zhang, L. ; Paxson, V.: Stream Control Transmission Protocol. RFC 2960 (Proposed Standard). http://www.ietf.org/rfc/rfc2960.txt. Version: Oktober 2000 (Request for Comments). – Updated by RFC 3309 [WC00] Wadleigh, Kevin R. ; Crawford, Isom L.: Software Optimization for High-Performance Computing. Prentice Hall PTR, 2000 107 [WKM+ 98] Wang, R. ; Krishnamurthy, A. ; Martin, R. ; Anderson, T. ; Culler, D.: Modeling and Optimizing Communication Pipelines. In: Proceedings of the 1998 Conference on Measurement and Modeling of Computer Systems (SIGMETRICS), 1998. – Madison 108 Index -ffast-math, 39 -fomit-frame-pointer, 28 -fprofile-generate, 39 -fprofile-use, 39 -funroll-loops, 42 likely(), 40, 92 unlikely(), 40, 92 3DNow, 39 Alan Cox, 17 Algorithmus, 19, 40 alloca(), 91 AMD, 39 API, 21, 47, 62 asynchron, 59 BMI post recv list(), 76 BMI post send(), 60, 65 BMI BMI BMI BMI post send list(), 76 socket collection testglobal(), 74 sockio nbvector(), 78 TCP BUFFERING, 65, 67 BMI BMI BMI BMI BMI TCP COMPLETE, 65, 67 TCP DEC HDR(), 59 TCP ENC HDR(), 59 TCP INPROGRESS, 65, 67 test(), 60, 65, 71, 73, 80, 88 BMI testcontext(), 65 BMI testunexpected(), 63 BMI wait(), 65 Bubblesort, 20, 40 Autoconf, 92 Automake, 92 Best Case, 56 Bloat, 17, 41 BMI, 15, 47, 49, 53 BMI-GAMMA, 15, 54, 78 BMI-Methode, 53 BMI-TCP, 15, 16, 53 BMI addr lookup, 56 BMI address lookup(), 64 BMI cancel(), 59 BMI post recv(), 63, 67 Cachemiss, 30 Callgrind, 47, 49 Common Case, 18 Compiler, 19 Compiler-Hints, 39 Completion Queue, 65, 67, 69 connect(), 57 Context, 65 CPU CLK UNHALTED, 28, 77 Datenstruktur, 20, 40 Eager, 61, 88 109 edge triggered, 62 eins, 47, 49, 53, 96 enqueue operation(), 69 epoll, 62, 74 Event, 62 Eventsystem, 62 Expected Message, 63 Flaschenhals, 17, 23, 37, 74, 77 Flow, 57, 76 Futex, 89 GAMMA, 15, 54, 78 gamma test(), 80 gamma wait(), 80 GCC, 38 GNU Emacs, 47 Hash, 60 hash key(), 78 Kqueue, 62 Landau-Notation, 20 Lars Schneidenbach, 85 level triggered, 62 Linker, 19 malloc(), 88, 91, 92 memcpy(), 88 method addr, 56, 64 method op, 60 MMX, 38, 39 MPI, 85, 87 NEON, 16, 47, 49 NEON Post(), 87 NEON Put(), 87 NEON Test(), 87 NEON Wait(), 88, 89 non-blocking, 59, 61 Header, 59 opcontrol, 28 Heapsort, 20 Operation Queues, 64 I/O-Multiplexing, 62, 63 icc, 39 Immediate Completion, 60, 65, 67, 71, 80 Operationsverwaltung, 64, 84 OProfile, 47, 49 OSC, 16, 49, 85 IND COMPLETE RECV UNEXP, 65 payload progress(), 74 IND RECV, 64, 67 Pipeline-Effekt, 82, 88 IND RECV EAGER DONE BUFFERING, 65 poll(), 62, 74 IND RECV INFLIGHT, 64, 67 pool free(), 96 IND SEND, 64, 67 Pools, 91 InfiniBand, 15 POSIX Asynchronous IO, 60 Inline-Funktion, 19 Progress-Engine, 89 Intel, 39 PVFS, 15 Job, 88 Quicksort, 40 110 pool alloc(), 96 RDP, 53 readv(), 61, 74 Rendez-Vous, 61, 67, 88 XTI, 15 Zellularautomat, 49 SCTP, 103 select(), 62 SIMD, 39, 42 SMP, 45, 54, 74 Socket, 60 socketcollection, 62, 63 Sockets, 15, 47, 49, 61, 62, 69, 87 sockio, 61 sockping, 47 Speicherbus, 17 SSE, 38, 39 statisches Linken, 19 strip, 28 tcp address, 64 tcp do work(), 73 tcp msg header, 59 TCP NODELAY, 84 tcp op, 60, 65 Thread, 88 Threads, 89 TLI, 15 Transferverwaltung, 64 UDP, 80, 84, 103 Unexpected Message, 63, 64 Vektorisierung, 39 Verbindungsaufbau, 57 Windows Sockets, 15 work on recv op(), 73 work on send op(), 73 writev(), 61, 64, 69, 74 111