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

Documentos relacionados